mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Feature: consumption templates (#4196)
* Initial implementation of consumption templates * Frontend implementation of consumption templates Testing * Support consumption template source * order templates, automatically add permissions * Support title assignment in consumption templates * Refactoring, filters to and, show sources on list Show sources on template list, update some translation strings Make filters and minor testing * Update strings * Only update django-multiselectfield * Basic docs, document some methods * Improve testing coverage, template multi-assignment merges
This commit is contained in:
parent
86d223fd93
commit
9712ac109d
2
Pipfile
2
Pipfile
@ -3,7 +3,6 @@ url = "https://pypi.python.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
|
||||
[packages]
|
||||
dateparser = "~=1.1"
|
||||
# WARNING: django does not use semver.
|
||||
@ -51,6 +50,7 @@ pdf2image = "*"
|
||||
flower = "*"
|
||||
bleach = "*"
|
||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||
django-multiselectfield = "*"
|
||||
|
||||
[dev-packages]
|
||||
# Linting
|
||||
|
11
Pipfile.lock
generated
11
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "ac966c7a02e216e5198e13857f2701fd5e9b1c4dbb39ad151889f8a4d8cd8711"
|
||||
"sha256": "973d5669b7774b4af56a55be874d496dc5d6fb751761bda5749410a4bce57e5c"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {},
|
||||
@ -429,7 +429,6 @@
|
||||
"sha256:cac9df0ba87b4f439e1a311ef22f75c938fc874bebf1fbabaed58d0e6d559a25"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==4.1.11"
|
||||
},
|
||||
"django-celery-results": {
|
||||
@ -484,6 +483,14 @@
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==2.4.0"
|
||||
},
|
||||
"django-multiselectfield": {
|
||||
"hashes": [
|
||||
"sha256:c270faa7f80588214c55f2d68cbddb2add525c2aa830c216b8a198de914eb470",
|
||||
"sha256:d0a4c71568fb2332c71478ffac9f8708e01314a35cf923dfd7a191343452f9f9"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.1.12"
|
||||
},
|
||||
"djangorestframework": {
|
||||
"hashes": [
|
||||
"sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8",
|
||||
|
@ -261,6 +261,62 @@ These can be found under Settings > Users & Groups, assuming the user has access
|
||||
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
|
||||
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
|
||||
|
||||
## Consumption templates
|
||||
|
||||
Consumption templates were introduced in v2.0 and allow for finer control over what metadata (tags, doc
|
||||
types) and permissions (owner, privileges) are assigned to documents during consumption. In general,
|
||||
templates are applied sequentially (by sort order) but subsequent templates will never override an
|
||||
assignment from a preceding template. The same is true for mail rules, e.g. if you set the correspondent
|
||||
in a mail rule any subsequent consumption templates that are applied _will not_ overwrite this. The
|
||||
exception to this is assignments that can be multiple e.g. tags and permissions, which will be merged.
|
||||
|
||||
Consumption templates allow you to filter by:
|
||||
|
||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||
example, automatically assigning documents to different owners based on the upload directory.
|
||||
- Mail rule. Choosing this option will force 'mail fetch' to be the template source.
|
||||
|
||||
!!! note
|
||||
|
||||
You must include a file name filter, a path filter or a mail rule filter. Use * for either to apply
|
||||
to all files.
|
||||
|
||||
Consumption templates can assign:
|
||||
|
||||
- Title, see [title placeholders](/usage#title_placeholders) below
|
||||
- Tags, correspondent, document types
|
||||
- Document owner
|
||||
- View and / or edit permissions to users or groups
|
||||
|
||||
### Consumption template permissions
|
||||
|
||||
All users who have application permissions for editing consumption templates can see the same set
|
||||
of templates. In other words, templates themselves intentionally do not have an owner or permissions.
|
||||
|
||||
Given their potentially far-reaching capabilities, you may want to restrict access to templates.
|
||||
|
||||
Upon migration, existing installs will grant access to consumption templates to users who can add
|
||||
documents (and superusers who can always access all parts of the app).
|
||||
|
||||
### Title placeholders
|
||||
|
||||
Consumption template titles can include placeholders, _only for items that are assigned within the template_.
|
||||
This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
||||
applied. You can use the following placeholders:
|
||||
|
||||
- `{correspondent}`: assigned correspondent name
|
||||
- `{document_type}`: assigned document type name
|
||||
- `{owner_username}`: assigned owner username
|
||||
- `{added}`: added datetime
|
||||
- `{added_year}`: added year
|
||||
- `{added_year_short}`: added year
|
||||
- `{added_month}`: added month
|
||||
- `{added_month_name}`: added month name
|
||||
- `{added_month_name_short}`: added month short name
|
||||
- `{added_day}`: added day
|
||||
|
||||
## Best practices {#basic-searching}
|
||||
|
||||
Paperless offers a couple tools that help you organize your document
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,7 @@ import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
} from './services/permissions.service'
|
||||
import { ConsumptionTemplatesListComponent } from './components/manage/consumption-templates-list/consumption-templates-list.component'
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
@ -182,7 +183,17 @@ export const routes: Routes = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{ path: 'tasks', component: TasksComponent },
|
||||
{
|
||||
path: 'templates',
|
||||
component: ConsumptionTemplatesListComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.ConsumptionTemplate,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
@ -95,6 +95,8 @@ import { UsernamePipe } from './pipes/username.pipe'
|
||||
import { LogoComponent } from './components/common/logo/logo.component'
|
||||
import { IsNumberPipe } from './pipes/is-number.pipe'
|
||||
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
|
||||
import { ConsumptionTemplatesListComponent } from './components/manage/consumption-templates-list/consumption-templates-list.component'
|
||||
import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
|
||||
import localeAf from '@angular/common/locales/af'
|
||||
import localeAr from '@angular/common/locales/ar'
|
||||
@ -233,6 +235,8 @@ function initializeApp(settings: SettingsService) {
|
||||
LogoComponent,
|
||||
IsNumberPipe,
|
||||
ShareLinksDropdownComponent,
|
||||
ConsumptionTemplatesListComponent,
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
@ -155,6 +155,13 @@
|
||||
</svg><span> <ng-container i18n>Storage paths</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }">
|
||||
<a class="nav-link" routerLink="templates" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Consumption templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#file-earmark-ruled"/>
|
||||
</svg><span> <ng-container i18n>Templates</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" tourAnchor="tour.file-tasks">
|
||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<span *ngIf="tasksService.failedFileTasks.length > 0 && slimSidebarEnabled" class="badge bg-danger position-absolute top-0 end-0">{{tasksService.failedFileTasks.length}}</span>
|
||||
|
@ -0,0 +1,92 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-number i18n-title title="Sort order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h5 class="border-bottom pb-2" i18n>Filters</h5>
|
||||
<p class="small" i18n>Process documents that match <em>all</em> filters specified below.</p>
|
||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.filter_filename"></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h5 class="border-bottom pb-2" i18n>Assignments</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#consumption-templates'>documentation</a>." [error]="error?.assign_title"></pngx-input-text>
|
||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
|
||||
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
|
||||
<div>
|
||||
<label class="form-label" i18n>Assign view permissions</label>
|
||||
<div class="mb-2">
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-label" i18n>Assign edit permissions</label>
|
||||
<div>
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<span class="text-danger" *ngIf="error?.non_field_errors"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
|
||||
</div>
|
||||
</form>
|
@ -0,0 +1,125 @@
|
||||
import { HttpClientTestingModule } 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 { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
|
||||
import { PermissionsUserComponent } from '../../input/permissions/permissions-user/permissions-user.component'
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
import { TagsComponent } from '../../input/tags/tags.component'
|
||||
import { TextComponent } from '../../input/text/text.component'
|
||||
import { EditDialogMode } from '../edit-dialog.component'
|
||||
import { ConsumptionTemplateEditDialogComponent } from './consumption-template-edit-dialog.component'
|
||||
|
||||
describe('ConsumptionTemplateEditDialogComponent', () => {
|
||||
let component: ConsumptionTemplateEditDialogComponent
|
||||
let settingsService: SettingsService
|
||||
let fixture: ComponentFixture<ConsumptionTemplateEditDialogComponent>
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
IfPermissionsDirective,
|
||||
IfOwnerDirective,
|
||||
SelectComponent,
|
||||
TextComponent,
|
||||
NumberComponent,
|
||||
TagsComponent,
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
SafeHtmlPipe,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
{
|
||||
provide: CorrespondentService,
|
||||
useValue: {
|
||||
listAll: () =>
|
||||
of({
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'c1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DocumentTypeService,
|
||||
useValue: {
|
||||
listAll: () =>
|
||||
of({
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'dt1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StoragePathService,
|
||||
useValue: {
|
||||
listAll: () =>
|
||||
of({
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'sp1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MailRuleService,
|
||||
useValue: {
|
||||
listAll: () =>
|
||||
of({
|
||||
results: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgSelectModule,
|
||||
NgbModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(ConsumptionTemplateEditDialogComponent)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||
component = fixture.componentInstance
|
||||
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
component.dialogMode = EditDialogMode.CREATE
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||
fixture.detectChanges()
|
||||
expect(createTitleSpy).toHaveBeenCalled()
|
||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||
component.dialogMode = EditDialogMode.EDIT
|
||||
fixture.detectChanges()
|
||||
expect(editTitleSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
@ -0,0 +1,115 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { FormGroup, FormControl } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { first } from 'rxjs'
|
||||
import {
|
||||
DocumentSource,
|
||||
PaperlessConsumptionTemplate,
|
||||
} from 'src/app/data/paperless-consumption-template'
|
||||
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
|
||||
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
|
||||
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
|
||||
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.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'
|
||||
import { EditDialogComponent } from '../edit-dialog.component'
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule'
|
||||
|
||||
export const DOCUMENT_SOURCE_OPTIONS = [
|
||||
{
|
||||
id: DocumentSource.ConsumeFolder,
|
||||
name: $localize`Consume Folder`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.ApiUpload,
|
||||
name: $localize`API Upload`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.MailFetch,
|
||||
name: $localize`Mail Fetch`,
|
||||
},
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-consumption-template-edit-dialog',
|
||||
templateUrl: './consumption-template-edit-dialog.component.html',
|
||||
styleUrls: ['./consumption-template-edit-dialog.component.scss'],
|
||||
})
|
||||
export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent<PaperlessConsumptionTemplate> {
|
||||
templates: PaperlessConsumptionTemplate[]
|
||||
correspondents: PaperlessCorrespondent[]
|
||||
documentTypes: PaperlessDocumentType[]
|
||||
storagePaths: PaperlessStoragePath[]
|
||||
mailRules: PaperlessMailRule[]
|
||||
|
||||
constructor(
|
||||
service: ConsumptionTemplateService,
|
||||
activeModal: NgbActiveModal,
|
||||
correspondentService: CorrespondentService,
|
||||
documentTypeService: DocumentTypeService,
|
||||
storagePathService: StoragePathService,
|
||||
mailRuleService: MailRuleService,
|
||||
userService: UserService,
|
||||
settingsService: SettingsService
|
||||
) {
|
||||
super(service, activeModal, userService, settingsService)
|
||||
|
||||
correspondentService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
|
||||
documentTypeService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
|
||||
storagePathService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
|
||||
mailRuleService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.mailRules = result.results))
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
return $localize`Create new consumption template`
|
||||
}
|
||||
|
||||
getEditTitle() {
|
||||
return $localize`Edit consumption template`
|
||||
}
|
||||
|
||||
getForm(): FormGroup {
|
||||
return new FormGroup({
|
||||
name: new FormControl(null),
|
||||
account: new FormControl(null),
|
||||
filter_filename: new FormControl(null),
|
||||
filter_path: new FormControl(null),
|
||||
filter_mailrule: new FormControl(null),
|
||||
order: new FormControl(null),
|
||||
sources: new FormControl([]),
|
||||
assign_title: new FormControl(null),
|
||||
assign_tags: new FormControl([]),
|
||||
assign_owner: new FormControl(null),
|
||||
assign_document_type: new FormControl(null),
|
||||
assign_correspondent: new FormControl(null),
|
||||
assign_storage_path: new FormControl(null),
|
||||
assign_view_users: new FormControl([]),
|
||||
assign_view_groups: new FormControl([]),
|
||||
assign_change_users: new FormControl([]),
|
||||
assign_change_groups: new FormControl([]),
|
||||
})
|
||||
}
|
||||
|
||||
get sourceOptions() {
|
||||
return DOCUMENT_SOURCE_OPTIONS
|
||||
}
|
||||
}
|
@ -26,11 +26,13 @@
|
||||
<div class="col-md-4">
|
||||
<pngx-input-select i18n-title title="Action" [items]="actionOptions" formControlName="action" i18n-hint hint="Action is only performed when documents are consumed from the mail. Mails without attachments remain entirely untouched."></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Action parameter" *ngIf="showActionParamField" formControlName="action_parameter" [error]="error?.action_parameter"></pngx-input-text>
|
||||
<p class="small fst-italic mt-5" i18n>Assignments specified here will supersede any consumption templates.</p>
|
||||
<pngx-input-select i18n-title title="Assign title from" [items]="metadataTitleOptions" formControlName="assign_title_from"></pngx-input-select>
|
||||
<pngx-input-tags [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
|
||||
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
|
||||
<pngx-input-select *ngIf="showCorrespondentField" i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||
<pngx-input-check i18n-title title="Assign owner from rule" formControlName="assign_owner_from_rule"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -15,6 +15,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { CheckComponent } from '../../input/check/check.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
@ -27,9 +28,6 @@ describe('MailRuleEditDialogComponent', () => {
|
||||
let component: MailRuleEditDialogComponent
|
||||
let settingsService: SettingsService
|
||||
let fixture: ComponentFixture<MailRuleEditDialogComponent>
|
||||
let accountService: MailAccountService
|
||||
let correspondentService: CorrespondentService
|
||||
let documentTypeService: DocumentTypeService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@ -43,6 +41,7 @@ describe('MailRuleEditDialogComponent', () => {
|
||||
NumberComponent,
|
||||
TagsComponent,
|
||||
SafeHtmlPipe,
|
||||
CheckComponent,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
|
@ -79,6 +79,10 @@ const METADATA_TITLE_OPTIONS = [
|
||||
id: MailMetadataTitleOption.FromFilename,
|
||||
name: $localize`Use attachment filename as title`,
|
||||
},
|
||||
{
|
||||
id: MailMetadataTitleOption.None,
|
||||
name: $localize`Do not assign title from this rule`,
|
||||
},
|
||||
]
|
||||
|
||||
const METADATA_CORRESPONDENT_OPTIONS = [
|
||||
@ -168,6 +172,7 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<PaperlessMa
|
||||
MailMetadataCorrespondentOption.FromNothing
|
||||
),
|
||||
assign_correspondent: new FormControl(null),
|
||||
assign_owner_from_rule: new FormControl(true),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col-md-4">
|
||||
<pngx-input-text i18n-title title="Username" formControlName="username" [error]="error?.username"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Email" formControlName="email" [error]="error?.email"></pngx-input-text>
|
||||
<pngx-input-password i18n-title title="Password" formControlName="password" [error]="error?.password"></pngx-input-password>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled">
|
||||
<label class="form-label" for="tags" i18n>Tags</label>
|
||||
<label class="form-label" for="tags" i18n>{{title}}</label>
|
||||
|
||||
<div class="input-group flex-nowrap">
|
||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||
@ -7,7 +7,7 @@
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="false"
|
||||
[clearSearchOnAdd]="true"
|
||||
[hideSelected]="true"
|
||||
[hideSelected]="tags.length > 0"
|
||||
[addTag]="allowCreate ? createTagRef : false"
|
||||
addTagText="Add tag"
|
||||
i18n-addTagText
|
||||
|
@ -59,6 +59,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
})
|
||||
}
|
||||
|
||||
@Input()
|
||||
title = $localize`Tags`
|
||||
|
||||
@Input()
|
||||
disabled = false
|
||||
|
||||
|
@ -0,0 +1,33 @@
|
||||
<pngx-page-header title="Consumption Templates">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ConsumptionTemplate }">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Template</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<table class="table table-striped align-middle border shadow-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" i18n>Name</th>
|
||||
<th scope="col" i18n>Sort order</th>
|
||||
<th scope="col" i18n>Document Sources</th>
|
||||
<th scope="col" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let template of templates">
|
||||
<td scope="row"><button class="btn btn-link p-0" type="button" (click)="editTemplate(template)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.ConsumptionTemplate)">{{template.name}}</button></td>
|
||||
<td scope="row"><code>{{template.order}}</code></td>
|
||||
<td scope="row">{{getSourceList(template)}}</td>
|
||||
<td scope="row">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-primary" type="button" (click)="editTemplate(template)" i18n>Edit</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)" i18n>Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div *ngIf="templates.length === 0" i18n>No templates defined.</div>
|
@ -0,0 +1,175 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbPaginationModule,
|
||||
NgbModalRef,
|
||||
NgbModalModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import {
|
||||
DocumentSource,
|
||||
PaperlessConsumptionTemplate,
|
||||
} from 'src/app/data/paperless-consumption-template'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ConsumptionTemplatesListComponent } from './consumption-templates-list.component'
|
||||
import { ConsumptionTemplateEditDialogComponent } from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
|
||||
const templates: PaperlessConsumptionTemplate[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Template 1',
|
||||
order: 0,
|
||||
sources: [
|
||||
DocumentSource.ConsumeFolder,
|
||||
DocumentSource.ApiUpload,
|
||||
DocumentSource.MailFetch,
|
||||
],
|
||||
filter_filename: 'foo',
|
||||
filter_path: 'bar',
|
||||
assign_tags: [1, 2, 3],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Template 2',
|
||||
order: 1,
|
||||
sources: [DocumentSource.MailFetch],
|
||||
filter_filename: null,
|
||||
filter_path: 'foo/bar',
|
||||
assign_owner: 1,
|
||||
},
|
||||
]
|
||||
|
||||
describe('ConsumptionTemplatesComponent', () => {
|
||||
let component: ConsumptionTemplatesListComponent
|
||||
let fixture: ComponentFixture<ConsumptionTemplatesListComponent>
|
||||
let consumptionTemplateService: ConsumptionTemplateService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
ConsumptionTemplatesListComponent,
|
||||
IfPermissionsDirective,
|
||||
PageHeaderComponent,
|
||||
ConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
currentUserCan: () => true,
|
||||
currentUserHasObjectPermissions: () => true,
|
||||
currentUserOwnsObject: () => true,
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgbPaginationModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbModalModule,
|
||||
],
|
||||
})
|
||||
|
||||
consumptionTemplateService = TestBed.inject(ConsumptionTemplateService)
|
||||
jest.spyOn(consumptionTemplateService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: templates.length,
|
||||
all: templates.map((o) => o.id),
|
||||
results: templates,
|
||||
})
|
||||
)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
|
||||
fixture = TestBed.createComponent(ConsumptionTemplatesListComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support create, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[0]
|
||||
createButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog =
|
||||
modal.componentInstance as ConsumptionTemplateEditDialogComponent
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(templates[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support edit, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const editButton = fixture.debugElement.queryAll(By.css('button'))[1]
|
||||
editButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog =
|
||||
modal.componentInstance as ConsumptionTemplateEditDialogComponent
|
||||
expect(editDialog.object).toEqual(templates[0])
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error editing item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(templates[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support delete, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const deleteSpy = jest.spyOn(consumptionTemplateService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]
|
||||
deleteButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
|
||||
// fail first
|
||||
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
@ -0,0 +1,109 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { PaperlessConsumptionTemplate } from 'src/app/data/paperless-consumption-template'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
DOCUMENT_SOURCE_OPTIONS,
|
||||
} from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-consumption-templates-list',
|
||||
templateUrl: './consumption-templates-list.component.html',
|
||||
styleUrls: ['./consumption-templates-list.component.scss'],
|
||||
})
|
||||
export class ConsumptionTemplatesListComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
public templates: PaperlessConsumptionTemplate[] = []
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
private consumptionTemplateService: ConsumptionTemplateService,
|
||||
public permissionsService: PermissionsService,
|
||||
private modalService: NgbModal,
|
||||
private toastService: ToastService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.consumptionTemplateService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((r) => {
|
||||
this.templates = r.results
|
||||
})
|
||||
}
|
||||
|
||||
getSourceList(template: PaperlessConsumptionTemplate): string {
|
||||
return template.sources
|
||||
.map((id) => DOCUMENT_SOURCE_OPTIONS.find((s) => s.id === id).name)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
editTemplate(rule: PaperlessConsumptionTemplate) {
|
||||
const modal = this.modalService.open(
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
{
|
||||
backdrop: 'static',
|
||||
size: 'xl',
|
||||
}
|
||||
)
|
||||
modal.componentInstance.dialogMode = rule
|
||||
? EditDialogMode.EDIT
|
||||
: EditDialogMode.CREATE
|
||||
modal.componentInstance.object = rule
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newTemplate) => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Saved template "${newTemplate.name}".`
|
||||
)
|
||||
this.consumptionTemplateService.clearCache()
|
||||
this.reload()
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving template.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
deleteTemplate(rule: PaperlessConsumptionTemplate) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete template`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this template.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.consumptionTemplateService.delete(rule).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Deleted template`)
|
||||
this.consumptionTemplateService.clearCache()
|
||||
this.reload()
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error deleting template.`, e)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
41
src-ui/src/app/data/paperless-consumption-template.ts
Normal file
41
src-ui/src/app/data/paperless-consumption-template.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export enum DocumentSource {
|
||||
ConsumeFolder = 1,
|
||||
ApiUpload = 2,
|
||||
MailFetch = 3,
|
||||
}
|
||||
|
||||
export interface PaperlessConsumptionTemplate extends ObjectWithId {
|
||||
name: string
|
||||
|
||||
order: number
|
||||
|
||||
sources: DocumentSource[]
|
||||
|
||||
filter_filename: string
|
||||
|
||||
filter_path?: string
|
||||
|
||||
filter_mailrule?: number // PaperlessMailRule.id
|
||||
|
||||
assign_title?: string
|
||||
|
||||
assign_tags?: number[] // PaperlessTag.id
|
||||
|
||||
assign_document_type?: number // PaperlessDocumentType.id
|
||||
|
||||
assign_correspondent?: number // PaperlessCorrespondent.id
|
||||
|
||||
assign_storage_path?: number // PaperlessStoragePath.id
|
||||
|
||||
assign_owner?: number // PaperlessUser.id
|
||||
|
||||
assign_view_users?: number[] // [PaperlessUser.id]
|
||||
|
||||
assign_view_groups?: number[] // [PaperlessGroup.id]
|
||||
|
||||
assign_change_users?: number[] // [PaperlessUser.id]
|
||||
|
||||
assign_change_groups?: number[] // [PaperlessGroup.id]
|
||||
}
|
@ -22,6 +22,7 @@ export enum MailAction {
|
||||
export enum MailMetadataTitleOption {
|
||||
FromSubject = 1,
|
||||
FromFilename = 2,
|
||||
None = 3,
|
||||
}
|
||||
|
||||
export enum MailMetadataCorrespondentOption {
|
||||
@ -67,4 +68,6 @@ export interface PaperlessMailRule extends ObjectWithPermissions {
|
||||
assign_correspondent_from?: MailMetadataCorrespondentOption
|
||||
|
||||
assign_correspondent?: number // PaperlessCorrespondent.id
|
||||
|
||||
assign_owner_from_rule: boolean
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export interface PaperlessUser extends ObjectWithId {
|
||||
|
@ -252,6 +252,10 @@ describe('PermissionsService', () => {
|
||||
'view_sharelink',
|
||||
'change_sharelink',
|
||||
'delete_sharelink',
|
||||
'add_consumptiontemplate',
|
||||
'view_consumptiontemplate',
|
||||
'change_consumptiontemplate',
|
||||
'delete_consumptiontemplate',
|
||||
],
|
||||
{
|
||||
username: 'testuser',
|
||||
|
@ -25,6 +25,7 @@ export enum PermissionType {
|
||||
Group = '%s_group',
|
||||
Admin = '%s_logentry',
|
||||
ShareLink = '%s_sharelink',
|
||||
ConsumptionTemplate = '%s_consumptiontemplate',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
@ -0,0 +1,64 @@
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
import { ConsumptionTemplateService } from './consumption-template.service'
|
||||
import {
|
||||
DocumentSource,
|
||||
PaperlessConsumptionTemplate,
|
||||
} from 'src/app/data/paperless-consumption-template'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: ConsumptionTemplateService
|
||||
const endpoint = 'consumption_templates'
|
||||
const templates: PaperlessConsumptionTemplate[] = [
|
||||
{
|
||||
name: 'Template 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
filter_filename: '*test*',
|
||||
filter_path: null,
|
||||
sources: [DocumentSource.ApiUpload],
|
||||
assign_correspondent: 2,
|
||||
},
|
||||
{
|
||||
name: 'Template 2',
|
||||
id: 2,
|
||||
order: 2,
|
||||
filter_filename: null,
|
||||
filter_path: '/test/',
|
||||
sources: [DocumentSource.ConsumeFolder, DocumentSource.ApiUpload],
|
||||
assign_document_type: 1,
|
||||
},
|
||||
]
|
||||
|
||||
// run common tests
|
||||
commonAbstractPaperlessServiceTests(
|
||||
'consumption_templates',
|
||||
ConsumptionTemplateService
|
||||
)
|
||||
|
||||
describe(`Additional service tests for ConsumptionTemplateService`, () => {
|
||||
it('should reload', () => {
|
||||
service.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
|
||||
)
|
||||
req.flush({
|
||||
results: templates,
|
||||
})
|
||||
expect(service.allTemplates).toEqual(templates)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Dont need to setup again
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(ConsumptionTemplateService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
httpTestingController.verify()
|
||||
})
|
||||
})
|
42
src-ui/src/app/services/rest/consumption-template.service.ts
Normal file
42
src-ui/src/app/services/rest/consumption-template.service.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { tap } from 'rxjs'
|
||||
import { PaperlessConsumptionTemplate } from 'src/app/data/paperless-consumption-template'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsumptionTemplateService extends AbstractPaperlessService<PaperlessConsumptionTemplate> {
|
||||
loading: boolean
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
super(http, 'consumption_templates')
|
||||
}
|
||||
|
||||
public reload() {
|
||||
this.loading = true
|
||||
this.listAll().subscribe((r) => {
|
||||
this.templates = r.results
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
private templates: PaperlessConsumptionTemplate[] = []
|
||||
|
||||
public get allTemplates(): PaperlessConsumptionTemplate[] {
|
||||
return this.templates
|
||||
}
|
||||
|
||||
create(o: PaperlessConsumptionTemplate) {
|
||||
return super.create(o).pipe(tap(() => this.reload()))
|
||||
}
|
||||
|
||||
update(o: PaperlessConsumptionTemplate) {
|
||||
return super.update(o).pipe(tap(() => this.reload()))
|
||||
}
|
||||
|
||||
delete(o: PaperlessConsumptionTemplate) {
|
||||
return super.delete(o).pipe(tap(() => this.reload()))
|
||||
}
|
||||
}
|
@ -28,6 +28,7 @@ const mail_rules = [
|
||||
attachment_type: MailFilterAttachmentType.Everything,
|
||||
action: MailAction.MarkRead,
|
||||
assign_title_from: MailMetadataTitleOption.FromSubject,
|
||||
assign_owner_from_rule: true,
|
||||
},
|
||||
{
|
||||
name: 'Mail Rule 2',
|
||||
@ -44,6 +45,7 @@ const mail_rules = [
|
||||
attachment_type: MailFilterAttachmentType.Everything,
|
||||
action: MailAction.Delete,
|
||||
assign_title_from: MailMetadataTitleOption.FromSubject,
|
||||
assign_owner_from_rule: true,
|
||||
},
|
||||
{
|
||||
name: 'Mail Rule 3',
|
||||
@ -60,6 +62,7 @@ const mail_rules = [
|
||||
attachment_type: MailFilterAttachmentType.Everything,
|
||||
action: MailAction.Flag,
|
||||
assign_title_from: MailMetadataTitleOption.FromSubject,
|
||||
assign_owner_from_rule: false,
|
||||
},
|
||||
]
|
||||
|
||||
|
@ -170,7 +170,8 @@ a, a:hover,
|
||||
}
|
||||
|
||||
.btn-link:hover,
|
||||
.btn-link:active {
|
||||
.btn-link:active,
|
||||
.btn-link:focus-visible {
|
||||
color: var(--pngx-primary-darken-15) !important;
|
||||
}
|
||||
|
||||
@ -456,7 +457,7 @@ ul.pagination {
|
||||
}
|
||||
|
||||
table.table {
|
||||
color: var(--bs-body-color);
|
||||
--bs-table-color: var(--bs-body-color);
|
||||
--bs-table-bg: var(--bs-light-rgb);
|
||||
|
||||
.des,.asc {
|
||||
|
@ -20,6 +20,10 @@ from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.matching import document_matches_template
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.utils import copy_basic_file_stats
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
|
||||
@ -27,10 +31,12 @@ from .classifier import load_classifier
|
||||
from .file_handling import create_source_path_directory
|
||||
from .file_handling import generate_unique_filename
|
||||
from .loggers import LoggingMixin
|
||||
from .models import ConsumptionTemplate
|
||||
from .models import Correspondent
|
||||
from .models import Document
|
||||
from .models import DocumentType
|
||||
from .models import FileInfo
|
||||
from .models import StoragePath
|
||||
from .models import Tag
|
||||
from .parsers import DocumentParser
|
||||
from .parsers import ParseError
|
||||
@ -319,10 +325,15 @@ class Consumer(LoggingMixin):
|
||||
override_correspondent_id=None,
|
||||
override_document_type_id=None,
|
||||
override_tag_ids=None,
|
||||
override_storage_path_id=None,
|
||||
task_id=None,
|
||||
override_created=None,
|
||||
override_asn=None,
|
||||
override_owner_id=None,
|
||||
override_view_users=None,
|
||||
override_view_groups=None,
|
||||
override_change_users=None,
|
||||
override_change_groups=None,
|
||||
) -> Document:
|
||||
"""
|
||||
Return the document object if it was successfully created.
|
||||
@ -334,10 +345,15 @@ class Consumer(LoggingMixin):
|
||||
self.override_correspondent_id = override_correspondent_id
|
||||
self.override_document_type_id = override_document_type_id
|
||||
self.override_tag_ids = override_tag_ids
|
||||
self.override_storage_path_id = override_storage_path_id
|
||||
self.task_id = task_id or str(uuid.uuid4())
|
||||
self.override_created = override_created
|
||||
self.override_asn = override_asn
|
||||
self.override_owner_id = override_owner_id
|
||||
self.override_view_users = override_view_users
|
||||
self.override_view_groups = override_view_groups
|
||||
self.override_change_users = override_change_users
|
||||
self.override_change_groups = override_change_groups
|
||||
|
||||
self._send_progress(
|
||||
0,
|
||||
@ -578,6 +594,92 @@ class Consumer(LoggingMixin):
|
||||
|
||||
return document
|
||||
|
||||
def get_template_overrides(
|
||||
self,
|
||||
input_doc: ConsumableDocument,
|
||||
) -> DocumentMetadataOverrides:
|
||||
"""
|
||||
Match consumption templates to a document based on source and
|
||||
file name filters, path filters or mail rule filter if specified
|
||||
"""
|
||||
overrides = DocumentMetadataOverrides()
|
||||
for template in ConsumptionTemplate.objects.all().order_by("order"):
|
||||
template_overrides = DocumentMetadataOverrides()
|
||||
|
||||
if document_matches_template(input_doc, template):
|
||||
if template.assign_title is not None:
|
||||
template_overrides.title = template.assign_title
|
||||
if template.assign_tags is not None:
|
||||
template_overrides.tag_ids = [
|
||||
tag.pk for tag in template.assign_tags.all()
|
||||
]
|
||||
if template.assign_correspondent is not None:
|
||||
template_overrides.correspondent_id = (
|
||||
template.assign_correspondent.pk
|
||||
)
|
||||
if template.assign_document_type is not None:
|
||||
template_overrides.document_type_id = (
|
||||
template.assign_document_type.pk
|
||||
)
|
||||
if template.assign_storage_path is not None:
|
||||
template_overrides.storage_path_id = template.assign_storage_path.pk
|
||||
if template.assign_owner is not None:
|
||||
template_overrides.owner_id = template.assign_owner.pk
|
||||
if template.assign_view_users is not None:
|
||||
template_overrides.view_users = [
|
||||
user.pk for user in template.assign_view_users.all()
|
||||
]
|
||||
if template.assign_view_groups is not None:
|
||||
template_overrides.view_groups = [
|
||||
group.pk for group in template.assign_view_groups.all()
|
||||
]
|
||||
if template.assign_change_users is not None:
|
||||
template_overrides.change_users = [
|
||||
user.pk for user in template.assign_change_users.all()
|
||||
]
|
||||
if template.assign_change_groups is not None:
|
||||
template_overrides.change_groups = [
|
||||
group.pk for group in template.assign_change_groups.all()
|
||||
]
|
||||
overrides.update(template_overrides)
|
||||
return overrides
|
||||
|
||||
def _parse_title_placeholders(self, title: str) -> str:
|
||||
"""
|
||||
Consumption template title placeholders can only include items that are
|
||||
assigned as part of this template (since auto-matching hasnt happened yet)
|
||||
"""
|
||||
local_added = timezone.localtime(timezone.now())
|
||||
|
||||
correspondent_name = (
|
||||
Correspondent.objects.get(pk=self.override_correspondent_id).name
|
||||
if self.override_correspondent_id is not None
|
||||
else None
|
||||
)
|
||||
doc_type_name = (
|
||||
DocumentType.objects.get(pk=self.override_document_type_id).name
|
||||
if self.override_correspondent_id is not None
|
||||
else None
|
||||
)
|
||||
owner_username = (
|
||||
User.objects.get(pk=self.override_owner_id).username
|
||||
if self.override_owner_id is not None
|
||||
else None
|
||||
)
|
||||
|
||||
return title.format(
|
||||
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"),
|
||||
owner_username=owner_username,
|
||||
).strip()
|
||||
|
||||
def _store(
|
||||
self,
|
||||
text: str,
|
||||
@ -612,7 +714,11 @@ class Consumer(LoggingMixin):
|
||||
|
||||
with open(self.path, "rb") as f:
|
||||
document = Document.objects.create(
|
||||
title=(self.override_title or file_info.title)[:127],
|
||||
title=(
|
||||
self._parse_title_placeholders(self.override_title)
|
||||
if self.override_title is not None
|
||||
else file_info.title
|
||||
)[:127],
|
||||
content=text,
|
||||
mime_type=mime_type,
|
||||
checksum=hashlib.md5(f.read()).hexdigest(),
|
||||
@ -643,6 +749,11 @@ class Consumer(LoggingMixin):
|
||||
for tag_id in self.override_tag_ids:
|
||||
document.tags.add(Tag.objects.get(pk=tag_id))
|
||||
|
||||
if self.override_storage_path_id:
|
||||
document.storage_path = StoragePath.objects.get(
|
||||
pk=self.override_storage_path_id,
|
||||
)
|
||||
|
||||
if self.override_asn:
|
||||
document.archive_serial_number = self.override_asn
|
||||
|
||||
@ -651,6 +762,24 @@ class Consumer(LoggingMixin):
|
||||
pk=self.override_owner_id,
|
||||
)
|
||||
|
||||
if (
|
||||
self.override_view_users is not None
|
||||
or self.override_view_groups is not None
|
||||
or self.override_change_users is not None
|
||||
or self.override_change_users is not None
|
||||
):
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": self.override_view_users or [],
|
||||
"groups": self.override_view_groups or [],
|
||||
},
|
||||
"change": {
|
||||
"users": self.override_change_users or [],
|
||||
"groups": self.override_change_groups or [],
|
||||
},
|
||||
}
|
||||
set_permissions_for_object(permissions=permissions, object=document)
|
||||
|
||||
def _write(self, storage_type, source, target):
|
||||
with open(source, "rb") as read_file, open(target, "wb") as write_file:
|
||||
write_file.write(read_file.read())
|
||||
|
@ -1,6 +1,6 @@
|
||||
import dataclasses
|
||||
import datetime
|
||||
import enum
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@ -20,19 +20,70 @@ class DocumentMetadataOverrides:
|
||||
correspondent_id: Optional[int] = None
|
||||
document_type_id: Optional[int] = None
|
||||
tag_ids: Optional[list[int]] = None
|
||||
storage_path_id: Optional[int] = None
|
||||
created: Optional[datetime.datetime] = None
|
||||
asn: Optional[int] = None
|
||||
owner_id: Optional[int] = None
|
||||
view_users: Optional[list[int]] = None
|
||||
view_groups: Optional[list[int]] = None
|
||||
change_users: Optional[list[int]] = None
|
||||
change_groups: Optional[list[int]] = None
|
||||
|
||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||
"""
|
||||
Merges two DocumentMetadataOverrides objects such that object B's overrides
|
||||
are only applied if the property is empty in object A or merged if multiple
|
||||
are accepted.
|
||||
|
||||
The update is an in-place modification of self
|
||||
"""
|
||||
# only if empty
|
||||
if self.title is None:
|
||||
self.title = other.title
|
||||
if self.correspondent_id is None:
|
||||
self.correspondent_id = other.correspondent_id
|
||||
if self.document_type_id is None:
|
||||
self.document_type_id = other.document_type_id
|
||||
if self.storage_path_id is None:
|
||||
self.storage_path_id = other.storage_path_id
|
||||
if self.owner_id is None:
|
||||
self.owner_id = other.owner_id
|
||||
# merge
|
||||
# TODO: Handle the case where other is also None
|
||||
if self.tag_ids is None:
|
||||
self.tag_ids = other.tag_ids
|
||||
else:
|
||||
self.tag_ids.extend(other.tag_ids)
|
||||
if self.view_users is None:
|
||||
self.view_users = other.view_users
|
||||
else:
|
||||
self.view_users.extend(other.view_users)
|
||||
if self.view_groups is None:
|
||||
self.view_groups = other.view_groups
|
||||
else:
|
||||
self.view_groups.extend(other.view_groups)
|
||||
if self.change_users is None:
|
||||
self.change_users = other.change_users
|
||||
else:
|
||||
self.change_users.extend(other.change_users)
|
||||
if self.change_groups is None:
|
||||
self.change_groups = other.change_groups
|
||||
else:
|
||||
self.change_groups = [
|
||||
*self.change_groups,
|
||||
*other.change_groups,
|
||||
]
|
||||
return self
|
||||
|
||||
|
||||
class DocumentSource(enum.IntEnum):
|
||||
class DocumentSource(IntEnum):
|
||||
"""
|
||||
The source of an incoming document. May have other uses in the future
|
||||
"""
|
||||
|
||||
ConsumeFolder = enum.auto()
|
||||
ApiUpload = enum.auto()
|
||||
MailFetch = enum.auto()
|
||||
ConsumeFolder = 1
|
||||
ApiUpload = 2
|
||||
MailFetch = 3
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@ -44,6 +95,7 @@ class ConsumableDocument:
|
||||
|
||||
source: DocumentSource
|
||||
original_file: Path
|
||||
mailrule_id: Optional[int] = None
|
||||
mime_type: str = dataclasses.field(init=False, default=None)
|
||||
|
||||
def __post_init__(self):
|
||||
|
@ -1,7 +1,11 @@
|
||||
import logging
|
||||
import re
|
||||
from fnmatch import fnmatch
|
||||
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
@ -231,3 +235,67 @@ def _split_match(matching_model):
|
||||
re.escape(normspace(" ", (t[0] or t[1]).strip())).replace(r"\ ", r"\s+")
|
||||
for t in findterms(matching_model.match)
|
||||
]
|
||||
|
||||
|
||||
def document_matches_template(
|
||||
document: ConsumableDocument,
|
||||
template: ConsumptionTemplate,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if the incoming document matches all filters and
|
||||
settings from the template, False otherwise
|
||||
"""
|
||||
|
||||
def log_match_failure(reason: str):
|
||||
logger.info(f"Document did not match template {template.name}")
|
||||
logger.debug(reason)
|
||||
|
||||
# Document source vs template source
|
||||
if document.source not in [int(x) for x in list(template.sources)]:
|
||||
log_match_failure(
|
||||
f"Document source {document.source.name} not in"
|
||||
f" {[DocumentSource(int(x)).name for x in template.sources]}",
|
||||
)
|
||||
return False
|
||||
|
||||
# Document mail rule vs template mail rule
|
||||
if (
|
||||
document.mailrule_id is not None
|
||||
and template.filter_mailrule is not None
|
||||
and document.mailrule_id != template.filter_mailrule.pk
|
||||
):
|
||||
log_match_failure(
|
||||
f"Document mail rule {document.mailrule_id}"
|
||||
f" != {template.filter_mailrule.pk}",
|
||||
)
|
||||
return False
|
||||
|
||||
# Document filename vs template filename
|
||||
if (
|
||||
template.filter_filename is not None
|
||||
and len(template.filter_filename) > 0
|
||||
and not fnmatch(
|
||||
document.original_file.name.lower(),
|
||||
template.filter_filename.lower(),
|
||||
)
|
||||
):
|
||||
log_match_failure(
|
||||
f"Document filename {document.original_file.name} does not match"
|
||||
f" {template.filter_filename.lower()}",
|
||||
)
|
||||
return False
|
||||
|
||||
# Document path vs template path
|
||||
if (
|
||||
template.filter_path is not None
|
||||
and len(template.filter_path) > 0
|
||||
and not document.original_file.match(template.filter_path)
|
||||
):
|
||||
log_match_failure(
|
||||
f"Document path {document.original_file}"
|
||||
f" does not match {template.filter_path}",
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(f"Document matched template {template.name}")
|
||||
return True
|
||||
|
219
src/documents/migrations/1039_consumptiontemplate.py
Normal file
219
src/documents/migrations/1039_consumptiontemplate.py
Normal file
@ -0,0 +1,219 @@
|
||||
# Generated by Django 4.1.11 on 2023-09-16 18:04
|
||||
|
||||
import django.db.models.deletion
|
||||
import multiselectfield.db.fields
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.management import create_permissions
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
def add_consumptiontemplate_permissions(apps, schema_editor):
|
||||
# create permissions without waiting for post_migrate signal
|
||||
for app_config in apps.get_app_configs():
|
||||
app_config.models_module = True
|
||||
create_permissions(app_config, apps=apps, verbosity=0)
|
||||
app_config.models_module = None
|
||||
|
||||
add_permission = Permission.objects.get(codename="add_document")
|
||||
consumptiontemplate_permissions = Permission.objects.filter(
|
||||
codename__contains="consumptiontemplate",
|
||||
)
|
||||
|
||||
for user in User.objects.filter(Q(user_permissions=add_permission)).distinct():
|
||||
user.user_permissions.add(*consumptiontemplate_permissions)
|
||||
|
||||
for group in Group.objects.filter(Q(permissions=add_permission)).distinct():
|
||||
group.permissions.add(*consumptiontemplate_permissions)
|
||||
|
||||
|
||||
def remove_consumptiontemplate_permissions(apps, schema_editor):
|
||||
consumptiontemplate_permissions = Permission.objects.filter(
|
||||
codename__contains="consumptiontemplate",
|
||||
)
|
||||
|
||||
for user in User.objects.all():
|
||||
user.user_permissions.remove(*consumptiontemplate_permissions)
|
||||
|
||||
for group in Group.objects.all():
|
||||
group.permissions.remove(*consumptiontemplate_permissions)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("documents", "1038_sharelink"),
|
||||
("paperless_mail", "0021_alter_mailaccount_password"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ConsumptionTemplate",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(max_length=256, unique=True, verbose_name="name"),
|
||||
),
|
||||
("order", models.IntegerField(default=0, verbose_name="order")),
|
||||
(
|
||||
"sources",
|
||||
multiselectfield.db.fields.MultiSelectField(
|
||||
choices=[
|
||||
(1, "Consume Folder"),
|
||||
(2, "Api Upload"),
|
||||
(3, "Mail Fetch"),
|
||||
],
|
||||
default="1,2,3",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_path",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="filter path",
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_filename",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="filter filename",
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_mailrule",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="paperless_mail.mailrule",
|
||||
verbose_name="filter documents from this mail rule",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_change_groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to="auth.group",
|
||||
verbose_name="grant change permissions to these groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_change_users",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="grant change permissions to these users",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_correspondent",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.correspondent",
|
||||
verbose_name="assign this correspondent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_document_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.documenttype",
|
||||
verbose_name="assign this document type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="assign this owner",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_storage_path",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.storagepath",
|
||||
verbose_name="assign this storage path",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_tags",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
to="documents.tag",
|
||||
verbose_name="assign this tag",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_title",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Assign a document title, can include some placeholders, see documentation.",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="assign title",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_view_groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to="auth.group",
|
||||
verbose_name="grant view permissions to these groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_view_users",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="grant view permissions to these users",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "consumption template",
|
||||
"verbose_name_plural": "consumption templates",
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
add_consumptiontemplate_permissions,
|
||||
remove_consumptiontemplate_permissions,
|
||||
),
|
||||
]
|
@ -11,18 +11,18 @@ import dateutil.parser
|
||||
import pathvalidate
|
||||
from celery import states
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from multiselectfield import MultiSelectField
|
||||
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.parsers import get_default_file_extension
|
||||
|
||||
ALL_STATES = sorted(states.ALL_STATES)
|
||||
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
|
||||
|
||||
|
||||
class ModelWithOwner(models.Model):
|
||||
owner = models.ForeignKey(
|
||||
@ -572,6 +572,9 @@ class UiSettings(models.Model):
|
||||
|
||||
|
||||
class PaperlessTask(models.Model):
|
||||
ALL_STATES = sorted(states.ALL_STATES)
|
||||
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
|
||||
|
||||
task_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
@ -735,3 +738,137 @@ class ShareLink(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"Share Link for {self.document.title}"
|
||||
|
||||
|
||||
class ConsumptionTemplate(models.Model):
|
||||
class DocumentSourceChoices(models.IntegerChoices):
|
||||
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
|
||||
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
|
||||
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
|
||||
|
||||
name = models.CharField(_("name"), max_length=256, unique=True)
|
||||
|
||||
order = models.IntegerField(_("order"), default=0)
|
||||
|
||||
sources = MultiSelectField(
|
||||
max_length=3,
|
||||
choices=DocumentSourceChoices.choices,
|
||||
default=f"{DocumentSource.ConsumeFolder},{DocumentSource.ApiUpload},{DocumentSource.MailFetch}",
|
||||
)
|
||||
|
||||
filter_path = models.CharField(
|
||||
_("filter path"),
|
||||
max_length=256,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Only consume documents with a path that matches "
|
||||
"this if specified. Wildcards specified as * are "
|
||||
"allowed. Case insensitive.",
|
||||
),
|
||||
)
|
||||
|
||||
filter_filename = models.CharField(
|
||||
_("filter filename"),
|
||||
max_length=256,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Only consume documents which entirely match this "
|
||||
"filename if specified. Wildcards such as *.pdf or "
|
||||
"*invoice* are allowed. Case insensitive.",
|
||||
),
|
||||
)
|
||||
|
||||
filter_mailrule = models.ForeignKey(
|
||||
"paperless_mail.MailRule",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("filter documents from this mail rule"),
|
||||
)
|
||||
|
||||
assign_title = models.CharField(
|
||||
_("assign title"),
|
||||
max_length=256,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Assign a document title, can include some placeholders, "
|
||||
"see documentation.",
|
||||
),
|
||||
)
|
||||
|
||||
assign_tags = models.ManyToManyField(
|
||||
Tag,
|
||||
blank=True,
|
||||
verbose_name=_("assign this tag"),
|
||||
)
|
||||
|
||||
assign_document_type = models.ForeignKey(
|
||||
DocumentType,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("assign this document type"),
|
||||
)
|
||||
|
||||
assign_correspondent = models.ForeignKey(
|
||||
Correspondent,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("assign this correspondent"),
|
||||
)
|
||||
|
||||
assign_storage_path = models.ForeignKey(
|
||||
StoragePath,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("assign this storage path"),
|
||||
)
|
||||
|
||||
assign_owner = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
verbose_name=_("assign this owner"),
|
||||
)
|
||||
|
||||
assign_view_users = models.ManyToManyField(
|
||||
User,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
verbose_name=_("grant view permissions to these users"),
|
||||
)
|
||||
|
||||
assign_view_groups = models.ManyToManyField(
|
||||
Group,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
verbose_name=_("grant view permissions to these groups"),
|
||||
)
|
||||
|
||||
assign_change_users = models.ManyToManyField(
|
||||
User,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
verbose_name=_("grant change permissions to these users"),
|
||||
)
|
||||
|
||||
assign_change_groups = models.ManyToManyField(
|
||||
Group,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
verbose_name=_("grant change permissions to these groups"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("consumption template")
|
||||
verbose_name_plural = _("consumption templates")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
@ -13,9 +13,12 @@ from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from rest_framework import fields
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import set_permissions_for_object
|
||||
|
||||
@ -1035,3 +1038,56 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
|
||||
self._validate_permissions(permissions)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ConsumptionTemplateSerializer(serializers.ModelSerializer):
|
||||
order = serializers.IntegerField(required=False)
|
||||
sources = fields.MultipleChoiceField(
|
||||
choices=ConsumptionTemplate.DocumentSourceChoices.choices,
|
||||
allow_empty=False,
|
||||
default={
|
||||
DocumentSource.ConsumeFolder,
|
||||
DocumentSource.ApiUpload,
|
||||
DocumentSource.MailFetch,
|
||||
},
|
||||
)
|
||||
assign_correspondent = CorrespondentField(allow_null=True, required=False)
|
||||
assign_tags = TagsField(many=True, allow_null=True, required=False)
|
||||
assign_document_type = DocumentTypeField(allow_null=True, required=False)
|
||||
assign_storage_path = StoragePathField(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = ConsumptionTemplate
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"order",
|
||||
"sources",
|
||||
"filter_path",
|
||||
"filter_filename",
|
||||
"filter_mailrule",
|
||||
"assign_title",
|
||||
"assign_tags",
|
||||
"assign_correspondent",
|
||||
"assign_document_type",
|
||||
"assign_storage_path",
|
||||
"assign_owner",
|
||||
"assign_view_users",
|
||||
"assign_view_groups",
|
||||
"assign_change_users",
|
||||
"assign_change_groups",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
if ("filter_mailrule") in attrs and attrs["filter_mailrule"] is not None:
|
||||
attrs["sources"] = {DocumentSource.MailFetch.value}
|
||||
if (
|
||||
("filter_mailrule" not in attrs)
|
||||
and ("filter_filename" not in attrs or len(attrs["filter_filename"]) == 0)
|
||||
and ("filter_path" not in attrs or len(attrs["filter_path"]) == 0)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"File name, path or mail rule filter are required",
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
@ -153,6 +153,12 @@ def consume_file(
|
||||
overrides.asn = reader.asn
|
||||
logger.info(f"Found ASN in barcode: {overrides.asn}")
|
||||
|
||||
template_overrides = Consumer().get_template_overrides(
|
||||
input_doc=input_doc,
|
||||
)
|
||||
|
||||
overrides.update(template_overrides)
|
||||
|
||||
# continue with consumption if no barcode was found
|
||||
document = Consumer().try_consume_file(
|
||||
input_doc.original_file,
|
||||
@ -161,9 +167,14 @@ def consume_file(
|
||||
override_correspondent_id=overrides.correspondent_id,
|
||||
override_document_type_id=overrides.document_type_id,
|
||||
override_tag_ids=overrides.tag_ids,
|
||||
override_storage_path_id=overrides.storage_path_id,
|
||||
override_created=overrides.created,
|
||||
override_asn=overrides.asn,
|
||||
override_owner_id=overrides.owner_id,
|
||||
override_view_users=overrides.view_users,
|
||||
override_view_groups=overrides.view_groups,
|
||||
override_change_users=overrides.change_users,
|
||||
override_change_groups=overrides.change_groups,
|
||||
task_id=self.request.id,
|
||||
)
|
||||
|
||||
|
@ -32,6 +32,8 @@ from whoosh.writing import AsyncWriter
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents import index
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
@ -45,6 +47,8 @@ from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import DocumentConsumeDelayMixin
|
||||
from paperless import version
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
@ -5313,3 +5317,168 @@ class TestBulkEditObjectPermissions(APITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
|
||||
ENDPOINT = "/api/consumption_templates/"
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
self.client.force_authenticate(user=user)
|
||||
self.user2 = User.objects.create(username="user2")
|
||||
self.user3 = User.objects.create(username="user3")
|
||||
self.group1 = Group.objects.create(name="group1")
|
||||
|
||||
self.c = Correspondent.objects.create(name="Correspondent Name")
|
||||
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
|
||||
self.dt = DocumentType.objects.create(name="DocType Name")
|
||||
self.t1 = Tag.objects.create(name="t1")
|
||||
self.t2 = Tag.objects.create(name="t2")
|
||||
self.t3 = Tag.objects.create(name="t3")
|
||||
self.sp = StoragePath.objects.create(path="/test/")
|
||||
|
||||
self.ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}",
|
||||
filter_filename="*simple*",
|
||||
filter_path="*/samples/*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
self.ct.assign_tags.add(self.t1)
|
||||
self.ct.assign_tags.add(self.t2)
|
||||
self.ct.assign_tags.add(self.t3)
|
||||
self.ct.assign_view_users.add(self.user3.pk)
|
||||
self.ct.assign_view_groups.add(self.group1.pk)
|
||||
self.ct.assign_change_users.add(self.user3.pk)
|
||||
self.ct.assign_change_groups.add(self.group1.pk)
|
||||
self.ct.save()
|
||||
|
||||
def test_api_get_consumption_template(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to get all consumption template
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Existing consumption templates are returned
|
||||
"""
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
|
||||
resp_consumption_template = response.data["results"][0]
|
||||
self.assertEqual(resp_consumption_template["id"], self.ct.id)
|
||||
self.assertEqual(
|
||||
resp_consumption_template["assign_correspondent"],
|
||||
self.ct.assign_correspondent.pk,
|
||||
)
|
||||
|
||||
def test_api_create_consumption_template(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP response
|
||||
- New template is created
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_filename": "*test*",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ConsumptionTemplate.objects.count(), 2)
|
||||
|
||||
def test_api_create_invalid_consumption_template(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template
|
||||
- Neither file name nor path filter are specified
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP 400 response
|
||||
- No template is created
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(StoragePath.objects.count(), 1)
|
||||
|
||||
def test_api_create_consumption_template_with_mailrule(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template with a mail rule but no MailFetch source
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP response
|
||||
- New template is created with MailFetch as source
|
||||
"""
|
||||
account1 = MailAccount.objects.create(
|
||||
name="Email1",
|
||||
username="username1",
|
||||
password="password1",
|
||||
imap_server="server.example.com",
|
||||
imap_port=443,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
)
|
||||
rule1 = MailRule.objects.create(
|
||||
name="Rule1",
|
||||
account=account1,
|
||||
folder="INBOX",
|
||||
filter_from="from@example.com",
|
||||
filter_to="someone@somewhere.com",
|
||||
filter_subject="subject",
|
||||
filter_body="body",
|
||||
filter_attachment_filename="file.pdf",
|
||||
maximum_age=30,
|
||||
action=MailRule.MailAction.MARK_READ,
|
||||
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
|
||||
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
|
||||
order=0,
|
||||
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||
)
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_mailrule": rule1.pk,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ConsumptionTemplate.objects.count(), 2)
|
||||
ct = ConsumptionTemplate.objects.get(name="Template 2")
|
||||
self.assertEqual(ct.sources, [int(DocumentSource.MailFetch).__str__()])
|
||||
|
@ -11,9 +11,12 @@ from unittest.mock import MagicMock
|
||||
|
||||
from dateutil import tz
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
|
||||
from documents.consumer import Consumer
|
||||
from documents.consumer import ConsumerError
|
||||
@ -22,6 +25,7 @@ from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import FileInfo
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.parsers import DocumentParser
|
||||
from documents.parsers import ParseError
|
||||
@ -431,6 +435,16 @@ class TestConsumer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(document.document_type.id, dt.id)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideStoragePath(self):
|
||||
sp = StoragePath.objects.create(name="test")
|
||||
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_storage_path_id=sp.pk,
|
||||
)
|
||||
self.assertEqual(document.storage_path.id, sp.id)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideTags(self):
|
||||
t1 = Tag.objects.create(name="t1")
|
||||
t2 = Tag.objects.create(name="t2")
|
||||
@ -445,6 +459,51 @@ class TestConsumer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertIn(t3, document.tags.all())
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideAsn(self):
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_asn=123,
|
||||
)
|
||||
self.assertEqual(document.archive_serial_number, 123)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideTitlePlaceholders(self):
|
||||
c = Correspondent.objects.create(name="Correspondent Name")
|
||||
dt = DocumentType.objects.create(name="DocType Name")
|
||||
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_correspondent_id=c.pk,
|
||||
override_document_type_id=dt.pk,
|
||||
override_title="{correspondent}{document_type} {added_month}-{added_year_short}",
|
||||
)
|
||||
now = timezone.now()
|
||||
self.assertEqual(document.title, f"{c.name}{dt.name} {now.strftime('%m-%y')}")
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideOwner(self):
|
||||
testuser = User.objects.create(username="testuser")
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_owner_id=testuser.pk,
|
||||
)
|
||||
self.assertEqual(document.owner, testuser)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverridePermissions(self):
|
||||
testuser = User.objects.create(username="testuser")
|
||||
testgroup = Group.objects.create(name="testgroup")
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_view_users=[testuser.pk],
|
||||
override_view_groups=[testgroup.pk],
|
||||
)
|
||||
user_checker = ObjectPermissionChecker(testuser)
|
||||
self.assertTrue(user_checker.has_perm("view_document", document))
|
||||
group_checker = ObjectPermissionChecker(testgroup)
|
||||
self.assertTrue(group_checker.has_perm("view_document", document))
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testNotAFile(self):
|
||||
self.assertRaisesMessage(
|
||||
ConsumerError,
|
||||
|
476
src/documents/tests/test_consumption_templates.py
Normal file
476
src/documents/tests/test_consumption_templates.py
Normal file
@ -0,0 +1,476 @@
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents import tasks
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
SAMPLE_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.c = Correspondent.objects.create(name="Correspondent Name")
|
||||
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
|
||||
self.dt = DocumentType.objects.create(name="DocType Name")
|
||||
self.t1 = Tag.objects.create(name="t1")
|
||||
self.t2 = Tag.objects.create(name="t2")
|
||||
self.t3 = Tag.objects.create(name="t3")
|
||||
self.sp = StoragePath.objects.create(path="/test/")
|
||||
|
||||
self.user2 = User.objects.create(username="user2")
|
||||
self.user3 = User.objects.create(username="user3")
|
||||
self.group1 = Group.objects.create(name="group1")
|
||||
|
||||
account1 = MailAccount.objects.create(
|
||||
name="Email1",
|
||||
username="username1",
|
||||
password="password1",
|
||||
imap_server="server.example.com",
|
||||
imap_port=443,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
)
|
||||
self.rule1 = MailRule.objects.create(
|
||||
name="Rule1",
|
||||
account=account1,
|
||||
folder="INBOX",
|
||||
filter_from="from@example.com",
|
||||
filter_to="someone@somewhere.com",
|
||||
filter_subject="subject",
|
||||
filter_body="body",
|
||||
filter_attachment_filename="file.pdf",
|
||||
maximum_age=30,
|
||||
action=MailRule.MailAction.MARK_READ,
|
||||
assign_title_from=MailRule.TitleSource.NONE,
|
||||
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
|
||||
order=0,
|
||||
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||
assign_owner_from_rule=False,
|
||||
)
|
||||
|
||||
return super().setUp()
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_match(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that matches is consumed
|
||||
THEN:
|
||||
- Template overrides are applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*simple*",
|
||||
filter_path="*/samples/*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
ct.assign_tags.add(self.t1)
|
||||
ct.assign_tags.add(self.t2)
|
||||
ct.assign_tags.add(self.t3)
|
||||
ct.assign_view_users.add(self.user3.pk)
|
||||
ct.assign_view_groups.add(self.group1.pk)
|
||||
ct.assign_change_users.add(self.user3.pk)
|
||||
ct.assign_change_groups.add(self.group1.pk)
|
||||
ct.save()
|
||||
|
||||
self.assertEqual(ct.__str__(), "Template 1")
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
|
||||
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
|
||||
self.assertEqual(
|
||||
overrides["override_tag_ids"],
|
||||
[self.t1.pk, self.t2.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
|
||||
self.assertEqual(overrides["override_owner_id"], self.user2.pk)
|
||||
self.assertEqual(overrides["override_view_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
|
||||
self.assertEqual(overrides["override_change_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
|
||||
self.assertEqual(
|
||||
overrides["override_title"],
|
||||
"Doc from {correspondent}",
|
||||
)
|
||||
|
||||
info = cm.output[0]
|
||||
expected_str = f"Document matched template {ct}"
|
||||
self.assertIn(expected_str, info)
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_match_mailrule(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that matches is consumed via mail rule
|
||||
THEN:
|
||||
- Template overrides are applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_mailrule=self.rule1,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
ct.assign_tags.add(self.t1)
|
||||
ct.assign_tags.add(self.t2)
|
||||
ct.assign_tags.add(self.t3)
|
||||
ct.assign_view_users.add(self.user3.pk)
|
||||
ct.assign_view_groups.add(self.group1.pk)
|
||||
ct.assign_change_users.add(self.user3.pk)
|
||||
ct.assign_change_groups.add(self.group1.pk)
|
||||
ct.save()
|
||||
|
||||
self.assertEqual(ct.__str__(), "Template 1")
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
mailrule_id=self.rule1.pk,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
|
||||
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
|
||||
self.assertEqual(
|
||||
overrides["override_tag_ids"],
|
||||
[self.t1.pk, self.t2.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
|
||||
self.assertEqual(overrides["override_owner_id"], self.user2.pk)
|
||||
self.assertEqual(overrides["override_view_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
|
||||
self.assertEqual(overrides["override_change_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
|
||||
self.assertEqual(
|
||||
overrides["override_title"],
|
||||
"Doc from {correspondent}",
|
||||
)
|
||||
|
||||
info = cm.output[0]
|
||||
expected_str = f"Document matched template {ct}"
|
||||
self.assertIn(expected_str, info)
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_match_multiple(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Multiple existing consumption template
|
||||
WHEN:
|
||||
- File that matches is consumed
|
||||
THEN:
|
||||
- Template overrides are applied with subsequent templates only overwriting empty values
|
||||
or merging if multiple
|
||||
"""
|
||||
ct1 = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_path="*/samples/*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
)
|
||||
ct1.assign_tags.add(self.t1)
|
||||
ct1.assign_tags.add(self.t2)
|
||||
ct1.assign_view_users.add(self.user2)
|
||||
ct1.save()
|
||||
ct2 = ConsumptionTemplate.objects.create(
|
||||
name="Template 2",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*simple*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c2,
|
||||
assign_storage_path=self.sp,
|
||||
)
|
||||
ct2.assign_tags.add(self.t3)
|
||||
ct1.assign_view_users.add(self.user3)
|
||||
ct2.save()
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
# template 1
|
||||
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
|
||||
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
|
||||
# template 2
|
||||
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
|
||||
# template 1 & 2
|
||||
self.assertEqual(
|
||||
overrides["override_tag_ids"],
|
||||
[self.t1.pk, self.t2.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(
|
||||
overrides["override_view_users"],
|
||||
[self.user2.pk, self.user3.pk],
|
||||
)
|
||||
|
||||
expected_str = f"Document matched template {ct1}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document matched template {ct2}"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_filename(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on filename is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*foobar*",
|
||||
filter_path=None,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document filename {test_file.name} does not match"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_path(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on path is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_path="*foo/bar*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document path {test_file} does not match"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_mail_rule(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on source is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_mailrule=self.rule1,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
mailrule_id=99,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = "Document mail rule 99 !="
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_source(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on source is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_path="*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ApiUpload,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document source {DocumentSource.ApiUpload.name} not in ['{DocumentSource.ConsumeFolder.name}', '{DocumentSource.MailFetch.name}']"
|
||||
self.assertIn(expected_str, cm.output[1])
|
@ -153,7 +153,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
manifest = self._do_export(use_filename_format=use_filename_format)
|
||||
|
||||
self.assertEqual(len(manifest), 154)
|
||||
self.assertEqual(len(manifest), 159)
|
||||
|
||||
# dont include consumer or AnonymousUser users
|
||||
self.assertEqual(
|
||||
@ -247,7 +247,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
|
||||
self.assertEqual(GroupObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(UserObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(Permission.objects.count(), 112)
|
||||
self.assertEqual(Permission.objects.count(), 116)
|
||||
messages = check_sanity()
|
||||
# everything is alright after the test
|
||||
self.assertEqual(len(messages), 0)
|
||||
@ -676,15 +676,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
os.path.join(self.dirs.media_dir, "documents"),
|
||||
)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 28)
|
||||
self.assertEqual(Permission.objects.count(), 112)
|
||||
self.assertEqual(ContentType.objects.count(), 29)
|
||||
self.assertEqual(Permission.objects.count(), 116)
|
||||
|
||||
manifest = self._do_export()
|
||||
|
||||
with paperless_environment():
|
||||
self.assertEqual(
|
||||
len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
|
||||
112,
|
||||
116,
|
||||
)
|
||||
# add 1 more to db to show objects are not re-created by import
|
||||
Permission.objects.create(
|
||||
@ -692,7 +692,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
codename="test_perm",
|
||||
content_type_id=1,
|
||||
)
|
||||
self.assertEqual(Permission.objects.count(), 113)
|
||||
self.assertEqual(Permission.objects.count(), 117)
|
||||
|
||||
# will cause an import error
|
||||
self.user.delete()
|
||||
@ -701,5 +701,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
with self.assertRaises(IntegrityError):
|
||||
call_command("document_importer", "--no-progress-bar", self.target)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 28)
|
||||
self.assertEqual(Permission.objects.count(), 113)
|
||||
self.assertEqual(ContentType.objects.count(), 29)
|
||||
self.assertEqual(Permission.objects.count(), 117)
|
||||
|
43
src/documents/tests/test_migration_consumption_templates.py
Normal file
43
src/documents/tests/test_migration_consumption_templates.py
Normal file
@ -0,0 +1,43 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from documents.tests.utils import TestMigrations
|
||||
|
||||
|
||||
class TestMigrateConsumptionTemplate(TestMigrations):
|
||||
migrate_from = "1038_sharelink"
|
||||
migrate_to = "1039_consumptiontemplate"
|
||||
|
||||
def setUpBeforeMigration(self, apps):
|
||||
User = get_user_model()
|
||||
Group = apps.get_model("auth.Group")
|
||||
self.Permission = apps.get_model("auth", "Permission")
|
||||
self.user = User.objects.create(username="user1")
|
||||
self.group = Group.objects.create(name="group1")
|
||||
permission = self.Permission.objects.get(codename="add_document")
|
||||
self.user.user_permissions.add(permission.id)
|
||||
self.group.permissions.add(permission.id)
|
||||
|
||||
def test_users_with_add_documents_get_add_consumptiontemplate(self):
|
||||
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
|
||||
self.assertTrue(self.user.has_perm(f"documents.{permission.codename}"))
|
||||
self.assertTrue(permission in self.group.permissions.all())
|
||||
|
||||
|
||||
class TestReverseMigrateConsumptionTemplate(TestMigrations):
|
||||
migrate_from = "1039_consumptiontemplate"
|
||||
migrate_to = "1038_sharelink"
|
||||
|
||||
def setUpBeforeMigration(self, apps):
|
||||
User = get_user_model()
|
||||
Group = apps.get_model("auth.Group")
|
||||
self.Permission = apps.get_model("auth", "Permission")
|
||||
self.user = User.objects.create(username="user1")
|
||||
self.group = Group.objects.create(name="group1")
|
||||
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
|
||||
self.user.user_permissions.add(permission.id)
|
||||
self.group.permissions.add(permission.id)
|
||||
|
||||
def test_remove_consumptiontemplate_permissions(self):
|
||||
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
|
||||
self.assertFalse(self.user.has_perm(f"documents.{permission.codename}"))
|
||||
self.assertFalse(permission in self.group.permissions.all())
|
@ -86,6 +86,7 @@ from .matching import match_correspondents
|
||||
from .matching import match_document_types
|
||||
from .matching import match_storage_paths
|
||||
from .matching import match_tags
|
||||
from .models import ConsumptionTemplate
|
||||
from .models import Correspondent
|
||||
from .models import Document
|
||||
from .models import DocumentType
|
||||
@ -101,6 +102,7 @@ from .serialisers import AcknowledgeTasksViewSerializer
|
||||
from .serialisers import BulkDownloadSerializer
|
||||
from .serialisers import BulkEditObjectPermissionsSerializer
|
||||
from .serialisers import BulkEditSerializer
|
||||
from .serialisers import ConsumptionTemplateSerializer
|
||||
from .serialisers import CorrespondentSerializer
|
||||
from .serialisers import DocumentListSerializer
|
||||
from .serialisers import DocumentSerializer
|
||||
@ -1248,3 +1250,14 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
|
||||
return HttpResponseBadRequest(
|
||||
"Error performing bulk permissions edit, check logs for more detail.",
|
||||
)
|
||||
|
||||
|
||||
class ConsumptionTemplateViewSet(ModelViewSet):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = ConsumptionTemplateSerializer
|
||||
pagination_class = StandardPagination
|
||||
|
||||
model = ConsumptionTemplate
|
||||
|
||||
queryset = ConsumptionTemplate.objects.all().order_by("order")
|
||||
|
@ -21,555 +21,648 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:33 documents/models.py:728
|
||||
#: documents/models.py:36 documents/models.py:731
|
||||
msgid "owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:50
|
||||
#: documents/models.py:53
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:51
|
||||
#: documents/models.py:54
|
||||
msgid "Any word"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:52
|
||||
#: documents/models.py:55
|
||||
msgid "All words"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:53
|
||||
#: documents/models.py:56
|
||||
msgid "Exact match"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:54
|
||||
#: documents/models.py:57
|
||||
msgid "Regular expression"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:55
|
||||
#: documents/models.py:58
|
||||
msgid "Fuzzy word"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:56
|
||||
#: documents/models.py:59
|
||||
msgid "Automatic"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:59 documents/models.py:399 paperless_mail/models.py:18
|
||||
#: paperless_mail/models.py:92
|
||||
#: documents/models.py:62 documents/models.py:402 documents/models.py:755
|
||||
#: paperless_mail/models.py:18 paperless_mail/models.py:93
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:61
|
||||
#: documents/models.py:64
|
||||
msgid "match"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:64
|
||||
#: documents/models.py:67
|
||||
msgid "matching algorithm"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:69
|
||||
#: documents/models.py:72
|
||||
msgid "is insensitive"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:92 documents/models.py:144
|
||||
#: documents/models.py:95 documents/models.py:147
|
||||
msgid "correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:93
|
||||
#: documents/models.py:96
|
||||
msgid "correspondents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:97
|
||||
#: documents/models.py:100
|
||||
msgid "color"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:100
|
||||
#: documents/models.py:103
|
||||
msgid "is inbox tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:103
|
||||
#: documents/models.py:106
|
||||
msgid ""
|
||||
"Marks this tag as an inbox tag: All newly consumed documents will be tagged "
|
||||
"with inbox tags."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:109
|
||||
#: documents/models.py:112
|
||||
msgid "tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:110 documents/models.py:182
|
||||
#: documents/models.py:113 documents/models.py:185
|
||||
msgid "tags"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:115 documents/models.py:164
|
||||
#: documents/models.py:118 documents/models.py:167
|
||||
msgid "document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:116
|
||||
#: documents/models.py:119
|
||||
msgid "document types"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:121
|
||||
#: documents/models.py:124
|
||||
msgid "path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:126 documents/models.py:153
|
||||
#: documents/models.py:129 documents/models.py:156
|
||||
msgid "storage path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:127
|
||||
#: documents/models.py:130
|
||||
msgid "storage paths"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:134
|
||||
#: documents/models.py:137
|
||||
msgid "Unencrypted"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:135
|
||||
#: documents/models.py:138
|
||||
msgid "Encrypted with GNU Privacy Guard"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:156
|
||||
#: documents/models.py:159
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:168 documents/models.py:642
|
||||
#: documents/models.py:171 documents/models.py:645
|
||||
msgid "content"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:171
|
||||
#: documents/models.py:174
|
||||
msgid ""
|
||||
"The raw, text-only data of the document. This field is primarily used for "
|
||||
"searching."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:176
|
||||
#: documents/models.py:179
|
||||
msgid "mime type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:186
|
||||
#: documents/models.py:189
|
||||
msgid "checksum"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:190
|
||||
#: documents/models.py:193
|
||||
msgid "The checksum of the original document."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:194
|
||||
#: documents/models.py:197
|
||||
msgid "archive checksum"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:199
|
||||
#: documents/models.py:202
|
||||
msgid "The checksum of the archived document."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:202 documents/models.py:382 documents/models.py:648
|
||||
#: documents/models.py:686
|
||||
#: documents/models.py:205 documents/models.py:385 documents/models.py:651
|
||||
#: documents/models.py:689
|
||||
msgid "created"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:205
|
||||
#: documents/models.py:208
|
||||
msgid "modified"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:212
|
||||
#: documents/models.py:215
|
||||
msgid "storage type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:220
|
||||
#: documents/models.py:223
|
||||
msgid "added"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:227
|
||||
#: documents/models.py:230
|
||||
msgid "filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:233
|
||||
#: documents/models.py:236
|
||||
msgid "Current filename in storage"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:237
|
||||
#: documents/models.py:240
|
||||
msgid "archive filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:243
|
||||
#: documents/models.py:246
|
||||
msgid "Current archive filename in storage"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:247
|
||||
#: documents/models.py:250
|
||||
msgid "original filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:253
|
||||
#: documents/models.py:256
|
||||
msgid "The original name of the file when it was uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:260
|
||||
#: documents/models.py:263
|
||||
msgid "archive serial number"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:270
|
||||
#: documents/models.py:273
|
||||
msgid "The position of this document in your physical document archive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:276 documents/models.py:659 documents/models.py:713
|
||||
#: documents/models.py:279 documents/models.py:662 documents/models.py:716
|
||||
msgid "document"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:277
|
||||
#: documents/models.py:280
|
||||
msgid "documents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:365
|
||||
#: documents/models.py:368
|
||||
msgid "debug"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:366
|
||||
#: documents/models.py:369
|
||||
msgid "information"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:367
|
||||
#: documents/models.py:370
|
||||
msgid "warning"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:368 paperless_mail/models.py:287
|
||||
#: documents/models.py:371 paperless_mail/models.py:293
|
||||
msgid "error"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:369
|
||||
#: documents/models.py:372
|
||||
msgid "critical"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:372
|
||||
#: documents/models.py:375
|
||||
msgid "group"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:374
|
||||
#: documents/models.py:377
|
||||
msgid "message"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:377
|
||||
#: documents/models.py:380
|
||||
msgid "level"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:386
|
||||
#: documents/models.py:389
|
||||
msgid "log"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:387
|
||||
#: documents/models.py:390
|
||||
msgid "logs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:396 documents/models.py:461
|
||||
#: documents/models.py:399 documents/models.py:464
|
||||
msgid "saved view"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:397
|
||||
#: documents/models.py:400
|
||||
msgid "saved views"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:402
|
||||
#: documents/models.py:405
|
||||
msgid "show on dashboard"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:405
|
||||
#: documents/models.py:408
|
||||
msgid "show in sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:409
|
||||
#: documents/models.py:412
|
||||
msgid "sort field"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:414
|
||||
#: documents/models.py:417
|
||||
msgid "sort reverse"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:419
|
||||
#: documents/models.py:422
|
||||
msgid "title contains"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:420
|
||||
#: documents/models.py:423
|
||||
msgid "content contains"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:421
|
||||
#: documents/models.py:424
|
||||
msgid "ASN is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:422
|
||||
#: documents/models.py:425
|
||||
msgid "correspondent is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:423
|
||||
#: documents/models.py:426
|
||||
msgid "document type is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:424
|
||||
#: documents/models.py:427
|
||||
msgid "is in inbox"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:425
|
||||
#: documents/models.py:428
|
||||
msgid "has tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:426
|
||||
#: documents/models.py:429
|
||||
msgid "has any tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:427
|
||||
#: documents/models.py:430
|
||||
msgid "created before"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:428
|
||||
#: documents/models.py:431
|
||||
msgid "created after"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:429
|
||||
#: documents/models.py:432
|
||||
msgid "created year is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:430
|
||||
#: documents/models.py:433
|
||||
msgid "created month is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:431
|
||||
#: documents/models.py:434
|
||||
msgid "created day is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:432
|
||||
#: documents/models.py:435
|
||||
msgid "added before"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:433
|
||||
#: documents/models.py:436
|
||||
msgid "added after"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:434
|
||||
#: documents/models.py:437
|
||||
msgid "modified before"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:435
|
||||
#: documents/models.py:438
|
||||
msgid "modified after"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:436
|
||||
#: documents/models.py:439
|
||||
msgid "does not have tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:437
|
||||
#: documents/models.py:440
|
||||
msgid "does not have ASN"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:438
|
||||
#: documents/models.py:441
|
||||
msgid "title or content contains"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:439
|
||||
#: documents/models.py:442
|
||||
msgid "fulltext query"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:440
|
||||
#: documents/models.py:443
|
||||
msgid "more like this"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:441
|
||||
#: documents/models.py:444
|
||||
msgid "has tags in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:442
|
||||
#: documents/models.py:445
|
||||
msgid "ASN greater than"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:443
|
||||
#: documents/models.py:446
|
||||
msgid "ASN less than"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:444
|
||||
#: documents/models.py:447
|
||||
msgid "storage path is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:445
|
||||
#: documents/models.py:448
|
||||
msgid "has correspondent in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:446
|
||||
#: documents/models.py:449
|
||||
msgid "does not have correspondent in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:447
|
||||
#: documents/models.py:450
|
||||
msgid "has document type in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:448
|
||||
#: documents/models.py:451
|
||||
msgid "does not have document type in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:449
|
||||
#: documents/models.py:452
|
||||
msgid "has storage path in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:450
|
||||
#: documents/models.py:453
|
||||
msgid "does not have storage path in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:451
|
||||
#: documents/models.py:454
|
||||
msgid "owner is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:452
|
||||
#: documents/models.py:455
|
||||
msgid "has owner in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:453
|
||||
#: documents/models.py:456
|
||||
msgid "does not have owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:454
|
||||
#: documents/models.py:457
|
||||
msgid "does not have owner in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:464
|
||||
#: documents/models.py:467
|
||||
msgid "rule type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:466
|
||||
#: documents/models.py:469
|
||||
msgid "value"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:469
|
||||
#: documents/models.py:472
|
||||
msgid "filter rule"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:470
|
||||
#: documents/models.py:473
|
||||
msgid "filter rules"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:578
|
||||
#: documents/models.py:581
|
||||
msgid "Task ID"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:579
|
||||
#: documents/models.py:582
|
||||
msgid "Celery ID for the Task that was run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:584
|
||||
#: documents/models.py:587
|
||||
msgid "Acknowledged"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:585
|
||||
#: documents/models.py:588
|
||||
msgid "If the task is acknowledged via the frontend or API"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:591
|
||||
#: documents/models.py:594
|
||||
msgid "Task Filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:592
|
||||
#: documents/models.py:595
|
||||
msgid "Name of the file which the Task was run for"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:598
|
||||
#: documents/models.py:601
|
||||
msgid "Task Name"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:599
|
||||
#: documents/models.py:602
|
||||
msgid "Name of the Task which was run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:606
|
||||
#: documents/models.py:609
|
||||
msgid "Task State"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:607
|
||||
#: documents/models.py:610
|
||||
msgid "Current state of the task being run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:612
|
||||
#: documents/models.py:615
|
||||
msgid "Created DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:613
|
||||
#: documents/models.py:616
|
||||
msgid "Datetime field when the task result was created in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:618
|
||||
#: documents/models.py:621
|
||||
msgid "Started DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:619
|
||||
#: documents/models.py:622
|
||||
msgid "Datetime field when the task was started in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:624
|
||||
#: documents/models.py:627
|
||||
msgid "Completed DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:625
|
||||
#: documents/models.py:628
|
||||
msgid "Datetime field when the task was completed in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:630
|
||||
#: documents/models.py:633
|
||||
msgid "Result Data"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:632
|
||||
#: documents/models.py:635
|
||||
msgid "The data returned by the task"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:644
|
||||
#: documents/models.py:647
|
||||
msgid "Note for the document"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:668
|
||||
#: documents/models.py:671
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:673
|
||||
#: documents/models.py:676
|
||||
msgid "note"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:674
|
||||
#: documents/models.py:677
|
||||
msgid "notes"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:682
|
||||
#: documents/models.py:685
|
||||
msgid "Archive"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:683
|
||||
#: documents/models.py:686
|
||||
msgid "Original"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:694
|
||||
#: documents/models.py:697
|
||||
msgid "expiration"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:701
|
||||
#: documents/models.py:704
|
||||
msgid "slug"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:733
|
||||
#: documents/models.py:736
|
||||
msgid "share link"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:734
|
||||
#: documents/models.py:737
|
||||
msgid "share links"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:96
|
||||
#: documents/models.py:744
|
||||
msgid "Consume Folder"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:745
|
||||
msgid "Api Upload"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:746
|
||||
msgid "Mail Fetch"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:752
|
||||
msgid "consumption template"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:753
|
||||
msgid "consumption templates"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:757 paperless_mail/models.py:95
|
||||
msgid "order"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:766
|
||||
msgid "filter path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:771
|
||||
msgid ""
|
||||
"Only consume documents with a path that matches this if specified. Wildcards "
|
||||
"specified as * are allowed. Case insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:778
|
||||
msgid "filter filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:783 paperless_mail/models.py:148
|
||||
msgid ""
|
||||
"Only consume documents which entirely match this filename if specified. "
|
||||
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:794
|
||||
msgid "filter documents from this mail rule"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:798
|
||||
msgid "assign title"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:803
|
||||
msgid ""
|
||||
"Assign a document title, can include some placeholders, see documentation."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:811 paperless_mail/models.py:204
|
||||
msgid "assign this tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:819 paperless_mail/models.py:212
|
||||
msgid "assign this document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:827 paperless_mail/models.py:226
|
||||
msgid "assign this correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:835
|
||||
msgid "assign this storage path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:844
|
||||
msgid "assign this owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:851
|
||||
msgid "grant view permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:858
|
||||
msgid "grant view permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:865
|
||||
msgid "grant change permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:872
|
||||
msgid "grant change permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:100
|
||||
#, python-format
|
||||
msgid "Invalid regular expression: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:371
|
||||
#: documents/serialisers.py:375
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:747
|
||||
#: documents/serialisers.py:751
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:844
|
||||
#: documents/serialisers.py:848
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
@ -749,7 +842,7 @@ msgstr ""
|
||||
msgid "Chinese Simplified"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/urls.py:182
|
||||
#: paperless/urls.py:184
|
||||
msgid "Paperless-ngx administration"
|
||||
msgstr ""
|
||||
|
||||
@ -909,138 +1002,124 @@ msgstr ""
|
||||
msgid "Use attachment filename as title"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:87
|
||||
msgid "Do not assign a correspondent"
|
||||
#: paperless_mail/models.py:85
|
||||
msgid "Do not assign title from rule"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:88
|
||||
msgid "Use mail address"
|
||||
msgid "Do not assign a correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:89
|
||||
msgid "Use name (or mail address if not available)"
|
||||
msgid "Use mail address"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:90
|
||||
msgid "Use name (or mail address if not available)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:91
|
||||
msgid "Use correspondent selected below"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:94
|
||||
msgid "order"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:100
|
||||
#: paperless_mail/models.py:101
|
||||
msgid "account"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:104 paperless_mail/models.py:242
|
||||
#: paperless_mail/models.py:105 paperless_mail/models.py:248
|
||||
msgid "folder"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:108
|
||||
#: paperless_mail/models.py:109
|
||||
msgid ""
|
||||
"Subfolders must be separated by a delimiter, often a dot ('.') or slash "
|
||||
"('/'), but it varies by mail server."
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:114
|
||||
#: paperless_mail/models.py:115
|
||||
msgid "filter from"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:121
|
||||
#: paperless_mail/models.py:122
|
||||
msgid "filter to"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:128
|
||||
#: paperless_mail/models.py:129
|
||||
msgid "filter subject"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:135
|
||||
#: paperless_mail/models.py:136
|
||||
msgid "filter body"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:142
|
||||
#: paperless_mail/models.py:143
|
||||
msgid "filter attachment filename"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:147
|
||||
msgid ""
|
||||
"Only consume documents which entirely match this filename if specified. "
|
||||
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:154
|
||||
#: paperless_mail/models.py:155
|
||||
msgid "maximum age"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:156
|
||||
#: paperless_mail/models.py:157
|
||||
msgid "Specified in days."
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:160
|
||||
#: paperless_mail/models.py:161
|
||||
msgid "attachment type"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:164
|
||||
#: paperless_mail/models.py:165
|
||||
msgid ""
|
||||
"Inline attachments include embedded images, so it's best to combine this "
|
||||
"option with a filename filter."
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:170
|
||||
#: paperless_mail/models.py:171
|
||||
msgid "consumption scope"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:176
|
||||
#: paperless_mail/models.py:177
|
||||
msgid "action"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:182
|
||||
#: paperless_mail/models.py:183
|
||||
msgid "action parameter"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:187
|
||||
#: paperless_mail/models.py:188
|
||||
msgid ""
|
||||
"Additional parameter for the action selected above, i.e., the target folder "
|
||||
"of the move to folder action. Subfolders must be separated by dots."
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:195
|
||||
#: paperless_mail/models.py:196
|
||||
msgid "assign title from"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:203
|
||||
msgid "assign this tag"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:211
|
||||
msgid "assign this document type"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:215
|
||||
#: paperless_mail/models.py:216
|
||||
msgid "assign correspondent from"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:225
|
||||
msgid "assign this correspondent"
|
||||
#: paperless_mail/models.py:230
|
||||
msgid "Assign the rule owner to documents"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:250
|
||||
#: paperless_mail/models.py:256
|
||||
msgid "uid"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:258
|
||||
#: paperless_mail/models.py:264
|
||||
msgid "subject"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:266
|
||||
#: paperless_mail/models.py:272
|
||||
msgid "received"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:273
|
||||
#: paperless_mail/models.py:279
|
||||
msgid "processed"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:279
|
||||
#: paperless_mail/models.py:285
|
||||
msgid "status"
|
||||
msgstr ""
|
||||
|
@ -14,6 +14,7 @@ from documents.views import AcknowledgeTasksView
|
||||
from documents.views import BulkDownloadView
|
||||
from documents.views import BulkEditObjectPermissionsView
|
||||
from documents.views import BulkEditView
|
||||
from documents.views import ConsumptionTemplateViewSet
|
||||
from documents.views import CorrespondentViewSet
|
||||
from documents.views import DocumentTypeViewSet
|
||||
from documents.views import IndexView
|
||||
@ -53,6 +54,7 @@ api_router.register(r"groups", GroupViewSet, basename="groups")
|
||||
api_router.register(r"mail_accounts", MailAccountViewSet)
|
||||
api_router.register(r"mail_rules", MailRuleViewSet)
|
||||
api_router.register(r"share_links", ShareLinkViewSet)
|
||||
api_router.register(r"consumption_templates", ConsumptionTemplateViewSet)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
@ -436,6 +436,9 @@ class MailAccountHandler(LoggingMixin):
|
||||
elif rule.assign_title_from == MailRule.TitleSource.FROM_FILENAME:
|
||||
return os.path.splitext(os.path.basename(att.filename))[0]
|
||||
|
||||
elif rule.assign_title_from == MailRule.TitleSource.NONE:
|
||||
return None
|
||||
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"Unknown title selector.",
|
||||
@ -690,6 +693,7 @@ class MailAccountHandler(LoggingMixin):
|
||||
input_doc = ConsumableDocument(
|
||||
source=DocumentSource.MailFetch,
|
||||
original_file=temp_filename,
|
||||
mailrule_id=rule.pk,
|
||||
)
|
||||
doc_overrides = DocumentMetadataOverrides(
|
||||
title=title,
|
||||
@ -697,7 +701,9 @@ class MailAccountHandler(LoggingMixin):
|
||||
correspondent_id=correspondent.id if correspondent else None,
|
||||
document_type_id=doc_type.id if doc_type else None,
|
||||
tag_ids=tag_ids,
|
||||
owner_id=rule.owner.id if rule.owner else None,
|
||||
owner_id=rule.owner.id
|
||||
if (rule.assign_owner_from_rule and rule.owner)
|
||||
else None,
|
||||
)
|
||||
|
||||
consume_task = consume_file.s(
|
||||
|
@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.1.11 on 2023-09-18 18:50
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless_mail", "0021_alter_mailaccount_password"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="mailrule",
|
||||
name="assign_owner_from_rule",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Assign the rule owner to documents",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="mailrule",
|
||||
name="assign_title_from",
|
||||
field=models.PositiveIntegerField(
|
||||
choices=[
|
||||
(1, "Use subject as title"),
|
||||
(2, "Use attachment filename as title"),
|
||||
(3, "Do not assign title from rule"),
|
||||
],
|
||||
default=1,
|
||||
verbose_name="assign title from",
|
||||
),
|
||||
),
|
||||
]
|
@ -82,6 +82,7 @@ class MailRule(document_models.ModelWithOwner):
|
||||
class TitleSource(models.IntegerChoices):
|
||||
FROM_SUBJECT = 1, _("Use subject as title")
|
||||
FROM_FILENAME = 2, _("Use attachment filename as title")
|
||||
NONE = 3, _("Do not assign title from rule")
|
||||
|
||||
class CorrespondentSource(models.IntegerChoices):
|
||||
FROM_NOTHING = 1, _("Do not assign a correspondent")
|
||||
@ -225,6 +226,11 @@ class MailRule(document_models.ModelWithOwner):
|
||||
verbose_name=_("assign this correspondent"),
|
||||
)
|
||||
|
||||
assign_owner_from_rule = models.BooleanField(
|
||||
_("Assign the rule owner to documents"),
|
||||
default=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.account.name}.{self.name}"
|
||||
|
||||
|
@ -88,6 +88,7 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
||||
"assign_correspondent_from",
|
||||
"assign_correspondent",
|
||||
"assign_document_type",
|
||||
"assign_owner_from_rule",
|
||||
"order",
|
||||
"attachment_type",
|
||||
"consumption_scope",
|
||||
|
@ -464,6 +464,7 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
|
||||
"assign_tags": [tag.pk],
|
||||
"assign_correspondent": correspondent.pk,
|
||||
"assign_document_type": document_type.pk,
|
||||
"assign_owner_from_rule": True,
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
@ -512,6 +513,10 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
|
||||
rule1["assign_document_type"],
|
||||
)
|
||||
self.assertEqual(returned_rule1["assign_tags"], rule1["assign_tags"])
|
||||
self.assertEqual(
|
||||
returned_rule1["assign_owner_from_rule"],
|
||||
rule1["assign_owner_from_rule"],
|
||||
)
|
||||
|
||||
def test_delete_mail_rule(self):
|
||||
"""
|
||||
|
@ -392,6 +392,11 @@ class TestMail(
|
||||
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
|
||||
)
|
||||
self.assertEqual(handler._get_title(message, att, rule), "the message title")
|
||||
rule = MailRule(
|
||||
name="b",
|
||||
assign_title_from=MailRule.TitleSource.NONE,
|
||||
)
|
||||
self.assertEqual(handler._get_title(message, att, rule), None)
|
||||
|
||||
def test_handle_message(self):
|
||||
message = self.create_message(
|
||||
|
Loading…
x
Reference in New Issue
Block a user