mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -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:
		
							
								
								
									
										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( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon