- Items per page
+ Items per page
@@ -31,36 +31,36 @@
- Saved views
+ Saved views
-
No saved views defined.
+
No saved views defined.
diff --git a/src-ui/src/app/components/manage/settings/settings.component.ts b/src-ui/src/app/components/manage/settings/settings.component.ts
index f839010b1..bec85e039 100644
--- a/src-ui/src/app/components/manage/settings/settings.component.ts
+++ b/src-ui/src/app/components/manage/settings/settings.component.ts
@@ -46,14 +46,14 @@ export class SettingsComponent implements OnInit {
this.savedViewService.delete(savedView).subscribe(() => {
this.savedViewGroup.removeControl(savedView.id.toString())
this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
- this.toastService.showToast(Toast.make("Information", `Saved view "${savedView.name} deleted.`))
+ this.toastService.showToast(Toast.make("Information", $localize`Saved view "${savedView.name} deleted.`))
})
}
private saveLocalSettings() {
localStorage.setItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage)
this.documentListViewService.updatePageSize()
- this.toastService.showToast(Toast.make("Information", "Settings saved successfully."))
+ this.toastService.showToast(Toast.make("Information", $localize`Settings saved successfully.`))
}
saveSettings() {
@@ -65,7 +65,7 @@ export class SettingsComponent implements OnInit {
this.savedViewService.patchMany(x).subscribe(s => {
this.saveLocalSettings()
}, error => {
- this.toastService.showToast(Toast.makeError(`Error while storing settings on server: ${JSON.stringify(error.error)}`))
+ this.toastService.showToast(Toast.makeError($localize`Error while storing settings on server: ${JSON.stringify(error.error)}`))
})
} else {
this.saveLocalSettings()
diff --git a/src-ui/src/app/components/not-found/not-found.component.html b/src-ui/src/app/components/not-found/not-found.component.html
index 6b21cf3ba..4d7e0f7e0 100644
--- a/src-ui/src/app/components/not-found/not-found.component.html
+++ b/src-ui/src/app/components/not-found/not-found.component.html
@@ -4,5 +4,5 @@
- 404 Not Found
+ 404 Not Found
\ No newline at end of file
diff --git a/src-ui/src/app/components/search/search.component.html b/src-ui/src/app/components/search/search.component.html
index de6f0133f..ba2253401 100644
--- a/src-ui/src/app/components/search/search.component.html
+++ b/src-ui/src/app/components/search/search.component.html
@@ -1,7 +1,7 @@
-
Invalid search query: {{errorMessage}}
+
Invalid search query: {{errorMessage}}
Showing documents similar to
diff --git a/src-ui/src/app/data/matching-model.ts b/src-ui/src/app/data/matching-model.ts
index 698c32da5..dd9ae95ff 100644
--- a/src-ui/src/app/data/matching-model.ts
+++ b/src-ui/src/app/data/matching-model.ts
@@ -9,12 +9,12 @@ export const MATCH_FUZZY = 5
export const MATCH_AUTO = 6
export const MATCHING_ALGORITHMS = [
- {id: MATCH_ANY, name: "Any"},
- {id: MATCH_ALL, name: "All"},
- {id: MATCH_LITERAL, name: "Literal"},
- {id: MATCH_REGEX, name: "Regular Expression"},
- {id: MATCH_FUZZY, name: "Fuzzy Match"},
- {id: MATCH_AUTO, name: "Auto"},
+ {id: MATCH_ANY, name: $localize`Any`},
+ {id: MATCH_ALL, name: $localize`All`},
+ {id: MATCH_LITERAL, name: $localize`Literal`},
+ {id: MATCH_REGEX, name: $localize`Regular expression`},
+ {id: MATCH_FUZZY, name: $localize`Fuzzy match`},
+ {id: MATCH_AUTO, name: $localize`Auto`},
]
export interface MatchingModel extends ObjectWithId {
diff --git a/src-ui/src/app/services/document-list-view.service.ts b/src-ui/src/app/services/document-list-view.service.ts
index 57d0a3f0e..b148d4087 100644
--- a/src-ui/src/app/services/document-list-view.service.ts
+++ b/src-ui/src/app/services/document-list-view.service.ts
@@ -40,10 +40,14 @@ export class DocumentListViewService {
}
set savedView(value: PaperlessSavedView) {
- if (value) {
+ if (value && !this._savedViewConfig || value && value.id != this._savedViewConfig.id) {
+ //saved view inactive and should be active now, or saved view active, but a different view is requested
//this is here so that we don't modify value, which might be the actual instance of the saved view.
+ this.selectNone()
this._savedViewConfig = Object.assign({}, value)
- } else {
+ } else if (this._savedViewConfig && !value) {
+ //saved view active, but document list requested
+ this.selectNone()
this._savedViewConfig = null
}
}
@@ -90,7 +94,7 @@ export class DocumentListViewService {
reload(onFinish?) {
this.isReloading = true
- this.documentService.list(
+ this.documentService.listFiltered(
this.currentPage,
this.currentPageSize,
this.view.sort_field,
@@ -118,6 +122,7 @@ export class DocumentListViewService {
//want changes in the filter editor to propagate into here right away.
this.view.filter_rules = filterRules
this.reload()
+ this.reduceSelectionToFilter()
this.saveDocumentListView()
}
@@ -191,6 +196,49 @@ export class DocumentListViewService {
}
}
+ selected = new Set()
+
+ selectNone() {
+ this.selected.clear()
+ }
+
+ private reduceSelectionToFilter() {
+ if (this.selected.size > 0) {
+ this.documentService.listAllFilteredIds(this.filterRules).subscribe(ids => {
+ let subset = new Set()
+ for (let id of ids) {
+ if (this.selected.has(id)) {
+ subset.add(id)
+ }
+ }
+ this.selected = subset
+ })
+ }
+ }
+
+ selectAll() {
+ this.documentService.listAllFilteredIds(this.filterRules).subscribe(ids => ids.forEach(id => this.selected.add(id)))
+ }
+
+ selectPage() {
+ this.selected.clear()
+ this.documents.forEach(doc => {
+ this.selected.add(doc.id)
+ })
+ }
+
+ isSelected(d: PaperlessDocument) {
+ return this.selected.has(d.id)
+ }
+
+ setSelected(d: PaperlessDocument, value: boolean) {
+ if (value) {
+ this.selected.add(d.id)
+ } else if (!value) {
+ this.selected.delete(d.id)
+ }
+ }
+
constructor(private documentService: DocumentService) {
let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
if (documentListViewConfigJson) {
diff --git a/src-ui/src/app/services/open-documents.service.ts b/src-ui/src/app/services/open-documents.service.ts
index e37f5db8c..c91031f83 100644
--- a/src-ui/src/app/services/open-documents.service.ts
+++ b/src-ui/src/app/services/open-documents.service.ts
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { PaperlessDocument } from '../data/paperless-document';
import { OPEN_DOCUMENT_SERVICE } from '../data/storage-keys';
+import { DocumentService } from './rest/document.service';
@Injectable({
providedIn: 'root'
@@ -9,7 +10,7 @@ export class OpenDocumentsService {
private MAX_OPEN_DOCUMENTS = 5
- constructor() {
+ constructor(private documentService: DocumentService) {
if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) {
try {
this.openDocuments = JSON.parse(sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS))
@@ -22,6 +23,15 @@ export class OpenDocumentsService {
private openDocuments: PaperlessDocument[] = []
+ refreshDocument(id: number) {
+ let index = this.openDocuments.findIndex(doc => doc.id == id)
+ if (index > -1) {
+ this.documentService.get(id).subscribe(doc => {
+ this.openDocuments[index] = doc
+ })
+ }
+ }
+
getOpenDocuments(): PaperlessDocument[] {
return this.openDocuments
}
diff --git a/src-ui/src/app/services/rest/abstract-paperless-service.ts b/src-ui/src/app/services/rest/abstract-paperless-service.ts
index 93e1a0c85..f57956754 100644
--- a/src-ui/src/app/services/rest/abstract-paperless-service.ts
+++ b/src-ui/src/app/services/rest/abstract-paperless-service.ts
@@ -52,9 +52,9 @@ export abstract class AbstractPaperlessService {
private _listAll: Observable>
- listAll(ordering?: string, extraParams?): Observable> {
+ listAll(sortField?: string, sortReverse?: boolean, extraParams?): Observable> {
if (!this._listAll) {
- this._listAll = this.list(1, 100000, ordering, extraParams).pipe(
+ this._listAll = this.list(1, 100000, sortField, sortReverse, extraParams).pipe(
publishReplay(1),
refCount()
)
diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts
index f50620d23..ec5c7689b 100644
--- a/src-ui/src/app/services/rest/document.service.ts
+++ b/src-ui/src/app/services/rest/document.service.ts
@@ -13,13 +13,13 @@ import { TagService } from './tag.service';
import { FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
export const DOCUMENT_SORT_FIELDS = [
- { field: "correspondent__name", name: "Correspondent" },
- { field: "document_type__name", name: "Document type" },
- { field: 'title', name: 'Title' },
- { field: 'archive_serial_number', name: 'ASN' },
- { field: 'created', name: 'Created' },
- { field: 'added', name: 'Added' },
- { field: 'modified', name: 'Modified' }
+ { field: "correspondent__name", name: $localize`Correspondent` },
+ { field: "document_type__name", name: $localize`Document type` },
+ { field: 'title', name: $localize`Title` },
+ { field: 'archive_serial_number', name: $localize`ASN` },
+ { field: 'created', name: $localize`Created` },
+ { field: 'added', name: $localize`Added` },
+ { field: 'modified', name: $localize`Modified` }
]
@Injectable({
@@ -61,8 +61,8 @@ export class DocumentService extends AbstractPaperlessService
return doc
}
- list(page?: number, pageSize?: number, sortField?: string, sortReverse?: boolean, filterRules?: FilterRule[]): Observable> {
- return super.list(page, pageSize, sortField, sortReverse, this.filterRulesToQueryParams(filterRules)).pipe(
+ listFiltered(page?: number, pageSize?: number, sortField?: string, sortReverse?: boolean, filterRules?: FilterRule[], extraParams = {}): Observable> {
+ return this.list(page, pageSize, sortField, sortReverse, Object.assign(extraParams, this.filterRulesToQueryParams(filterRules))).pipe(
map(results => {
results.results.forEach(doc => this.addObservablesToDocument(doc))
return results
@@ -70,6 +70,12 @@ export class DocumentService extends AbstractPaperlessService
)
}
+ listAllFilteredIds(filterRules?: FilterRule[]): Observable {
+ return this.listFiltered(1, 100000, null, null, filterRules, {"fields": "id"}).pipe(
+ map(response => response.results.map(doc => doc.id))
+ )
+ }
+
getPreviewUrl(id: number, original: boolean = false): string {
let url = this.getResourceUrl(id, 'preview')
if (original) {
@@ -98,4 +104,12 @@ export class DocumentService extends AbstractPaperlessService
return this.http.get(this.getResourceUrl(id, 'metadata'))
}
+ bulkEdit(ids: number[], method: string, args: any) {
+ return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
+ 'documents': ids,
+ 'method': method,
+ 'parameters': args
+ })
+ }
+
}
diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts
index f12c6a7cb..ab6b07c73 100644
--- a/src-ui/src/environments/environment.prod.ts
+++ b/src-ui/src/environments/environment.prod.ts
@@ -2,5 +2,5 @@ export const environment = {
production: true,
apiBaseUrl: "/api/",
appTitle: "Paperless-ng",
- version: "0.9.8"
+ version: "0.9.9"
};
diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss
index 88f3ae30f..df2aea003 100644
--- a/src-ui/src/theme.scss
+++ b/src-ui/src/theme.scss
@@ -1,5 +1,6 @@
$paperless-green: #17541f;
$primary: #17541f;
+$primaryFaded: #d1ddd2;
$theme-colors: (
"primary": $primary
diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py
new file mode 100644
index 000000000..aa5b8ea3f
--- /dev/null
+++ b/src/documents/bulk_edit.py
@@ -0,0 +1,72 @@
+from django.db.models import Q
+from django_q.tasks import async_task
+
+from documents.models import Document, Correspondent, DocumentType
+
+
+def set_correspondent(doc_ids, correspondent):
+ if correspondent:
+ correspondent = Correspondent.objects.get(id=correspondent)
+
+ qs = Document.objects.filter(
+ Q(id__in=doc_ids) & ~Q(correspondent=correspondent))
+ affected_docs = [doc.id for doc in qs]
+ qs.update(correspondent=correspondent)
+
+ async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
+
+ return "OK"
+
+
+def set_document_type(doc_ids, document_type):
+ if document_type:
+ document_type = DocumentType.objects.get(id=document_type)
+
+ qs = Document.objects.filter(
+ Q(id__in=doc_ids) & ~Q(document_type=document_type))
+ affected_docs = [doc.id for doc in qs]
+ qs.update(document_type=document_type)
+
+ async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
+
+ return "OK"
+
+
+def add_tag(doc_ids, tag):
+
+ qs = Document.objects.filter(Q(id__in=doc_ids) & ~Q(tags__id=tag))
+ affected_docs = [doc.id for doc in qs]
+
+ DocumentTagRelationship = Document.tags.through
+
+ DocumentTagRelationship.objects.bulk_create([
+ DocumentTagRelationship(
+ document_id=doc, tag_id=tag) for doc in affected_docs
+ ])
+
+ async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
+
+ return "OK"
+
+
+def remove_tag(doc_ids, tag):
+
+ qs = Document.objects.filter(Q(id__in=doc_ids) & Q(tags__id=tag))
+ affected_docs = [doc.id for doc in qs]
+
+ DocumentTagRelationship = Document.tags.through
+
+ DocumentTagRelationship.objects.filter(
+ Q(document_id__in=affected_docs) &
+ Q(tag_id=tag)
+ ).delete()
+
+ async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
+
+ return "OK"
+
+
+def delete(doc_ids):
+ Document.objects.filter(id__in=doc_ids).delete()
+
+ return "OK"
diff --git a/src/documents/checks.py b/src/documents/checks.py
index b6da5bfc9..ba55b1397 100644
--- a/src/documents/checks.py
+++ b/src/documents/checks.py
@@ -51,6 +51,6 @@ def parser_check(app_configs, **kwargs):
if len(parsers) == 0:
return [Error("No parsers found. This is a bug. The consumer won't be "
- "able to onsume any documents without parsers.")]
+ "able to consume any documents without parsers.")]
else:
return []
diff --git a/src/documents/consumer.py b/src/documents/consumer.py
index ab4912a36..ab07b3149 100755
--- a/src/documents/consumer.py
+++ b/src/documents/consumer.py
@@ -95,19 +95,20 @@ class Consumer(LoggingMixin):
self.pre_check_directories()
self.pre_check_duplicate()
- self.log("info", "Consuming {}".format(self.filename))
+ self.log("info", f"Consuming {self.filename}")
# Determine the parser class.
mime_type = magic.from_file(self.path, mime=True)
+ self.log("debug", f"Detected mime type: {mime_type}")
+
parser_class = get_parser_class_for_mime_type(mime_type)
if not parser_class:
- raise ConsumerError(f"No parsers abvailable for {self.filename}")
+ raise ConsumerError(f"No parsers available for {self.filename}")
else:
self.log("debug",
- f"Parser: {parser_class.__name__} "
- f"based on mime type {mime_type}")
+ f"Parser: {parser_class.__name__}")
# Notify all listeners that we're going to do some work.
diff --git a/src/documents/management/commands/document_importer.py b/src/documents/management/commands/document_importer.py
index 8e9a79219..6df14a82c 100644
--- a/src/documents/management/commands/document_importer.py
+++ b/src/documents/management/commands/document_importer.py
@@ -1,8 +1,10 @@
import json
+import logging
import os
import shutil
from contextlib import contextmanager
+import tqdm
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
@@ -43,6 +45,8 @@ class Command(Renderable, BaseCommand):
def handle(self, *args, **options):
+ logging.getLogger().handlers[0].level = logging.ERROR
+
self.source = options["source"]
if not os.path.exists(self.source):
@@ -69,6 +73,9 @@ class Command(Renderable, BaseCommand):
self._import_files_from_manifest()
+ print("Updating search index...")
+ call_command('document_index', 'reindex')
+
@staticmethod
def _check_manifest_exists(path):
if not os.path.exists(path):
@@ -111,10 +118,13 @@ class Command(Renderable, BaseCommand):
os.makedirs(settings.THUMBNAIL_DIR, exist_ok=True)
os.makedirs(settings.ARCHIVE_DIR, exist_ok=True)
- for record in self.manifest:
+ print("Copy files into paperless...")
- if not record["model"] == "documents.document":
- continue
+ manifest_documents = list(filter(
+ lambda r: r["model"] == "documents.document",
+ self.manifest))
+
+ for record in tqdm.tqdm(manifest_documents):
document = Document.objects.get(pk=record["pk"])
@@ -138,7 +148,6 @@ class Command(Renderable, BaseCommand):
create_source_path_directory(document.source_path)
- print(f"Moving {document_path} to {document.source_path}")
shutil.copy(document_path, document.source_path)
shutil.copy(thumbnail_path, document.thumbnail_path)
if archive_path:
diff --git a/src/documents/management/commands/document_index.py b/src/documents/management/commands/document_index.py
index 7dfdbaa42..08e20e1d2 100644
--- a/src/documents/management/commands/document_index.py
+++ b/src/documents/management/commands/document_index.py
@@ -1,4 +1,5 @@
from django.core.management import BaseCommand
+from django.db import transaction
from documents.mixins import Renderable
from documents.tasks import index_reindex, index_optimize
@@ -18,8 +19,8 @@ class Command(Renderable, BaseCommand):
def handle(self, *args, **options):
self.verbosity = options["verbosity"]
-
- if options['command'] == 'reindex':
- index_reindex()
- elif options['command'] == 'optimize':
- index_optimize()
+ with transaction.atomic():
+ if options['command'] == 'reindex':
+ index_reindex()
+ elif options['command'] == 'optimize':
+ index_optimize()
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index ee0a42384..d9f1833bf 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -3,11 +3,34 @@ from django.utils.text import slugify
from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
+from . import bulk_edit
from .models import Correspondent, Tag, Document, Log, DocumentType, \
SavedView, SavedViewFilterRule
from .parsers import is_mime_type_supported
+# https://www.django-rest-framework.org/api-guide/serializers/#example
+class DynamicFieldsModelSerializer(serializers.ModelSerializer):
+ """
+ A ModelSerializer that takes an additional `fields` argument that
+ controls which fields should be displayed.
+ """
+
+ def __init__(self, *args, **kwargs):
+ # Don't pass the 'fields' arg up to the superclass
+ fields = kwargs.pop('fields', None)
+
+ # Instantiate the superclass normally
+ super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)
+
+ if fields is not None:
+ # Drop any fields that are not specified in the `fields` argument.
+ allowed = set(fields)
+ existing = set(self.fields)
+ for field_name in existing - allowed:
+ self.fields.pop(field_name)
+
+
class CorrespondentSerializer(serializers.ModelSerializer):
document_count = serializers.IntegerField(read_only=True)
@@ -91,7 +114,7 @@ class DocumentTypeField(serializers.PrimaryKeyRelatedField):
return DocumentType.objects.all()
-class DocumentSerializer(serializers.ModelSerializer):
+class DocumentSerializer(DynamicFieldsModelSerializer):
correspondent = CorrespondentField(allow_null=True)
tags = TagsField(many=True)
@@ -180,6 +203,101 @@ class SavedViewSerializer(serializers.ModelSerializer):
return saved_view
+class BulkEditSerializer(serializers.Serializer):
+
+ documents = serializers.ListField(
+ child=serializers.IntegerField(),
+ label="Documents",
+ write_only=True
+ )
+
+ method = serializers.ChoiceField(
+ choices=[
+ "set_correspondent",
+ "set_document_type",
+ "add_tag",
+ "remove_tag",
+ "delete"
+ ],
+ label="Method",
+ write_only=True,
+ )
+
+ parameters = serializers.DictField(allow_empty=True)
+
+ def validate_documents(self, documents):
+ count = Document.objects.filter(id__in=documents).count()
+ if not count == len(documents):
+ raise serializers.ValidationError(
+ "Some documents don't exist or were specified twice.")
+ return documents
+
+ def validate_method(self, method):
+ if method == "set_correspondent":
+ return bulk_edit.set_correspondent
+ elif method == "set_document_type":
+ return bulk_edit.set_document_type
+ elif method == "add_tag":
+ return bulk_edit.add_tag
+ elif method == "remove_tag":
+ return bulk_edit.remove_tag
+ elif method == "delete":
+ return bulk_edit.delete
+ else:
+ raise serializers.ValidationError("Unsupported method.")
+
+ def _validate_parameters_tags(self, parameters):
+ if 'tag' in parameters:
+ tag_id = parameters['tag']
+ try:
+ Tag.objects.get(id=tag_id)
+ except Tag.DoesNotExist:
+ raise serializers.ValidationError("Tag does not exist")
+ else:
+ raise serializers.ValidationError("tag not specified")
+
+ def _validate_parameters_document_type(self, parameters):
+ if 'document_type' in parameters:
+ document_type_id = parameters['document_type']
+ if document_type_id is None:
+ # None is ok
+ return
+ try:
+ DocumentType.objects.get(id=document_type_id)
+ except DocumentType.DoesNotExist:
+ raise serializers.ValidationError(
+ "Document type does not exist")
+ else:
+ raise serializers.ValidationError("document_type not specified")
+
+ def _validate_parameters_correspondent(self, parameters):
+ if 'correspondent' in parameters:
+ correspondent_id = parameters['correspondent']
+ if correspondent_id is None:
+ return
+ try:
+ Correspondent.objects.get(id=correspondent_id)
+ except Correspondent.DoesNotExist:
+ raise serializers.ValidationError(
+ "Correspondent does not exist")
+ else:
+ raise serializers.ValidationError("correspondent not specified")
+
+ def validate(self, attrs):
+
+ method = attrs['method']
+ parameters = attrs['parameters']
+
+ if method == bulk_edit.set_correspondent:
+ self._validate_parameters_correspondent(parameters)
+ elif method == bulk_edit.set_document_type:
+ self._validate_parameters_document_type(parameters)
+ elif method == bulk_edit.add_tag or method == bulk_edit.remove_tag:
+ self._validate_parameters_tags(parameters)
+
+ return attrs
+
+
class PostDocumentSerializer(serializers.Serializer):
document = serializers.FileField(
diff --git a/src/documents/tasks.py b/src/documents/tasks.py
index 8c9b00dd6..fafe6e10f 100644
--- a/src/documents/tasks.py
+++ b/src/documents/tasks.py
@@ -2,6 +2,7 @@ import logging
import tqdm
from django.conf import settings
+from django.db.models.signals import post_save
from whoosh.writing import AsyncWriter
from documents import index, sanity_checker
@@ -87,3 +88,9 @@ def sanity_check():
raise SanityFailedError(messages)
else:
return "No issues detected."
+
+
+def bulk_rename_files(document_ids):
+ qs = Document.objects.filter(id__in=document_ids)
+ for doc in qs:
+ post_save.send(Document, instance=doc, created=False)
diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py
index ba1ab45ca..0262b6d6a 100644
--- a/src/documents/tests/test_api.py
+++ b/src/documents/tests/test_api.py
@@ -1,3 +1,4 @@
+import json
import os
import shutil
import tempfile
@@ -7,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
@@ -63,6 +64,58 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
self.assertEqual(len(Document.objects.all()), 0)
+ def test_document_fields(self):
+ c = Correspondent.objects.create(name="c", pk=41)
+ dt = DocumentType.objects.create(name="dt", pk=63)
+ tag = Tag.objects.create(name="t", pk=85)
+ doc = Document.objects.create(title="WOW", content="the content", correspondent=c, document_type=dt, checksum="123", mime_type="application/pdf")
+
+ response = self.client.get("/api/documents/", format='json')
+ self.assertEqual(response.status_code, 200)
+ results_full = response.data['results']
+ self.assertTrue("content" in results_full[0])
+ self.assertTrue("id" in results_full[0])
+
+ response = self.client.get("/api/documents/?fields=id", format='json')
+ self.assertEqual(response.status_code, 200)
+ results = response.data['results']
+ self.assertFalse("content" in results[0])
+ self.assertTrue("id" in results[0])
+ self.assertEqual(len(results[0]), 1)
+
+ response = self.client.get("/api/documents/?fields=content", format='json')
+ self.assertEqual(response.status_code, 200)
+ results = response.data['results']
+ self.assertTrue("content" in results[0])
+ self.assertFalse("id" in results[0])
+ self.assertEqual(len(results[0]), 1)
+
+ response = self.client.get("/api/documents/?fields=id,content", format='json')
+ self.assertEqual(response.status_code, 200)
+ results = response.data['results']
+ self.assertTrue("content" in results[0])
+ self.assertTrue("id" in results[0])
+ self.assertEqual(len(results[0]), 2)
+
+ response = self.client.get("/api/documents/?fields=id,conteasdnt", format='json')
+ self.assertEqual(response.status_code, 200)
+ results = response.data['results']
+ self.assertFalse("content" in results[0])
+ self.assertTrue("id" in results[0])
+ self.assertEqual(len(results[0]), 1)
+
+ response = self.client.get("/api/documents/?fields=", format='json')
+ self.assertEqual(response.status_code, 200)
+ results = response.data['results']
+ self.assertEqual(results_full, results)
+
+ response = self.client.get("/api/documents/?fields=dgfhs", format='json')
+ self.assertEqual(response.status_code, 200)
+ results = response.data['results']
+ self.assertEqual(len(results[0]), 0)
+
+
+
def test_document_actions(self):
_, filename = tempfile.mkstemp(dir=self.dirs.originals_dir)
@@ -614,3 +667,273 @@ 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])
+
+ @mock.patch("documents.serialisers.bulk_edit.set_correspondent")
+ def test_api_set_correspondent(self, m):
+ m.return_value = "OK"
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc1.id],
+ "method": "set_correspondent",
+ "parameters": {"correspondent": self.c1.id}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 200)
+ m.assert_called_once()
+ args, kwargs = m.call_args
+ self.assertEqual(args[0], [self.doc1.id])
+ self.assertEqual(kwargs['correspondent'], self.c1.id)
+
+ @mock.patch("documents.serialisers.bulk_edit.set_correspondent")
+ def test_api_unset_correspondent(self, m):
+ m.return_value = "OK"
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc1.id],
+ "method": "set_correspondent",
+ "parameters": {"correspondent": None}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 200)
+ m.assert_called_once()
+ args, kwargs = m.call_args
+ self.assertEqual(args[0], [self.doc1.id])
+ self.assertIsNone(kwargs['correspondent'])
+
+ @mock.patch("documents.serialisers.bulk_edit.set_document_type")
+ def test_api_set_type(self, m):
+ m.return_value = "OK"
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc1.id],
+ "method": "set_document_type",
+ "parameters": {"document_type": self.dt1.id}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 200)
+ m.assert_called_once()
+ args, kwargs = m.call_args
+ self.assertEqual(args[0], [self.doc1.id])
+ self.assertEqual(kwargs['document_type'], self.dt1.id)
+
+ @mock.patch("documents.serialisers.bulk_edit.set_document_type")
+ def test_api_unset_type(self, m):
+ m.return_value = "OK"
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc1.id],
+ "method": "set_document_type",
+ "parameters": {"document_type": None}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 200)
+ m.assert_called_once()
+ args, kwargs = m.call_args
+ self.assertEqual(args[0], [self.doc1.id])
+ self.assertIsNone(kwargs['document_type'])
+
+ @mock.patch("documents.serialisers.bulk_edit.add_tag")
+ def test_api_add_tag(self, m):
+ m.return_value = "OK"
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc1.id],
+ "method": "add_tag",
+ "parameters": {"tag": self.t1.id}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 200)
+ m.assert_called_once()
+ args, kwargs = m.call_args
+ self.assertEqual(args[0], [self.doc1.id])
+ self.assertEqual(kwargs['tag'], self.t1.id)
+
+ @mock.patch("documents.serialisers.bulk_edit.remove_tag")
+ def test_api_remove_tag(self, m):
+ m.return_value = "OK"
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc1.id],
+ "method": "remove_tag",
+ "parameters": {"tag": self.t1.id}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 200)
+ m.assert_called_once()
+ args, kwargs = m.call_args
+ self.assertEqual(args[0], [self.doc1.id])
+ self.assertEqual(kwargs['tag'], self.t1.id)
+
+ @mock.patch("documents.serialisers.bulk_edit.delete")
+ def test_api_delete(self, m):
+ m.return_value = "OK"
+ 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)
+ m.assert_called_once()
+ args, kwargs = m.call_args
+ self.assertEqual(args[0], [self.doc1.id])
+ self.assertEqual(len(kwargs), 0)
+
+ 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)
+
+ def test_api_invalid_correspondent(self):
+ self.assertEqual(self.doc2.correspondent, self.c1)
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc2.id],
+ "method": "set_correspondent",
+ "parameters": {'correspondent': 345657}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 400)
+
+ doc2 = Document.objects.get(id=self.doc2.id)
+ self.assertEqual(doc2.correspondent, self.c1)
+
+ def test_api_invalid_document_type(self):
+ self.assertEqual(self.doc2.document_type, self.dt1)
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc2.id],
+ "method": "set_document_type",
+ "parameters": {'document_type': 345657}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 400)
+
+ doc2 = Document.objects.get(id=self.doc2.id)
+ self.assertEqual(doc2.document_type, self.dt1)
+
+ def test_api_add_invalid_tag(self):
+ self.assertEqual(list(self.doc2.tags.all()), [self.t1])
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc2.id],
+ "method": "add_tag",
+ "parameters": {'tag': 345657}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 400)
+
+ self.assertEqual(list(self.doc2.tags.all()), [self.t1])
+
+ def test_api_delete_invalid_tag(self):
+ self.assertEqual(list(self.doc2.tags.all()), [self.t1])
+ response = self.client.post("/api/documents/bulk_edit/", json.dumps({
+ "documents": [self.doc2.id],
+ "method": "remove_tag",
+ "parameters": {'tag': 345657}
+ }), content_type='application/json')
+ self.assertEqual(response.status_code, 400)
+
+ self.assertEqual(list(self.doc2.tags.all()), [self.t1])
+
+
+class TestApiAuth(APITestCase):
+
+ def test_auth_required(self):
+
+ d = Document.objects.create(title="Test")
+
+ self.assertEqual(self.client.get("/api/documents/").status_code, 401)
+
+ self.assertEqual(self.client.get(f"/api/documents/{d.id}/").status_code, 401)
+ self.assertEqual(self.client.get(f"/api/documents/{d.id}/download/").status_code, 401)
+ self.assertEqual(self.client.get(f"/api/documents/{d.id}/preview/").status_code, 401)
+ self.assertEqual(self.client.get(f"/api/documents/{d.id}/thumb/").status_code, 401)
+
+ self.assertEqual(self.client.get("/api/tags/").status_code, 401)
+ self.assertEqual(self.client.get("/api/correspondents/").status_code, 401)
+ self.assertEqual(self.client.get("/api/document_types/").status_code, 401)
+
+ self.assertEqual(self.client.get("/api/logs/").status_code, 401)
+ self.assertEqual(self.client.get("/api/saved_views/").status_code, 401)
+
+ self.assertEqual(self.client.get("/api/search/").status_code, 401)
+ self.assertEqual(self.client.get("/api/search/auto_complete/").status_code, 401)
+ self.assertEqual(self.client.get("/api/documents/bulk_edit/").status_code, 401)
diff --git a/src/documents/tests/test_checks.py b/src/documents/tests/test_checks.py
index 1027c11a0..ee4fbe8d1 100644
--- a/src/documents/tests/test_checks.py
+++ b/src/documents/tests/test_checks.py
@@ -1,9 +1,12 @@
import unittest
+from unittest import mock
+from django.core.checks import Error
from django.test import TestCase
from .factories import DocumentFactory
-from ..checks import changed_password_check
+from .. import document_consumer_declaration
+from ..checks import changed_password_check, parser_check
from ..models import Document
@@ -15,3 +18,13 @@ class ChecksTestCase(TestCase):
def test_changed_password_check_no_encryption(self):
DocumentFactory.create(storage_type=Document.STORAGE_TYPE_UNENCRYPTED)
self.assertEqual(changed_password_check(None), [])
+
+ def test_parser_check(self):
+
+ self.assertEqual(parser_check(None), [])
+
+ with mock.patch('documents.checks.document_consumer_declaration.send') as m:
+ m.return_value = []
+
+ self.assertEqual(parser_check(None), [Error("No parsers found. This is a bug. The consumer won't be "
+ "able to consume any documents without parsers.")])
diff --git a/src/documents/views.py b/src/documents/views.py
index 43e06065f..8f6ec7f13 100755
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -47,7 +47,8 @@ from .serialisers import (
TagSerializer,
DocumentTypeSerializer,
PostDocumentSerializer,
- SavedViewSerializer
+ SavedViewSerializer,
+ BulkEditSerializer
)
@@ -110,6 +111,10 @@ class DocumentTypeViewSet(ModelViewSet):
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
+class BulkEditForm(object):
+ pass
+
+
class DocumentViewSet(RetrieveModelMixin,
UpdateModelMixin,
DestroyModelMixin,
@@ -133,6 +138,17 @@ class DocumentViewSet(RetrieveModelMixin,
"added",
"archive_serial_number")
+ def get_serializer(self, *args, **kwargs):
+ fields_param = self.request.query_params.get('fields', None)
+ if fields_param:
+ fields = fields_param.split(",")
+ else:
+ fields = None
+ serializer_class = self.get_serializer_class()
+ kwargs.setdefault('context', self.get_serializer_context())
+ kwargs.setdefault('fields', fields)
+ return serializer_class(*args, **kwargs)
+
def update(self, request, *args, **kwargs):
response = super(DocumentViewSet, self).update(
request, *args, **kwargs)
@@ -274,6 +290,39 @@ class SavedViewViewSet(ModelViewSet):
serializer.save(user=self.request.user)
+class BulkEditView(APIView):
+
+ permission_classes = (IsAuthenticated,)
+ serializer_class = BulkEditSerializer
+ parser_classes = (parsers.JSONParser,)
+
+ def get_serializer_context(self):
+ return {
+ 'request': self.request,
+ 'format': self.format_kwarg,
+ 'view': self
+ }
+
+ def get_serializer(self, *args, **kwargs):
+ kwargs['context'] = self.get_serializer_context()
+ return self.serializer_class(*args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ method = serializer.validated_data.get("method")
+ parameters = serializer.validated_data.get("parameters")
+ documents = serializer.validated_data.get("documents")
+
+ try:
+ # TODO: parameter validation
+ result = method(documents, **parameters)
+ return Response({"result": result})
+ except Exception as e:
+ return HttpResponseBadRequest(str(e))
+
+
class PostDocumentView(APIView):
permission_classes = (IsAuthenticated,)
diff --git a/src/paperless/urls.py b/src/paperless/urls.py
index 079971bb3..831eb02b4 100755
--- a/src/paperless/urls.py
+++ b/src/paperless/urls.py
@@ -18,7 +18,8 @@ from documents.views import (
SearchAutoCompleteView,
StatisticsView,
PostDocumentView,
- SavedViewViewSet
+ SavedViewViewSet,
+ BulkEditView
)
from paperless.views import FaviconView
@@ -52,6 +53,10 @@ urlpatterns = [
re_path(r"^documents/post_document/", PostDocumentView.as_view(),
name="post_document"),
+
+ re_path(r"^documents/bulk_edit/", BulkEditView.as_view(),
+ name="bulk_edit"),
+
path('token/', views.obtain_auth_token)
] + api_router.urls)),
diff --git a/src/paperless/version.py b/src/paperless/version.py
index 10283c145..b1dfc590c 100644
--- a/src/paperless/version.py
+++ b/src/paperless/version.py
@@ -1 +1 @@
-__version__ = (0, 9, 8)
+__version__ = (0, 9, 9)