mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge branch 'feature-bulk-edit' into feature-bulk-editor
This commit is contained in:
		| @@ -5,85 +5,6 @@ Advanced topics | ||||
| Paperless offers a couple features that automate certain tasks and make your life | ||||
| easier. | ||||
|  | ||||
| Guesswork | ||||
| ######### | ||||
|  | ||||
|  | ||||
| Any document you put into the consumption directory will be consumed, but if | ||||
| you name the file right, it'll automatically set some values in the database | ||||
| for you.  This is is the logic the consumer follows: | ||||
|  | ||||
| 1. Try to find the correspondent, title, and tags in the file name following | ||||
|    the pattern: ``Date - Correspondent - Title - tag,tag,tag.pdf``.  Note that | ||||
|    the format of the date is **rigidly defined** as ``YYYYMMDDHHMMSSZ`` or | ||||
|    ``YYYYMMDDZ``.  The ``Z`` refers "Zulu time" AKA "UTC". | ||||
|    The tags are optional, so the format ``Date - Correspondent - Title.pdf`` | ||||
|    works as well. | ||||
| 2. If that doesn't work, we skip the date and try this pattern: | ||||
|    ``Correspondent - Title - tag,tag,tag.pdf``. | ||||
| 3. If that doesn't work, we try to find the correspondent and title in the file | ||||
|    name following the pattern: ``Correspondent - Title.pdf``. | ||||
| 4. If that doesn't work, just assume that the name of the file is the title. | ||||
|  | ||||
| So given the above, the following examples would work as you'd expect: | ||||
|  | ||||
| * ``20150314000700Z - Some Company Name - Invoice 2016-01-01 - money,invoices.pdf`` | ||||
| * ``20150314Z - Some Company Name - Invoice 2016-01-01 - money,invoices.pdf`` | ||||
| * ``Some Company Name - Invoice 2016-01-01 - money,invoices.pdf`` | ||||
| * ``Another Company - Letter of Reference.jpg`` | ||||
| * ``Dad's Recipe for Pancakes.png`` | ||||
|  | ||||
| These however wouldn't work: | ||||
|  | ||||
| * ``2015-03-14 00:07:00 UTC - Some Company Name, Invoice 2016-01-01, money, invoices.pdf`` | ||||
| * ``2015-03-14 - Some Company Name, Invoice 2016-01-01, money, invoices.pdf`` | ||||
| * ``Some Company Name, Invoice 2016-01-01, money, invoices.pdf`` | ||||
| * ``Another Company- Letter of Reference.jpg`` | ||||
|  | ||||
| Do I have to be so strict about naming? | ||||
| ======================================= | ||||
|  | ||||
| Rather than using the strict document naming rules, one can also set the option | ||||
| ``PAPERLESS_FILENAME_DATE_ORDER`` in ``paperless.conf`` to any date order | ||||
| that is accepted by dateparser_. Doing so will cause ``paperless`` to default | ||||
| to any date format that is found in the title, instead of a date pulled from | ||||
| the document's text, without requiring the strict formatting of the document | ||||
| filename as described above. | ||||
|  | ||||
| .. _dateparser: https://github.com/scrapinghub/dateparser/blob/v0.7.0/docs/usage.rst#settings | ||||
|  | ||||
| .. _advanced-transforming_filenames: | ||||
|  | ||||
| Transforming filenames for parsing | ||||
| ================================== | ||||
|  | ||||
| Some devices can't produce filenames that can be parsed by the default | ||||
| parser. By configuring the option ``PAPERLESS_FILENAME_PARSE_TRANSFORMS`` in | ||||
| ``paperless.conf`` one can add transformations that are applied to the filename | ||||
| before it's parsed. | ||||
|  | ||||
| The option contains a list of dictionaries of regular expressions (key: | ||||
| ``pattern``) and replacements (key: ``repl``) in JSON format, which are | ||||
| applied in order by passing them to ``re.subn``. Transformation stops | ||||
| after the first match, so at most one transformation is applied. The general | ||||
| syntax is | ||||
|  | ||||
| .. code:: python | ||||
|  | ||||
|    [{"pattern":"pattern1", "repl":"repl1"}, {"pattern":"pattern2", "repl":"repl2"}, ..., {"pattern":"patternN", "repl":"replN"}] | ||||
|  | ||||
| The example below is for a Brother ADS-2400N, a scanner that allows | ||||
| different names to different hardware buttons (useful for handling | ||||
| multiple entities in one instance), but insists on adding ``_<count>`` | ||||
| to the filename. | ||||
|  | ||||
| .. code:: python | ||||
|  | ||||
|    # Brother profile configuration, support "Name_Date_Count" (the default | ||||
|    # setting) and "Name_Count" (use "Name" as tag and "Count" as title). | ||||
|    PAPERLESS_FILENAME_PARSE_TRANSFORMS=[{"pattern":"^([a-z]+)_(\\d{8})_(\\d{6})_([0-9]+)\\.", "repl":"\\2\\3Z - \\4 - \\1."}, {"pattern":"^([a-z]+)_([0-9]+)\\.", "repl":" - \\2 - \\1."}] | ||||
|  | ||||
|  | ||||
| .. _advanced-matching: | ||||
|  | ||||
| Matching tags, correspondents and document types | ||||
|   | ||||
| @@ -400,11 +400,6 @@ PAPERLESS_FILENAME_DATE_ORDER=<format> | ||||
|  | ||||
|     Defaults to none, which disables this feature. | ||||
|  | ||||
| PAPERLESS_FILENAME_PARSE_TRANSFORMS | ||||
|     Transforms filenames before they are processed by paperless. See | ||||
|     :ref:`advanced-transforming_filenames` for details. | ||||
|  | ||||
|     Defaults to none, which disables this feature. | ||||
|  | ||||
| Binaries | ||||
| ######## | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <div class="form-group paperless-input-select"> | ||||
|   <label [for]="inputId">{{title}}</label> | ||||
|   <div [class.input-group]="showPlusButton()"> | ||||
|     <ng-select name="correspondent" [(ngModel)]="value" | ||||
|     <ng-select name="inputId" [(ngModel)]="value" | ||||
|       [disabled]="disabled" | ||||
|       [style.color]="textColor" | ||||
|       [style.background]="backgroundColor" | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| <app-page-header title="Dashboard" subTitle="Welcome to paperless-ng!"> | ||||
| <app-page-header title="Dashboard" [subTitle]="subtitle"> | ||||
|   <img src="assets/logo.svg" height="80" class="m-2 d-none d-md-block"> | ||||
| </app-page-header> | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { Meta } from '@angular/platform-browser'; | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service'; | ||||
|  | ||||
| @@ -11,8 +12,29 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'; | ||||
| export class DashboardComponent implements OnInit { | ||||
|  | ||||
|   constructor( | ||||
|     private savedViewService: SavedViewService) { } | ||||
|     private savedViewService: SavedViewService, | ||||
|     private meta: Meta | ||||
|   ) { } | ||||
|  | ||||
|   get displayName() { | ||||
|     let tagFullName = this.meta.getTag('name=full_name') | ||||
|     let tagUsername = this.meta.getTag('name=username') | ||||
|     if (tagFullName && tagFullName.content) { | ||||
|       return tagFullName.content | ||||
|     } else if (tagUsername && tagUsername.content) { | ||||
|       return tagUsername.content | ||||
|     } else { | ||||
|       return null | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   get subtitle() { | ||||
|     if (this.displayName) { | ||||
|       return `Hello ${this.displayName}, welcome to Paperless-ng!` | ||||
|     } else { | ||||
|       return `Welcome to Paperless-ng!` | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   savedViews: PaperlessSavedView[] = [] | ||||
|  | ||||
|   | ||||
| @@ -23,7 +23,7 @@ export class SavedViewWidgetComponent implements OnInit { | ||||
|   documents: PaperlessDocument[] = [] | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.documentService.list(1,10,this.savedView.sort_field, this.savedView.sort_reverse, this.savedView.filter_rules).subscribe(result => { | ||||
|     this.documentService.listFiltered(1,10,this.savedView.sort_field, this.savedView.sort_reverse, this.savedView.filter_rules).subscribe(result => { | ||||
|       this.documents = result.results | ||||
|     }) | ||||
|   } | ||||
|   | ||||
| @@ -1,7 +1,15 @@ | ||||
| <div class="card mb-3 bg-light shadow-sm"> | ||||
| <div class="card mb-3 bg-light shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable"> | ||||
|   <div class="row no-gutters"> | ||||
|     <div class="col-md-2 d-none d-lg-block"> | ||||
|       <img [src]="getThumbUrl()" class="card-img doc-img border-right"> | ||||
|     <div class="col-md-2 d-none d-lg-block doc-img-background" [class.doc-img-background-selected]="selected"> | ||||
|       <img [src]="getThumbUrl()" class="card-img doc-img border-right" (click)="selected = selectable ? !selected : false"> | ||||
|  | ||||
|       <div style="top: 0; left: 0" class="position-absolute border-right border-bottom bg-light p-1" [class.document-card-check]="!selected"> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
|           <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (change)="selected = $event.target.checked"> | ||||
|           <label class="custom-control-label" for="smallCardCheck{{document.id}}"></label> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|       <div class="card-body"> | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| @import "/src/theme"; | ||||
|  | ||||
| .result-content { | ||||
|   color: darkgray; | ||||
|   overflow-wrap: anywhere; | ||||
| } | ||||
|  | ||||
| @@ -8,11 +9,31 @@ | ||||
|   object-position: top; | ||||
|   height: 100%; | ||||
|   position: absolute; | ||||
|  | ||||
|   mix-blend-mode: multiply; | ||||
| } | ||||
|  | ||||
| .search-score-bar { | ||||
|   width: 100px; | ||||
|   height: 5px; | ||||
|   margin-top: 2px; | ||||
| } | ||||
|  | ||||
| .document-card-check { | ||||
|   display: none | ||||
| } | ||||
|  | ||||
| .document-card:hover .document-card-check { | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .card-selected { | ||||
|   border-color: $primary; | ||||
| } | ||||
|  | ||||
| .doc-img-background { | ||||
|   background-color: white; | ||||
| } | ||||
|  | ||||
| .doc-img-background-selected { | ||||
|   background-color: $primaryFaded; | ||||
| } | ||||
| @@ -12,6 +12,25 @@ export class DocumentCardLargeComponent implements OnInit { | ||||
|  | ||||
|   constructor(private documentService: DocumentService, private sanitizer: DomSanitizer) { } | ||||
|  | ||||
|   _selected = false | ||||
|  | ||||
|   get selected() { | ||||
|     return this._selected | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   set selected(value: boolean) { | ||||
|     this._selected = value | ||||
|     this.selectedChange.emit(value) | ||||
|   } | ||||
|  | ||||
|   @Output() | ||||
|   selectedChange = new EventEmitter<boolean>() | ||||
|  | ||||
|   get selectable() { | ||||
|     return this.selectedChange.observers.length > 0 | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   moreLikeThis: boolean = false | ||||
|  | ||||
|   | ||||
| @@ -10,7 +10,6 @@ | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|  | ||||
|       <div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1"> | ||||
|         <div *ngFor="let t of getTagsLimited$() | async"> | ||||
|           <app-tag [tag]="t" (click)="clickTag.emit(t.id)" [clickable]="true" linkTitle="Filter by tag"></app-tag> | ||||
|   | ||||
| @@ -100,7 +100,7 @@ | ||||
| </div> | ||||
|  | ||||
| <div *ngIf="displayMode == 'largeCards'"> | ||||
|   <app-document-card-large *ngFor="let d of list.documents" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"> | ||||
|   <app-document-card-large [selected]="list.isSelected(d)" (selectedChange)="list.setSelected(d, $event)"   *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"> | ||||
|   </app-document-card-large> | ||||
| </div> | ||||
|  | ||||
| @@ -115,7 +115,7 @@ | ||||
|     <th class="d-none d-xl-table-cell">Added</th> | ||||
|   </thead> | ||||
|   <tbody> | ||||
|     <tr *ngFor="let d of list.documents" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''"> | ||||
|     <tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''"> | ||||
|       <td> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
|           <input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (change)="list.setSelected(d, $event.target.checked)"> | ||||
| @@ -149,7 +149,6 @@ | ||||
|   </tbody> | ||||
| </table> | ||||
|  | ||||
|  | ||||
| <div class="m-n2 row m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> | ||||
|   <app-document-card-small [document]="d" [selected]="list.isSelected(d)" (selectedChange)="list.setSelected(d, $event)" *ngFor="let d of list.documents" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small> | ||||
| <div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> | ||||
|   <app-document-card-small [selected]="list.isSelected(d)" (selectedChange)="list.setSelected(d, $event)"  [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small> | ||||
| </div> | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { map } from 'rxjs/operators'; | ||||
| import { PaperlessDocument } from 'src/app/data/paperless-document'; | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service'; | ||||
| import { CorrespondentService } from 'src/app/services/rest/correspondent.service'; | ||||
| @@ -139,6 +140,10 @@ export class DocumentListComponent implements OnInit { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   trackByDocumentId(index, item: PaperlessDocument) { | ||||
|     return item.id | ||||
|   } | ||||
|  | ||||
|   private executeBulkOperation(method: string, args): Observable<any> { | ||||
|     return this.documentService.bulkEdit(Array.from(this.list.selected), method, args).pipe( | ||||
|       map(r => { | ||||
|   | ||||
| @@ -8,10 +8,9 @@ | ||||
|   <div class="modal-body"> | ||||
|      | ||||
|     <app-input-text title="Name" formControlName="name"></app-input-text> | ||||
|     <app-input-text title="Match" formControlName="match"></app-input-text> | ||||
|     <app-input-select title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> | ||||
|     <app-input-check title="Case insensitive" formControlName="is_insensitive"></app-input-check> | ||||
|  | ||||
|     <app-input-text title="Match" formControlName="match" hint="Auto matching does not require you to fill in this field."></app-input-text> | ||||
|     <app-input-check title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check> | ||||
|   </div> | ||||
|   <div class="modal-footer"> | ||||
|     <button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button> | ||||
|   | ||||
| @@ -8,9 +8,9 @@ | ||||
|     <div class="modal-body"> | ||||
|        | ||||
|       <app-input-text title="Name" formControlName="name"></app-input-text> | ||||
|       <app-input-text title="Match" formControlName="match"></app-input-text> | ||||
|       <app-input-select title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> | ||||
|       <app-input-check title="Case insensitive" formControlName="is_insensitive"></app-input-check> | ||||
|       <app-input-text title="Match" formControlName="match" hint="Auto matching does not require you to fill in this field."></app-input-text> | ||||
|       <app-input-check title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check> | ||||
|  | ||||
|     </div> | ||||
|     <div class="modal-footer"> | ||||
|   | ||||
| @@ -7,11 +7,21 @@ | ||||
|     </div> | ||||
|     <div class="modal-body"> | ||||
|       <app-input-text title="Name" formControlName="name"></app-input-text> | ||||
|       <app-input-select title="Colour" [items]="getColours()" formControlName="colour" [textColor]="getColor(objectForm.value.colour).textColor" [backgroundColor]="getColor(objectForm.value.colour).value"></app-input-select> | ||||
|  | ||||
|  | ||||
|       <div class="form-group paperless-input-select"> | ||||
|         <label for="colour">Colour</label> | ||||
|         <ng-select name="colour" formControlName="colour" [items]="getColours()" bindValue="id" bindLabel="name" [clearable]="false"> | ||||
|           <ng-template ng-option-tmp ng-label-tmp let-item="item"> | ||||
|             <span class="badge" [style.background]="item.value" [style.color]="item.textColor">{{item.name}}</span> | ||||
|           </ng-template> | ||||
|         </ng-select> | ||||
|       </div> | ||||
|       | ||||
|       <app-input-check title="Inbox tag" formControlName="is_inbox_tag" hint="Inbox tags are automatically assigned to all consumed documents."></app-input-check> | ||||
|       <app-input-text title="Match" formControlName="match"></app-input-text> | ||||
|       <app-input-select title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> | ||||
|       <app-input-check title="Case insensitive" formControlName="is_insensitive"></app-input-check> | ||||
|       <app-input-text title="Match" formControlName="match" hint="Auto matching does not require you to fill in this field."></app-input-text> | ||||
|       <app-input-check title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check> | ||||
|     </div> | ||||
|     <div class="modal-footer"> | ||||
|       <button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button> | ||||
|   | ||||
| @@ -6,14 +6,14 @@ export const TAG_COLOURS = [ | ||||
|     {id: 1, value: "#a6cee3", name: "Light Blue", textColor: "#000000"}, | ||||
|     {id: 2, value: "#1f78b4", name: "Blue", textColor: "#ffffff"}, | ||||
|     {id: 3, value: "#b2df8a", name: "Light Green", textColor: "#000000"}, | ||||
|     {id: 4, value: "#33a02c", name: "Green", textColor: "#000000"}, | ||||
|     {id: 4, value: "#33a02c", name: "Green", textColor: "#ffffff"}, | ||||
|     {id: 5, value: "#fb9a99", name: "Light Red", textColor: "#000000"}, | ||||
|     {id: 6, value: "#e31a1c", name: "Red ", textColor: "#ffffff"}, | ||||
|     {id: 7, value: "#fdbf6f", name: "Light Orange", textColor: "#000000"}, | ||||
|     {id: 8, value: "#ff7f00", name: "Orange", textColor: "#000000"}, | ||||
|     {id: 9, value: "#cab2d6", name: "Light Violet", textColor: "#000000"}, | ||||
|     {id: 10, value: "#6a3d9a", name: "Violet", textColor: "#ffffff"}, | ||||
|     {id: 11, value: "#b15928", name: "Brown", textColor: "#000000"}, | ||||
|     {id: 11, value: "#b15928", name: "Brown", textColor: "#ffffff"}, | ||||
|     {id: 12, value: "#000000", name: "Black", textColor: "#ffffff"}, | ||||
|     {id: 13, value: "#cccccc", name: "Light Grey", textColor: "#000000"} | ||||
| ] | ||||
|   | ||||
| @@ -1,18 +1,29 @@ | ||||
| import json | ||||
| import os | ||||
| import shutil | ||||
| from contextlib import contextmanager | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.management import call_command | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
| from django.db.models.signals import post_save, m2m_changed | ||||
| from filelock import FileLock | ||||
|  | ||||
| from documents.models import Document | ||||
| from documents.settings import EXPORTER_FILE_NAME, EXPORTER_THUMBNAIL_NAME, \ | ||||
|     EXPORTER_ARCHIVE_NAME | ||||
| from ...file_handling import create_source_path_directory, \ | ||||
|     generate_unique_filename | ||||
| from ...file_handling import create_source_path_directory | ||||
| from ...mixins import Renderable | ||||
| from ...signals.handlers import update_filename_and_move_files | ||||
|  | ||||
|  | ||||
| @contextmanager | ||||
| def disable_signal(sig, receiver, sender): | ||||
|     try: | ||||
|         sig.disconnect(receiver=receiver, sender=sender) | ||||
|         yield | ||||
|     finally: | ||||
|         sig.connect(receiver=receiver, sender=sender) | ||||
|  | ||||
|  | ||||
| class Command(Renderable, BaseCommand): | ||||
| @@ -47,11 +58,16 @@ class Command(Renderable, BaseCommand): | ||||
|             self.manifest = json.load(f) | ||||
|  | ||||
|         self._check_manifest() | ||||
|         with disable_signal(post_save, | ||||
|                             receiver=update_filename_and_move_files, | ||||
|                             sender=Document): | ||||
|             with disable_signal(m2m_changed, | ||||
|                                 receiver=update_filename_and_move_files, | ||||
|                                 sender=Document.tags.through): | ||||
|                 # Fill up the database with whatever is in the manifest | ||||
|                 call_command("loaddata", manifest_path) | ||||
|  | ||||
|         # Fill up the database with whatever is in the manifest | ||||
|         call_command("loaddata", manifest_path) | ||||
|  | ||||
|         self._import_files_from_manifest() | ||||
|                 self._import_files_from_manifest() | ||||
|  | ||||
|     @staticmethod | ||||
|     def _check_manifest_exists(path): | ||||
| @@ -117,9 +133,6 @@ class Command(Renderable, BaseCommand): | ||||
|             document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED | ||||
|  | ||||
|             with FileLock(settings.MEDIA_LOCK): | ||||
|                 document.filename = generate_unique_filename( | ||||
|                     document, settings.ORIGINALS_DIR) | ||||
|  | ||||
|                 if os.path.isfile(document.source_path): | ||||
|                     raise FileExistsError(document.source_path) | ||||
|  | ||||
|   | ||||
| @@ -8,6 +8,8 @@ | ||||
|   <title>Paperless-ng</title> | ||||
|   <base href="/"> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
| 	<meta name="username" content="{{username}}"> | ||||
| 	<meta name="full_name" content="{{full_name}}"> | ||||
| 	<meta name="cookie_prefix" content="{{cookie_prefix}}"> | ||||
|   <link rel="icon" type="image/x-icon" href="favicon.ico"> | ||||
| <link rel="stylesheet" href="{% static 'frontend/styles.css' %}"></head> | ||||
|   | ||||
| @@ -8,7 +8,7 @@ from django.contrib.auth.models import User | ||||
| from rest_framework.test import APITestCase | ||||
| from whoosh.writing import AsyncWriter | ||||
|  | ||||
| from documents import index | ||||
| from documents import index, bulk_edit | ||||
| from documents.models import Document, Correspondent, DocumentType, Tag, SavedView | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
|  | ||||
| @@ -615,3 +615,115 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|  | ||||
|         v1 = SavedView.objects.get(id=v1.id) | ||||
|         self.assertEqual(v1.filter_rules.count(), 0) | ||||
|  | ||||
|  | ||||
| class TestBulkEdit(DirectoriesMixin, APITestCase): | ||||
|  | ||||
|     def setUp(self): | ||||
|         super(TestBulkEdit, self).setUp() | ||||
|  | ||||
|         user = User.objects.create_superuser(username="temp_admin") | ||||
|         self.client.force_login(user=user) | ||||
|  | ||||
|         patcher = mock.patch('documents.bulk_edit.async_task') | ||||
|         self.async_task = patcher.start() | ||||
|         self.addCleanup(patcher.stop) | ||||
|         self.c1 = Correspondent.objects.create(name="c1") | ||||
|         self.c2 = Correspondent.objects.create(name="c2") | ||||
|         self.dt1 = DocumentType.objects.create(name="dt1") | ||||
|         self.dt2 = DocumentType.objects.create(name="dt2") | ||||
|         self.t1 = Tag.objects.create(name="t1") | ||||
|         self.t2 = Tag.objects.create(name="t2") | ||||
|         self.doc1 = Document.objects.create(checksum="A", title="A") | ||||
|         self.doc2 = Document.objects.create(checksum="B", title="B", correspondent=self.c1, document_type=self.dt1) | ||||
|         self.doc3 = Document.objects.create(checksum="C", title="C", correspondent=self.c2, document_type=self.dt2) | ||||
|         self.doc4 = Document.objects.create(checksum="D", title="D") | ||||
|         self.doc5 = Document.objects.create(checksum="E", title="E") | ||||
|         self.doc2.tags.add(self.t1) | ||||
|         self.doc3.tags.add(self.t2) | ||||
|         self.doc4.tags.add(self.t1, self.t2) | ||||
|  | ||||
|     def test_set_correspondent(self): | ||||
|         self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 1) | ||||
|         bulk_edit.set_correspondent([self.doc1.id, self.doc2.id, self.doc3.id], self.c2.id) | ||||
|         self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 3) | ||||
|         self.async_task.assert_called_once() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|         self.assertCountEqual(kwargs['document_ids'], [self.doc1.id, self.doc2.id]) | ||||
|  | ||||
|     def test_unset_correspondent(self): | ||||
|         self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 1) | ||||
|         bulk_edit.set_correspondent([self.doc1.id, self.doc2.id, self.doc3.id], None) | ||||
|         self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 0) | ||||
|         self.async_task.assert_called_once() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|         self.assertCountEqual(kwargs['document_ids'], [self.doc2.id, self.doc3.id]) | ||||
|  | ||||
|     def test_set_document_type(self): | ||||
|         self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 1) | ||||
|         bulk_edit.set_document_type([self.doc1.id, self.doc2.id, self.doc3.id], self.dt2.id) | ||||
|         self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 3) | ||||
|         self.async_task.assert_called_once() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|         self.assertCountEqual(kwargs['document_ids'], [self.doc1.id, self.doc2.id]) | ||||
|  | ||||
|     def test_unset_document_type(self): | ||||
|         self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 1) | ||||
|         bulk_edit.set_document_type([self.doc1.id, self.doc2.id, self.doc3.id], None) | ||||
|         self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 0) | ||||
|         self.async_task.assert_called_once() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|         self.assertCountEqual(kwargs['document_ids'], [self.doc2.id, self.doc3.id]) | ||||
|  | ||||
|     def test_add_tag(self): | ||||
|         self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2) | ||||
|         bulk_edit.add_tag([self.doc1.id, self.doc2.id, self.doc3.id, self.doc4.id], self.t1.id) | ||||
|         self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 4) | ||||
|         self.async_task.assert_called_once() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|         self.assertCountEqual(kwargs['document_ids'], [self.doc1.id, self.doc3.id]) | ||||
|  | ||||
|  | ||||
|     def test_remove_tag(self): | ||||
|         self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2) | ||||
|         bulk_edit.remove_tag([self.doc1.id, self.doc3.id, self.doc4.id], self.t1.id) | ||||
|         self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 1) | ||||
|         self.async_task.assert_called_once() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|         self.assertCountEqual(kwargs['document_ids'], [self.doc4.id]) | ||||
|  | ||||
|     def test_delete(self): | ||||
|         self.assertEqual(Document.objects.count(), 5) | ||||
|         bulk_edit.delete([self.doc1.id, self.doc2.id]) | ||||
|         self.assertEqual(Document.objects.count(), 3) | ||||
|         self.assertCountEqual([doc.id for doc in Document.objects.all()], [self.doc3.id, self.doc4.id, self.doc5.id]) | ||||
|  | ||||
|     def test_api(self): | ||||
|         self.assertEqual(Document.objects.count(), 5) | ||||
|         response = self.client.post("/api/documents/bulk_edit/", json.dumps({ | ||||
|             "documents": [self.doc1.id], | ||||
|             "method": "delete", | ||||
|             "parameters": {} | ||||
|         }), content_type='application/json') | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual(Document.objects.count(), 4) | ||||
|  | ||||
|     def test_api_invalid_doc(self): | ||||
|         self.assertEqual(Document.objects.count(), 5) | ||||
|         response = self.client.post("/api/documents/bulk_edit/", json.dumps({ | ||||
|             "documents": [-235], | ||||
|             "method": "delete", | ||||
|             "parameters": {} | ||||
|         }), content_type='application/json') | ||||
|         self.assertEqual(response.status_code, 400) | ||||
|         self.assertEqual(Document.objects.count(), 5) | ||||
|  | ||||
|     def test_api_invalid_method(self): | ||||
|         self.assertEqual(Document.objects.count(), 5) | ||||
|         response = self.client.post("/api/documents/bulk_edit/", json.dumps({ | ||||
|             "documents": [self.doc2.id], | ||||
|             "method": "exterminate", | ||||
|             "parameters": {} | ||||
|         }), content_type='application/json') | ||||
|         self.assertEqual(response.status_code, 400) | ||||
|         self.assertEqual(Document.objects.count(), 5) | ||||
|   | ||||
| @@ -24,11 +24,17 @@ class TestExportImport(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         file = os.path.join(self.dirs.originals_dir, "0000001.pdf") | ||||
|  | ||||
|         Document.objects.create(content="Content", checksum="42995833e01aea9b3edee44bbfdd7ce1", archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b", title="wow", filename="0000001.pdf", mime_type="application/pdf") | ||||
|         Document.objects.create(content="Content", checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg", mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG) | ||||
|         Tag.objects.create(name="t") | ||||
|         DocumentType.objects.create(name="dt") | ||||
|         Correspondent.objects.create(name="c") | ||||
|         d1 = Document.objects.create(content="Content", checksum="42995833e01aea9b3edee44bbfdd7ce1", archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b", title="wow", filename="0000001.pdf", mime_type="application/pdf") | ||||
|         d2 = Document.objects.create(content="Content", checksum="9c9691e51741c1f4f41a20896af31770", title="wow", filename="0000002.pdf.gpg", mime_type="application/pdf", storage_type=Document.STORAGE_TYPE_GPG) | ||||
|         t1 = Tag.objects.create(name="t") | ||||
|         dt1 = DocumentType.objects.create(name="dt") | ||||
|         c1 = Correspondent.objects.create(name="c") | ||||
|  | ||||
|         d1.tags.add(t1) | ||||
|         d1.correspondents = c1 | ||||
|         d1.document_type = dt1 | ||||
|         d1.save() | ||||
|         d2.save() | ||||
|  | ||||
|         target = tempfile.mkdtemp() | ||||
|         self.addCleanup(shutil.rmtree, target) | ||||
| @@ -59,11 +65,25 @@ class TestExportImport(DirectoriesMixin, TestCase): | ||||
|                     self.assertEqual(checksum, element['fields']['archive_checksum']) | ||||
|  | ||||
|         with paperless_environment() as dirs: | ||||
|             self.assertEqual(Document.objects.count(), 2) | ||||
|             Document.objects.all().delete() | ||||
|             Correspondent.objects.all().delete() | ||||
|             DocumentType.objects.all().delete() | ||||
|             Tag.objects.all().delete() | ||||
|             self.assertEqual(Document.objects.count(), 0) | ||||
|  | ||||
|             call_command('document_importer', target) | ||||
|             self.assertEqual(Document.objects.count(), 2) | ||||
|             messages = check_sanity() | ||||
|             # everything is alright after the test | ||||
|             self.assertEqual(len(messages), 0, str([str(m) for m in messages])) | ||||
|  | ||||
|     @override_settings( | ||||
|         PAPERLESS_FILENAME_FORMAT="{title}" | ||||
|     ) | ||||
|     def test_exporter_with_filename_format(self): | ||||
|         self.test_exporter() | ||||
|  | ||||
|     def test_export_missing_files(self): | ||||
|  | ||||
|         target = tempfile.mkdtemp() | ||||
|   | ||||
| @@ -58,6 +58,8 @@ class IndexView(TemplateView): | ||||
|     def get_context_data(self, **kwargs): | ||||
|         context = super().get_context_data(**kwargs) | ||||
|         context['cookie_prefix'] = settings.COOKIE_PREFIX | ||||
|         context['username'] = self.request.user.username | ||||
|         context['full_name'] = self.request.user.get_full_name() | ||||
|         return context | ||||
|  | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Michael Shamoon
					Michael Shamoon