mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge branch 'dev' into feature-consume-eml
This commit is contained in:
		| @@ -5,6 +5,7 @@ from .models import Document | ||||
| from .models import DocumentType | ||||
| from .models import SavedView | ||||
| from .models import SavedViewFilterRule | ||||
| from .models import StoragePath | ||||
| from .models import Tag | ||||
|  | ||||
|  | ||||
| @@ -74,19 +75,19 @@ class DocumentAdmin(admin.ModelAdmin): | ||||
|             for o in queryset: | ||||
|                 index.remove_document(writer, o) | ||||
|  | ||||
|         super(DocumentAdmin, self).delete_queryset(request, queryset) | ||||
|         super().delete_queryset(request, queryset) | ||||
|  | ||||
|     def delete_model(self, request, obj): | ||||
|         from documents import index | ||||
|  | ||||
|         index.remove_document_from_index(obj) | ||||
|         super(DocumentAdmin, self).delete_model(request, obj) | ||||
|         super().delete_model(request, obj) | ||||
|  | ||||
|     def save_model(self, request, obj, form, change): | ||||
|         from documents import index | ||||
|  | ||||
|         index.add_or_update_document(obj) | ||||
|         super(DocumentAdmin, self).save_model(request, obj, form, change) | ||||
|         super().save_model(request, obj, form, change) | ||||
|  | ||||
|  | ||||
| class RuleInline(admin.TabularInline): | ||||
| @@ -100,8 +101,19 @@ class SavedViewAdmin(admin.ModelAdmin): | ||||
|     inlines = [RuleInline] | ||||
|  | ||||
|  | ||||
| class StoragePathInline(admin.TabularInline): | ||||
|     model = StoragePath | ||||
|  | ||||
|  | ||||
| class StoragePathAdmin(admin.ModelAdmin): | ||||
|     list_display = ("name", "path", "match", "matching_algorithm") | ||||
|     list_filter = ("path", "matching_algorithm") | ||||
|     list_editable = ("path", "match", "matching_algorithm") | ||||
|  | ||||
|  | ||||
| admin.site.register(Correspondent, CorrespondentAdmin) | ||||
| admin.site.register(Tag, TagAdmin) | ||||
| admin.site.register(DocumentType, DocumentTypeAdmin) | ||||
| admin.site.register(Document, DocumentAdmin) | ||||
| admin.site.register(SavedView, SavedViewAdmin) | ||||
| admin.site.register(StoragePath, StoragePathAdmin) | ||||
|   | ||||
| @@ -16,6 +16,7 @@ class DocumentsConfig(AppConfig): | ||||
|             set_correspondent, | ||||
|             set_document_type, | ||||
|             set_tags, | ||||
|             set_storage_path, | ||||
|             add_to_index, | ||||
|         ) | ||||
|  | ||||
| @@ -23,6 +24,7 @@ class DocumentsConfig(AppConfig): | ||||
|         document_consumption_finished.connect(set_correspondent) | ||||
|         document_consumption_finished.connect(set_document_type) | ||||
|         document_consumption_finished.connect(set_tags) | ||||
|         document_consumption_finished.connect(set_storage_path) | ||||
|         document_consumption_finished.connect(set_log_entry) | ||||
|         document_consumption_finished.connect(add_to_index) | ||||
|  | ||||
|   | ||||
| @@ -32,7 +32,7 @@ class OriginalsOnlyStrategy(BulkArchiveStrategy): | ||||
|  | ||||
| class ArchiveOnlyStrategy(BulkArchiveStrategy): | ||||
|     def __init__(self, zipf): | ||||
|         super(ArchiveOnlyStrategy, self).__init__(zipf) | ||||
|         super().__init__(zipf) | ||||
|  | ||||
|     def add_document(self, doc: Document): | ||||
|         if doc.has_archive_version: | ||||
|   | ||||
| @@ -5,6 +5,7 @@ from django_q.tasks import async_task | ||||
| from documents.models import Correspondent | ||||
| from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| from documents.models import StoragePath | ||||
|  | ||||
|  | ||||
| def set_correspondent(doc_ids, correspondent): | ||||
| @@ -20,6 +21,24 @@ def set_correspondent(doc_ids, correspondent): | ||||
|     return "OK" | ||||
|  | ||||
|  | ||||
| def set_storage_path(doc_ids, storage_path): | ||||
|     if storage_path: | ||||
|         storage_path = StoragePath.objects.get(id=storage_path) | ||||
|  | ||||
|     qs = Document.objects.filter( | ||||
|         Q(id__in=doc_ids) & ~Q(storage_path=storage_path), | ||||
|     ) | ||||
|     affected_docs = [doc.id for doc in qs] | ||||
|     qs.update(storage_path=storage_path) | ||||
|  | ||||
|     async_task( | ||||
|         "documents.tasks.bulk_update_documents", | ||||
|         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) | ||||
|   | ||||
| @@ -57,10 +57,10 @@ def load_classifier(): | ||||
|     return classifier | ||||
|  | ||||
|  | ||||
| class DocumentClassifier(object): | ||||
| class DocumentClassifier: | ||||
|  | ||||
|     # v7 - Updated scikit-learn package version | ||||
|     FORMAT_VERSION = 7 | ||||
|     # v8 - Added storage path classifier | ||||
|     FORMAT_VERSION = 8 | ||||
|  | ||||
|     def __init__(self): | ||||
|         # hash of the training data. used to prevent re-training when the | ||||
| @@ -72,6 +72,7 @@ class DocumentClassifier(object): | ||||
|         self.tags_classifier = None | ||||
|         self.correspondent_classifier = None | ||||
|         self.document_type_classifier = None | ||||
|         self.storage_path_classifier = None | ||||
|  | ||||
|     def load(self): | ||||
|         with open(settings.MODEL_FILE, "rb") as f: | ||||
| @@ -90,6 +91,7 @@ class DocumentClassifier(object): | ||||
|                     self.tags_classifier = pickle.load(f) | ||||
|                     self.correspondent_classifier = pickle.load(f) | ||||
|                     self.document_type_classifier = pickle.load(f) | ||||
|                     self.storage_path_classifier = pickle.load(f) | ||||
|                 except Exception: | ||||
|                     raise ClassifierModelCorruptError() | ||||
|  | ||||
| @@ -107,6 +109,7 @@ class DocumentClassifier(object): | ||||
|             pickle.dump(self.tags_classifier, f) | ||||
|             pickle.dump(self.correspondent_classifier, f) | ||||
|             pickle.dump(self.document_type_classifier, f) | ||||
|             pickle.dump(self.storage_path_classifier, f) | ||||
|  | ||||
|         if os.path.isfile(target_file): | ||||
|             os.unlink(target_file) | ||||
| @@ -118,6 +121,7 @@ class DocumentClassifier(object): | ||||
|         labels_tags = list() | ||||
|         labels_correspondent = list() | ||||
|         labels_document_type = list() | ||||
|         labels_storage_path = list() | ||||
|  | ||||
|         # Step 1: Extract and preprocess training data from the database. | ||||
|         logger.debug("Gathering data from database...") | ||||
| @@ -144,17 +148,22 @@ class DocumentClassifier(object): | ||||
|             labels_correspondent.append(y) | ||||
|  | ||||
|             tags = sorted( | ||||
|                 [ | ||||
|                     tag.pk | ||||
|                     for tag in doc.tags.filter( | ||||
|                         matching_algorithm=MatchingModel.MATCH_AUTO, | ||||
|                     ) | ||||
|                 ], | ||||
|                 tag.pk | ||||
|                 for tag in doc.tags.filter( | ||||
|                     matching_algorithm=MatchingModel.MATCH_AUTO, | ||||
|                 ) | ||||
|             ) | ||||
|             for tag in tags: | ||||
|                 m.update(tag.to_bytes(4, "little", signed=True)) | ||||
|             labels_tags.append(tags) | ||||
|  | ||||
|             y = -1 | ||||
|             sd = doc.storage_path | ||||
|             if sd and sd.matching_algorithm == MatchingModel.MATCH_AUTO: | ||||
|                 y = sd.pk | ||||
|             m.update(y.to_bytes(4, "little", signed=True)) | ||||
|             labels_storage_path.append(y) | ||||
|  | ||||
|         if not data: | ||||
|             raise ValueError("No training data available.") | ||||
|  | ||||
| @@ -163,7 +172,7 @@ class DocumentClassifier(object): | ||||
|         if self.data_hash and new_data_hash == self.data_hash: | ||||
|             return False | ||||
|  | ||||
|         labels_tags_unique = set([tag for tags in labels_tags for tag in tags]) | ||||
|         labels_tags_unique = {tag for tags in labels_tags for tag in tags} | ||||
|  | ||||
|         num_tags = len(labels_tags_unique) | ||||
|  | ||||
| @@ -174,14 +183,16 @@ class DocumentClassifier(object): | ||||
|         # it usually is. | ||||
|         num_correspondents = len(set(labels_correspondent) | {-1}) - 1 | ||||
|         num_document_types = len(set(labels_document_type) | {-1}) - 1 | ||||
|         num_storage_paths = len(set(labels_storage_path) | {-1}) - 1 | ||||
|  | ||||
|         logger.debug( | ||||
|             "{} documents, {} tag(s), {} correspondent(s), " | ||||
|             "{} document type(s).".format( | ||||
|             "{} document type(s). {} storage path(es)".format( | ||||
|                 len(data), | ||||
|                 num_tags, | ||||
|                 num_correspondents, | ||||
|                 num_document_types, | ||||
|                 num_storage_paths, | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
| @@ -244,6 +255,21 @@ class DocumentClassifier(object): | ||||
|                 "classifier.", | ||||
|             ) | ||||
|  | ||||
|         if num_storage_paths > 0: | ||||
|             logger.debug( | ||||
|                 "Training storage paths classifier...", | ||||
|             ) | ||||
|             self.storage_path_classifier = MLPClassifier(tol=0.01) | ||||
|             self.storage_path_classifier.fit( | ||||
|                 data_vectorized, | ||||
|                 labels_storage_path, | ||||
|             ) | ||||
|         else: | ||||
|             self.storage_path_classifier = None | ||||
|             logger.debug( | ||||
|                 "There are no storage paths. Not training storage path classifier.", | ||||
|             ) | ||||
|  | ||||
|         self.data_hash = new_data_hash | ||||
|  | ||||
|         return True | ||||
| @@ -290,3 +316,14 @@ class DocumentClassifier(object): | ||||
|                 return [] | ||||
|         else: | ||||
|             return [] | ||||
|  | ||||
|     def predict_storage_path(self, content): | ||||
|         if self.storage_path_classifier: | ||||
|             X = self.data_vectorizer.transform([preprocess_content(content)]) | ||||
|             storage_path_id = self.storage_path_classifier.predict(X) | ||||
|             if storage_path_id != -1: | ||||
|                 return storage_path_id | ||||
|             else: | ||||
|                 return None | ||||
|         else: | ||||
|             return None | ||||
|   | ||||
| @@ -3,6 +3,8 @@ import hashlib | ||||
| import os | ||||
| import uuid | ||||
| from subprocess import Popen | ||||
| from typing import Optional | ||||
| from typing import Type | ||||
|  | ||||
| import magic | ||||
| from asgiref.sync import async_to_sync | ||||
| @@ -23,6 +25,7 @@ from .models import Document | ||||
| from .models import DocumentType | ||||
| from .models import FileInfo | ||||
| from .models import Tag | ||||
| from .parsers import DocumentParser | ||||
| from .parsers import get_parser_class_for_mime_type | ||||
| from .parsers import parse_date | ||||
| from .parsers import ParseError | ||||
| @@ -186,7 +189,8 @@ class Consumer(LoggingMixin): | ||||
|         override_document_type_id=None, | ||||
|         override_tag_ids=None, | ||||
|         task_id=None, | ||||
|     ): | ||||
|         override_created=None, | ||||
|     ) -> Document: | ||||
|         """ | ||||
|         Return the document object if it was successfully created. | ||||
|         """ | ||||
| @@ -198,6 +202,7 @@ class Consumer(LoggingMixin): | ||||
|         self.override_document_type_id = override_document_type_id | ||||
|         self.override_tag_ids = override_tag_ids | ||||
|         self.task_id = task_id or str(uuid.uuid4()) | ||||
|         self.override_created = override_created | ||||
|  | ||||
|         self._send_progress(0, 100, "STARTING", MESSAGE_NEW_FILE) | ||||
|  | ||||
| @@ -220,7 +225,10 @@ class Consumer(LoggingMixin): | ||||
|  | ||||
|         self.log("debug", f"Detected mime type: {mime_type}") | ||||
|  | ||||
|         parser_class = get_parser_class_for_mime_type(mime_type) | ||||
|         # Based on the mime type, get the parser for that type | ||||
|         parser_class: Optional[Type[DocumentParser]] = get_parser_class_for_mime_type( | ||||
|             mime_type, | ||||
|         ) | ||||
|         if not parser_class: | ||||
|             self._fail(MESSAGE_UNSUPPORTED_TYPE, f"Unsupported mime type {mime_type}") | ||||
|  | ||||
| @@ -241,7 +249,10 @@ class Consumer(LoggingMixin): | ||||
|  | ||||
|         # This doesn't parse the document yet, but gives us a parser. | ||||
|  | ||||
|         document_parser = parser_class(self.logging_group, progress_callback) | ||||
|         document_parser: DocumentParser = parser_class( | ||||
|             self.logging_group, | ||||
|             progress_callback, | ||||
|         ) | ||||
|  | ||||
|         self.log("debug", f"Parser: {type(document_parser).__name__}") | ||||
|  | ||||
| @@ -257,7 +268,7 @@ class Consumer(LoggingMixin): | ||||
|  | ||||
|         try: | ||||
|             self._send_progress(20, 100, "WORKING", MESSAGE_PARSING_DOCUMENT) | ||||
|             self.log("debug", "Parsing {}...".format(self.filename)) | ||||
|             self.log("debug", f"Parsing {self.filename}...") | ||||
|             document_parser.parse(self.path, mime_type, self.filename) | ||||
|  | ||||
|             self.log("debug", f"Generating thumbnail for {self.filename}...") | ||||
| @@ -270,7 +281,7 @@ class Consumer(LoggingMixin): | ||||
|  | ||||
|             text = document_parser.get_text() | ||||
|             date = document_parser.get_date() | ||||
|             if not date: | ||||
|             if date is None: | ||||
|                 self._send_progress(90, 100, "WORKING", MESSAGE_PARSE_DATE) | ||||
|                 date = parse_date(self.filename, text) | ||||
|             archive_path = document_parser.get_archive_path() | ||||
| @@ -342,11 +353,11 @@ class Consumer(LoggingMixin): | ||||
|                             ).hexdigest() | ||||
|  | ||||
|                 # Don't save with the lock active. Saving will cause the file | ||||
|                 # renaming logic to aquire the lock as well. | ||||
|                 # renaming logic to acquire the lock as well. | ||||
|                 document.save() | ||||
|  | ||||
|                 # Delete the file only if it was successfully consumed | ||||
|                 self.log("debug", "Deleting file {}".format(self.path)) | ||||
|                 self.log("debug", f"Deleting file {self.path}") | ||||
|                 os.unlink(self.path) | ||||
|  | ||||
|                 # https://github.com/jonaswinkler/paperless-ng/discussions/1037 | ||||
| @@ -356,13 +367,14 @@ class Consumer(LoggingMixin): | ||||
|                 ) | ||||
|  | ||||
|                 if os.path.isfile(shadow_file): | ||||
|                     self.log("debug", "Deleting file {}".format(shadow_file)) | ||||
|                     self.log("debug", f"Deleting file {shadow_file}") | ||||
|                     os.unlink(shadow_file) | ||||
|  | ||||
|         except Exception as e: | ||||
|             self._fail( | ||||
|                 str(e), | ||||
|                 f"The following error occured while consuming " f"{self.filename}: {e}", | ||||
|                 f"The following error occurred while consuming " | ||||
|                 f"{self.filename}: {e}", | ||||
|                 exc_info=True, | ||||
|             ) | ||||
|         finally: | ||||
| @@ -370,27 +382,38 @@ class Consumer(LoggingMixin): | ||||
|  | ||||
|         self.run_post_consume_script(document) | ||||
|  | ||||
|         self.log("info", "Document {} consumption finished".format(document)) | ||||
|         self.log("info", f"Document {document} consumption finished") | ||||
|  | ||||
|         self._send_progress(100, 100, "SUCCESS", MESSAGE_FINISHED, document.id) | ||||
|  | ||||
|         return document | ||||
|  | ||||
|     def _store(self, text, date, mime_type): | ||||
|     def _store(self, text, date, mime_type) -> Document: | ||||
|  | ||||
|         # If someone gave us the original filename, use it instead of doc. | ||||
|  | ||||
|         file_info = FileInfo.from_filename(self.filename) | ||||
|  | ||||
|         stats = os.stat(self.path) | ||||
|  | ||||
|         self.log("debug", "Saving record to database") | ||||
|  | ||||
|         created = ( | ||||
|             file_info.created | ||||
|             or date | ||||
|             or timezone.make_aware(datetime.datetime.fromtimestamp(stats.st_mtime)) | ||||
|         ) | ||||
|         if self.override_created is not None: | ||||
|             create_date = self.override_created | ||||
|             self.log( | ||||
|                 "debug", | ||||
|                 f"Creation date from post_documents parameter: {create_date}", | ||||
|             ) | ||||
|         elif file_info.created is not None: | ||||
|             create_date = file_info.created | ||||
|             self.log("debug", f"Creation date from FileInfo: {create_date}") | ||||
|         elif date is not None: | ||||
|             create_date = date | ||||
|             self.log("debug", f"Creation date from parse_date: {create_date}") | ||||
|         else: | ||||
|             stats = os.stat(self.path) | ||||
|             create_date = timezone.make_aware( | ||||
|                 datetime.datetime.fromtimestamp(stats.st_mtime), | ||||
|             ) | ||||
|             self.log("debug", f"Creation date from st_mtime: {create_date}") | ||||
|  | ||||
|         storage_type = Document.STORAGE_TYPE_UNENCRYPTED | ||||
|  | ||||
| @@ -400,8 +423,8 @@ class Consumer(LoggingMixin): | ||||
|                 content=text, | ||||
|                 mime_type=mime_type, | ||||
|                 checksum=hashlib.md5(f.read()).hexdigest(), | ||||
|                 created=created, | ||||
|                 modified=created, | ||||
|                 created=create_date, | ||||
|                 modified=create_date, | ||||
|                 storage_type=storage_type, | ||||
|             ) | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,7 @@ from collections import defaultdict | ||||
| import pathvalidate | ||||
| from django.conf import settings | ||||
| from django.template.defaultfilters import slugify | ||||
| from django.utils import timezone | ||||
|  | ||||
|  | ||||
| logger = logging.getLogger("paperless.filehandling") | ||||
| @@ -127,13 +128,26 @@ def generate_unique_filename(doc, archive_filename=False): | ||||
|  | ||||
| def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|     path = "" | ||||
|     filename_format = settings.FILENAME_FORMAT | ||||
|  | ||||
|     try: | ||||
|         if settings.PAPERLESS_FILENAME_FORMAT is not None: | ||||
|             tags = defaultdictNoStr(lambda: slugify(None), many_to_dictionary(doc.tags)) | ||||
|         if doc.storage_path is not None: | ||||
|             logger.debug( | ||||
|                 f"Document has storage_path {doc.storage_path.id} " | ||||
|                 f"({doc.storage_path.path}) set", | ||||
|             ) | ||||
|             filename_format = doc.storage_path.path | ||||
|  | ||||
|         if filename_format is not None: | ||||
|             tags = defaultdictNoStr( | ||||
|                 lambda: slugify(None), | ||||
|                 many_to_dictionary(doc.tags), | ||||
|             ) | ||||
|  | ||||
|             tag_list = pathvalidate.sanitize_filename( | ||||
|                 ",".join(sorted([tag.name for tag in doc.tags.all()])), | ||||
|                 ",".join( | ||||
|                     sorted(tag.name for tag in doc.tags.all()), | ||||
|                 ), | ||||
|                 replacement_text="-", | ||||
|             ) | ||||
|  | ||||
| @@ -143,7 +157,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|                     replacement_text="-", | ||||
|                 ) | ||||
|             else: | ||||
|                 correspondent = "none" | ||||
|                 correspondent = "-none-" | ||||
|  | ||||
|             if doc.document_type: | ||||
|                 document_type = pathvalidate.sanitize_filename( | ||||
| @@ -151,36 +165,45 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|                     replacement_text="-", | ||||
|                 ) | ||||
|             else: | ||||
|                 document_type = "none" | ||||
|                 document_type = "-none-" | ||||
|  | ||||
|             if doc.archive_serial_number: | ||||
|                 asn = str(doc.archive_serial_number) | ||||
|             else: | ||||
|                 asn = "none" | ||||
|                 asn = "-none-" | ||||
|  | ||||
|             path = settings.PAPERLESS_FILENAME_FORMAT.format( | ||||
|             # Convert UTC database date to localized date | ||||
|             local_added = timezone.localdate(doc.added) | ||||
|             local_created = timezone.localdate(doc.created) | ||||
|  | ||||
|             path = filename_format.format( | ||||
|                 title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"), | ||||
|                 correspondent=correspondent, | ||||
|                 document_type=document_type, | ||||
|                 created=datetime.date.isoformat(doc.created), | ||||
|                 created_year=doc.created.year if doc.created else "none", | ||||
|                 created_month=f"{doc.created.month:02}" if doc.created else "none", | ||||
|                 created_day=f"{doc.created.day:02}" if doc.created else "none", | ||||
|                 added=datetime.date.isoformat(doc.added), | ||||
|                 added_year=doc.added.year if doc.added else "none", | ||||
|                 added_month=f"{doc.added.month:02}" if doc.added else "none", | ||||
|                 added_day=f"{doc.added.day:02}" if doc.added else "none", | ||||
|                 created=datetime.date.isoformat(local_created), | ||||
|                 created_year=local_created.year, | ||||
|                 created_month=f"{local_created.month:02}", | ||||
|                 created_day=f"{local_created.day:02}", | ||||
|                 added=datetime.date.isoformat(local_added), | ||||
|                 added_year=local_added.year, | ||||
|                 added_month=f"{local_added.month:02}", | ||||
|                 added_day=f"{local_added.day:02}", | ||||
|                 asn=asn, | ||||
|                 tags=tags, | ||||
|                 tag_list=tag_list, | ||||
|             ).strip() | ||||
|  | ||||
|             if settings.FILENAME_FORMAT_REMOVE_NONE: | ||||
|                 path = path.replace("-none-/", "")  # remove empty directories | ||||
|                 path = path.replace(" -none-", "")  # remove when spaced, with space | ||||
|                 path = path.replace("-none-", "")  # remove rest of the occurences | ||||
|  | ||||
|             path = path.replace("-none-", "none")  # backward compatibility | ||||
|             path = path.strip(os.sep) | ||||
|  | ||||
|     except (ValueError, KeyError, IndexError): | ||||
|         logger.warning( | ||||
|             f"Invalid PAPERLESS_FILENAME_FORMAT: " | ||||
|             f"{settings.PAPERLESS_FILENAME_FORMAT}, falling back to default", | ||||
|             f"Invalid filename_format '{filename_format}', falling back to default", | ||||
|         ) | ||||
|  | ||||
|     counter_str = f"_{counter:02}" if counter else "" | ||||
|   | ||||
| @@ -7,6 +7,7 @@ from .models import Correspondent | ||||
| from .models import Document | ||||
| from .models import DocumentType | ||||
| from .models import Log | ||||
| from .models import StoragePath | ||||
| from .models import Tag | ||||
|  | ||||
| CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"] | ||||
| @@ -35,7 +36,7 @@ class DocumentTypeFilterSet(FilterSet): | ||||
|  | ||||
| class TagsFilter(Filter): | ||||
|     def __init__(self, exclude=False, in_list=False): | ||||
|         super(TagsFilter, self).__init__() | ||||
|         super().__init__() | ||||
|         self.exclude = exclude | ||||
|         self.in_list = in_list | ||||
|  | ||||
| @@ -114,6 +115,9 @@ class DocumentFilterSet(FilterSet): | ||||
|             "document_type": ["isnull"], | ||||
|             "document_type__id": ID_KWARGS, | ||||
|             "document_type__name": CHAR_KWARGS, | ||||
|             "storage_path": ["isnull"], | ||||
|             "storage_path__id": ID_KWARGS, | ||||
|             "storage_path__name": CHAR_KWARGS, | ||||
|         } | ||||
|  | ||||
|  | ||||
| @@ -121,3 +125,12 @@ class LogFilterSet(FilterSet): | ||||
|     class Meta: | ||||
|         model = Log | ||||
|         fields = {"level": INT_KWARGS, "created": DATE_KWARGS, "group": ID_KWARGS} | ||||
|  | ||||
|  | ||||
| class StoragePathFilterSet(FilterSet): | ||||
|     class Meta: | ||||
|         model = StoragePath | ||||
|         fields = { | ||||
|             "name": CHAR_KWARGS, | ||||
|             "path": CHAR_KWARGS, | ||||
|         } | ||||
|   | ||||
| @@ -46,6 +46,9 @@ def get_schema(): | ||||
|         created=DATETIME(sortable=True), | ||||
|         modified=DATETIME(sortable=True), | ||||
|         added=DATETIME(sortable=True), | ||||
|         path=TEXT(sortable=True), | ||||
|         path_id=NUMERIC(), | ||||
|         has_path=BOOLEAN(), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -104,6 +107,9 @@ def update_document(writer, doc): | ||||
|         added=doc.added, | ||||
|         asn=doc.archive_serial_number, | ||||
|         modified=doc.modified, | ||||
|         path=doc.storage_path.name if doc.storage_path else None, | ||||
|         path_id=doc.storage_path.id if doc.storage_path else None, | ||||
|         has_path=doc.storage_path is not None, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -157,6 +163,11 @@ class DelayedQuery: | ||||
|                 criterias.append(query.DateRange("added", start=isoparse(v), end=None)) | ||||
|             elif k == "added__date__lt": | ||||
|                 criterias.append(query.DateRange("added", start=None, end=isoparse(v))) | ||||
|             elif k == "storage_path__id": | ||||
|                 criterias.append(query.Term("path_id", v)) | ||||
|             elif k == "storage_path__isnull": | ||||
|                 criterias.append(query.Term("has_path", v == "false")) | ||||
|  | ||||
|         if len(criterias) > 0: | ||||
|             return query.And(criterias) | ||||
|         else: | ||||
|   | ||||
| @@ -55,7 +55,7 @@ class Command(BaseCommand): | ||||
|  | ||||
|         for document in encrypted_files: | ||||
|  | ||||
|             print("Decrypting {}".format(document).encode("utf-8")) | ||||
|             print(f"Decrypting {document}".encode()) | ||||
|  | ||||
|             old_paths = [document.source_path, document.thumbnail_path] | ||||
|  | ||||
|   | ||||
| @@ -152,4 +152,4 @@ class Command(BaseCommand): | ||||
|                     ), | ||||
|                 ) | ||||
|         except KeyboardInterrupt: | ||||
|             print("Aborting...") | ||||
|             self.stdout.write(self.style.NOTICE("Aborting...")) | ||||
|   | ||||
| @@ -28,8 +28,11 @@ def _tags_from_path(filepath): | ||||
|     """Walk up the directory tree from filepath to CONSUMPTION_DIR | ||||
|     and get or create Tag IDs for every directory. | ||||
|     """ | ||||
|     normalized_consumption_dir = os.path.abspath( | ||||
|         os.path.normpath(settings.CONSUMPTION_DIR), | ||||
|     ) | ||||
|     tag_ids = set() | ||||
|     path_parts = Path(filepath).relative_to(settings.CONSUMPTION_DIR).parent.parts | ||||
|     path_parts = Path(filepath).relative_to(normalized_consumption_dir).parent.parts | ||||
|     for part in path_parts: | ||||
|         tag_ids.add( | ||||
|             Tag.objects.get_or_create(name__iexact=part, defaults={"name": part})[0].pk, | ||||
| @@ -39,7 +42,10 @@ def _tags_from_path(filepath): | ||||
|  | ||||
|  | ||||
| def _is_ignored(filepath: str) -> bool: | ||||
|     filepath_relative = PurePath(filepath).relative_to(settings.CONSUMPTION_DIR) | ||||
|     normalized_consumption_dir = os.path.abspath( | ||||
|         os.path.normpath(settings.CONSUMPTION_DIR), | ||||
|     ) | ||||
|     filepath_relative = PurePath(filepath).relative_to(normalized_consumption_dir) | ||||
|     return any(filepath_relative.match(p) for p in settings.CONSUMER_IGNORE_PATTERNS) | ||||
|  | ||||
|  | ||||
| @@ -160,6 +166,8 @@ class Command(BaseCommand): | ||||
|         if not directory: | ||||
|             raise CommandError("CONSUMPTION_DIR does not appear to be set.") | ||||
|  | ||||
|         directory = os.path.abspath(directory) | ||||
|  | ||||
|         if not os.path.isdir(directory): | ||||
|             raise CommandError(f"Consumption directory {directory} does not exist") | ||||
|  | ||||
| @@ -208,7 +216,7 @@ class Command(BaseCommand): | ||||
|  | ||||
|         try: | ||||
|  | ||||
|             inotify_debounce: Final[float] = 0.5 | ||||
|             inotify_debounce: Final[float] = settings.CONSUMER_INOTIFY_DELAY | ||||
|             notified_files = {} | ||||
|  | ||||
|             while not self.stop_flag: | ||||
| @@ -226,10 +234,23 @@ class Command(BaseCommand): | ||||
|                 for filepath in notified_files: | ||||
|                     # Time of the last inotify event for this file | ||||
|                     last_event_time = notified_files[filepath] | ||||
|                     if (monotonic() - last_event_time) > inotify_debounce: | ||||
|  | ||||
|                     # Current time - last time over the configured timeout | ||||
|                     waited_long_enough = ( | ||||
|                         monotonic() - last_event_time | ||||
|                     ) > inotify_debounce | ||||
|  | ||||
|                     # Also make sure the file exists still, some scanners might write a | ||||
|                     # temporary file first | ||||
|                     file_still_exists = os.path.exists(filepath) and os.path.isfile( | ||||
|                         filepath, | ||||
|                     ) | ||||
|  | ||||
|                     if waited_long_enough and file_still_exists: | ||||
|                         _consume(filepath) | ||||
|                     else: | ||||
|                     elif file_still_exists: | ||||
|                         still_waiting[filepath] = last_event_time | ||||
|  | ||||
|                 # These files are still waiting to hit the timeout | ||||
|                 notified_files = still_waiting | ||||
|  | ||||
|   | ||||
| @@ -18,10 +18,12 @@ from documents.models import DocumentType | ||||
| from documents.models import SavedView | ||||
| from documents.models import SavedViewFilterRule | ||||
| from documents.models import Tag | ||||
| from documents.models import UiSettings | ||||
| from documents.settings import EXPORTER_ARCHIVE_NAME | ||||
| from documents.settings import EXPORTER_FILE_NAME | ||||
| from documents.settings import EXPORTER_THUMBNAIL_NAME | ||||
| from filelock import FileLock | ||||
| from paperless import version | ||||
| from paperless.db import GnuPG | ||||
| from paperless_mail.models import MailAccount | ||||
| from paperless_mail.models import MailRule | ||||
| @@ -111,8 +113,8 @@ class Command(BaseCommand): | ||||
|                 map(lambda f: os.path.abspath(os.path.join(root, f)), files), | ||||
|             ) | ||||
|  | ||||
|         # 2. Create manifest, containing all correspondents, types, tags and | ||||
|         # documents | ||||
|         # 2. Create manifest, containing all correspondents, types, tags, | ||||
|         # documents and ui_settings | ||||
|         with transaction.atomic(): | ||||
|             manifest = json.loads( | ||||
|                 serializers.serialize("json", Correspondent.objects.all()), | ||||
| @@ -149,6 +151,10 @@ class Command(BaseCommand): | ||||
|  | ||||
|             manifest += json.loads(serializers.serialize("json", User.objects.all())) | ||||
|  | ||||
|             manifest += json.loads( | ||||
|                 serializers.serialize("json", UiSettings.objects.all()), | ||||
|             ) | ||||
|  | ||||
|         # 3. Export files from each document | ||||
|         for index, document_dict in tqdm.tqdm( | ||||
|             enumerate(document_manifest), | ||||
| @@ -232,12 +238,18 @@ class Command(BaseCommand): | ||||
|                         archive_target, | ||||
|                     ) | ||||
|  | ||||
|         # 4. write manifest to target forlder | ||||
|         # 4.1 write manifest to target folder | ||||
|         manifest_path = os.path.abspath(os.path.join(self.target, "manifest.json")) | ||||
|  | ||||
|         with open(manifest_path, "w") as f: | ||||
|             json.dump(manifest, f, indent=2) | ||||
|  | ||||
|         # 4.2 write version information to target folder | ||||
|         version_path = os.path.abspath(os.path.join(self.target, "version.json")) | ||||
|  | ||||
|         with open(version_path, "w") as f: | ||||
|             json.dump({"version": version.__full_version_str__}, f, indent=2) | ||||
|  | ||||
|         if self.delete: | ||||
|             # 5. Remove files which we did not explicitly export in this run | ||||
|  | ||||
|   | ||||
| @@ -6,9 +6,11 @@ from contextlib import contextmanager | ||||
|  | ||||
| import tqdm | ||||
| from django.conf import settings | ||||
| from django.core.exceptions import FieldDoesNotExist | ||||
| from django.core.management import call_command | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.core.management.base import CommandError | ||||
| from django.core.serializers.base import DeserializationError | ||||
| from django.db.models.signals import m2m_changed | ||||
| from django.db.models.signals import post_save | ||||
| from documents.models import Document | ||||
| @@ -16,6 +18,7 @@ from documents.settings import EXPORTER_ARCHIVE_NAME | ||||
| from documents.settings import EXPORTER_FILE_NAME | ||||
| from documents.settings import EXPORTER_THUMBNAIL_NAME | ||||
| from filelock import FileLock | ||||
| from paperless import version | ||||
|  | ||||
| from ...file_handling import create_source_path_directory | ||||
| from ...signals.handlers import update_filename_and_move_files | ||||
| @@ -53,6 +56,7 @@ class Command(BaseCommand): | ||||
|         BaseCommand.__init__(self, *args, **kwargs) | ||||
|         self.source = None | ||||
|         self.manifest = None | ||||
|         self.version = None | ||||
|  | ||||
|     def handle(self, *args, **options): | ||||
|  | ||||
| @@ -66,12 +70,30 @@ class Command(BaseCommand): | ||||
|         if not os.access(self.source, os.R_OK): | ||||
|             raise CommandError("That path doesn't appear to be readable") | ||||
|  | ||||
|         manifest_path = os.path.join(self.source, "manifest.json") | ||||
|         manifest_path = os.path.normpath(os.path.join(self.source, "manifest.json")) | ||||
|         self._check_manifest_exists(manifest_path) | ||||
|  | ||||
|         with open(manifest_path) as f: | ||||
|             self.manifest = json.load(f) | ||||
|  | ||||
|         version_path = os.path.normpath(os.path.join(self.source, "version.json")) | ||||
|         if os.path.exists(version_path): | ||||
|             with open(version_path) as f: | ||||
|                 self.version = json.load(f)["version"] | ||||
|                 # Provide an initial warning if needed to the user | ||||
|                 if self.version != version.__full_version_str__: | ||||
|                     self.stdout.write( | ||||
|                         self.style.WARNING( | ||||
|                             "Version mismatch: " | ||||
|                             f"Currently {version.__full_version_str__}," | ||||
|                             f" importing {self.version}." | ||||
|                             " Continuing, but import may fail.", | ||||
|                         ), | ||||
|                     ) | ||||
|  | ||||
|         else: | ||||
|             self.stdout.write(self.style.NOTICE("No version.json file located")) | ||||
|  | ||||
|         self._check_manifest() | ||||
|         with disable_signal( | ||||
|             post_save, | ||||
| @@ -84,12 +106,36 @@ class Command(BaseCommand): | ||||
|                 sender=Document.tags.through, | ||||
|             ): | ||||
|                 # Fill up the database with whatever is in the manifest | ||||
|                 call_command("loaddata", manifest_path) | ||||
|                 try: | ||||
|                     call_command("loaddata", manifest_path) | ||||
|                 except (FieldDoesNotExist, DeserializationError) as e: | ||||
|                     self.stdout.write(self.style.ERROR("Database import failed")) | ||||
|                     if ( | ||||
|                         self.version is not None | ||||
|                         and self.version != version.__full_version_str__ | ||||
|                     ): | ||||
|                         self.stdout.write( | ||||
|                             self.style.ERROR( | ||||
|                                 "Version mismatch: " | ||||
|                                 f"Currently {version.__full_version_str__}," | ||||
|                                 f" importing {self.version}", | ||||
|                             ), | ||||
|                         ) | ||||
|                         raise e | ||||
|                     else: | ||||
|                         self.stdout.write( | ||||
|                             self.style.ERROR("No version information present"), | ||||
|                         ) | ||||
|                         raise e | ||||
|  | ||||
|                 self._import_files_from_manifest(options["no_progress_bar"]) | ||||
|  | ||||
|         print("Updating search index...") | ||||
|         call_command("document_index", "reindex") | ||||
|         self.stdout.write("Updating search index...") | ||||
|         call_command( | ||||
|             "document_index", | ||||
|             "reindex", | ||||
|             no_progress_bar=options["no_progress_bar"], | ||||
|         ) | ||||
|  | ||||
|     @staticmethod | ||||
|     def _check_manifest_exists(path): | ||||
| @@ -132,7 +178,7 @@ class Command(BaseCommand): | ||||
|         os.makedirs(settings.THUMBNAIL_DIR, exist_ok=True) | ||||
|         os.makedirs(settings.ARCHIVE_DIR, exist_ok=True) | ||||
|  | ||||
|         print("Copy files into paperless...") | ||||
|         self.stdout.write("Copy files into paperless...") | ||||
|  | ||||
|         manifest_documents = list( | ||||
|             filter(lambda r: r["model"] == "documents.document", self.manifest), | ||||
|   | ||||
| @@ -17,4 +17,4 @@ class Command(LoadDataCommand): | ||||
|     def find_fixtures(self, fixture_label): | ||||
|         if fixture_label == "-": | ||||
|             return [("-", None, "-")] | ||||
|         return super(Command, self).find_fixtures(fixture_label) | ||||
|         return super().find_fixtures(fixture_label) | ||||
|   | ||||
| @@ -11,7 +11,14 @@ logger = logging.getLogger("paperless.management.superuser") | ||||
| class Command(BaseCommand): | ||||
|  | ||||
|     help = """ | ||||
|         Creates a Django superuser based on env variables. | ||||
|         Creates a Django superuser: | ||||
|         User named: admin | ||||
|         Email: root@localhost | ||||
|         with password based on env variable. | ||||
|         No superuser will be created, when: | ||||
|         - The username is taken already exists | ||||
|         - A superuser already exists | ||||
|         - PAPERLESS_ADMIN_PASSWORD is not set | ||||
|     """.replace( | ||||
|         "    ", | ||||
|         "", | ||||
| @@ -19,26 +26,41 @@ class Command(BaseCommand): | ||||
|  | ||||
|     def handle(self, *args, **options): | ||||
|  | ||||
|         username = os.getenv("PAPERLESS_ADMIN_USER") | ||||
|         if not username: | ||||
|             return | ||||
|  | ||||
|         username = os.getenv("PAPERLESS_ADMIN_USER", "admin") | ||||
|         mail = os.getenv("PAPERLESS_ADMIN_MAIL", "root@localhost") | ||||
|         password = os.getenv("PAPERLESS_ADMIN_PASSWORD") | ||||
|  | ||||
|         # Check if user exists already, leave as is if it does | ||||
|         # Check if there's already a user called admin | ||||
|         if User.objects.filter(username=username).exists(): | ||||
|             user: User = User.objects.get_by_natural_key(username) | ||||
|             user.set_password(password) | ||||
|             user.save() | ||||
|             self.stdout.write(f"Changed password of user {username}.") | ||||
|         elif password: | ||||
|             # Create superuser based on env variables | ||||
|             User.objects.create_superuser(username, mail, password) | ||||
|             self.stdout.write(f'Created superuser "{username}" with provided password.') | ||||
|         else: | ||||
|             self.stdout.write(f'Did not create superuser "{username}".') | ||||
|             self.stdout.write( | ||||
|                 'Make sure you specified "PAPERLESS_ADMIN_PASSWORD" in your ' | ||||
|                 '"docker-compose.env" file.', | ||||
|                 self.style.NOTICE( | ||||
|                     f"Did not create superuser, a user {username} already exists", | ||||
|                 ), | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         # Check if any superuseruser | ||||
|         # exists already, leave as is if it does | ||||
|         if User.objects.filter(is_superuser=True).count() > 0: | ||||
|             self.stdout.write( | ||||
|                 self.style.NOTICE( | ||||
|                     "Did not create superuser, the DB already contains superusers", | ||||
|                 ), | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         if password is None: | ||||
|             self.stdout.write( | ||||
|                 self.style.ERROR( | ||||
|                     "Please check if PAPERLESS_ADMIN_PASSWORD has been" | ||||
|                     " set in the environment", | ||||
|                 ), | ||||
|             ) | ||||
|         else: | ||||
|             # Create superuser with password based on env variable | ||||
|             User.objects.create_superuser(username, mail, password) | ||||
|             self.stdout.write( | ||||
|                 self.style.SUCCESS( | ||||
|                     f'Created superuser "{username}" with provided password.', | ||||
|                 ), | ||||
|             ) | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import re | ||||
| from documents.models import Correspondent | ||||
| from documents.models import DocumentType | ||||
| from documents.models import MatchingModel | ||||
| from documents.models import StoragePath | ||||
| from documents.models import Tag | ||||
|  | ||||
|  | ||||
| @@ -57,6 +58,22 @@ def match_tags(document, classifier): | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def match_storage_paths(document, classifier): | ||||
|     if classifier: | ||||
|         pred_id = classifier.predict_storage_path(document.content) | ||||
|     else: | ||||
|         pred_id = None | ||||
|  | ||||
|     storage_paths = StoragePath.objects.all() | ||||
|  | ||||
|     return list( | ||||
|         filter( | ||||
|             lambda o: matches(o, document) or o.pk == pred_id, | ||||
|             storage_paths, | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def matches(matching_model, document): | ||||
|     search_kwargs = {} | ||||
|  | ||||
|   | ||||
| @@ -83,7 +83,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|     path = "" | ||||
|  | ||||
|     try: | ||||
|         if settings.PAPERLESS_FILENAME_FORMAT is not None: | ||||
|         if settings.FILENAME_FORMAT is not None: | ||||
|             tags = defaultdictNoStr(lambda: slugify(None), many_to_dictionary(doc.tags)) | ||||
|  | ||||
|             tag_list = pathvalidate.sanitize_filename( | ||||
| @@ -105,7 +105,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|             else: | ||||
|                 document_type = "none" | ||||
|  | ||||
|             path = settings.PAPERLESS_FILENAME_FORMAT.format( | ||||
|             path = settings.FILENAME_FORMAT.format( | ||||
|                 title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"), | ||||
|                 correspondent=correspondent, | ||||
|                 document_type=document_type, | ||||
| @@ -128,7 +128,7 @@ def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): | ||||
|     except (ValueError, KeyError, IndexError): | ||||
|         logger.warning( | ||||
|             f"Invalid PAPERLESS_FILENAME_FORMAT: " | ||||
|             f"{settings.PAPERLESS_FILENAME_FORMAT}, falling back to default" | ||||
|             f"{settings.FILENAME_FORMAT}, falling back to default" | ||||
|         ) | ||||
|  | ||||
|     counter_str = f"_{counter:02}" if counter else "" | ||||
|   | ||||
| @@ -0,0 +1,73 @@ | ||||
| # Generated by Django 4.0.4 on 2022-05-02 15:56 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("documents", "1018_alter_savedviewfilterrule_value"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="StoragePath", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.AutoField( | ||||
|                         auto_created=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         verbose_name="ID", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "name", | ||||
|                     models.CharField(max_length=128, unique=True, verbose_name="name"), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "match", | ||||
|                     models.CharField(blank=True, max_length=256, verbose_name="match"), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "matching_algorithm", | ||||
|                     models.PositiveIntegerField( | ||||
|                         choices=[ | ||||
|                             (1, "Any word"), | ||||
|                             (2, "All words"), | ||||
|                             (3, "Exact match"), | ||||
|                             (4, "Regular expression"), | ||||
|                             (5, "Fuzzy word"), | ||||
|                             (6, "Automatic"), | ||||
|                         ], | ||||
|                         default=1, | ||||
|                         verbose_name="matching algorithm", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "is_insensitive", | ||||
|                     models.BooleanField(default=True, verbose_name="is insensitive"), | ||||
|                 ), | ||||
|                 ("path", models.CharField(max_length=512, verbose_name="path")), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "storage path", | ||||
|                 "verbose_name_plural": "storage paths", | ||||
|                 "ordering": ("name",), | ||||
|             }, | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="document", | ||||
|             name="storage_path", | ||||
|             field=models.ForeignKey( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="documents", | ||||
|                 to="documents.storagepath", | ||||
|                 verbose_name="storage path", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										39
									
								
								src/documents/migrations/1019_uisettings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/documents/migrations/1019_uisettings.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| # Generated by Django 4.0.4 on 2022-05-07 05:10 | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|         ("documents", "1018_alter_savedviewfilterrule_value"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="UiSettings", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.AutoField( | ||||
|                         auto_created=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         verbose_name="ID", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("settings", models.JSONField(null=True)), | ||||
|                 ( | ||||
|                     "user", | ||||
|                     models.OneToOneField( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         related_name="ui_settings", | ||||
|                         to=settings.AUTH_USER_MODEL, | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										13
									
								
								src/documents/migrations/1020_merge_20220518_1839.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/documents/migrations/1020_merge_20220518_1839.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| # Generated by Django 4.0.4 on 2022-05-18 18:39 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("documents", "1019_storagepath_document_storage_path"), | ||||
|         ("documents", "1019_uisettings"), | ||||
|     ] | ||||
|  | ||||
|     operations = [] | ||||
| @@ -1,4 +1,3 @@ | ||||
| # coding=utf-8 | ||||
| import datetime | ||||
| import logging | ||||
| import os | ||||
| @@ -11,7 +10,6 @@ from django.conf import settings | ||||
| from django.contrib.auth.models import User | ||||
| from django.db import models | ||||
| from django.utils import timezone | ||||
| from django.utils.timezone import is_aware | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from documents.parsers import get_default_file_extension | ||||
|  | ||||
| @@ -85,6 +83,18 @@ class DocumentType(MatchingModel): | ||||
|         verbose_name_plural = _("document types") | ||||
|  | ||||
|  | ||||
| class StoragePath(MatchingModel): | ||||
|     path = models.CharField( | ||||
|         _("path"), | ||||
|         max_length=512, | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         ordering = ("name",) | ||||
|         verbose_name = _("storage path") | ||||
|         verbose_name_plural = _("storage paths") | ||||
|  | ||||
|  | ||||
| class Document(models.Model): | ||||
|  | ||||
|     STORAGE_TYPE_UNENCRYPTED = "unencrypted" | ||||
| @@ -103,6 +113,15 @@ class Document(models.Model): | ||||
|         verbose_name=_("correspondent"), | ||||
|     ) | ||||
|  | ||||
|     storage_path = models.ForeignKey( | ||||
|         StoragePath, | ||||
|         blank=True, | ||||
|         null=True, | ||||
|         related_name="documents", | ||||
|         on_delete=models.SET_NULL, | ||||
|         verbose_name=_("storage path"), | ||||
|     ) | ||||
|  | ||||
|     title = models.CharField(_("title"), max_length=128, blank=True, db_index=True) | ||||
|  | ||||
|     document_type = models.ForeignKey( | ||||
| @@ -210,10 +229,10 @@ class Document(models.Model): | ||||
|         verbose_name_plural = _("documents") | ||||
|  | ||||
|     def __str__(self): | ||||
|         if is_aware(self.created): | ||||
|             created = timezone.localdate(self.created).isoformat() | ||||
|         else: | ||||
|             created = datetime.date.isoformat(self.created) | ||||
|  | ||||
|         # Convert UTC database time to local time | ||||
|         created = datetime.date.isoformat(timezone.localdate(self.created)) | ||||
|  | ||||
|         if self.correspondent and self.title: | ||||
|             return f"{created} {self.correspondent} {self.title}" | ||||
|         else: | ||||
| @@ -224,7 +243,7 @@ class Document(models.Model): | ||||
|         if self.filename: | ||||
|             fname = str(self.filename) | ||||
|         else: | ||||
|             fname = "{:07}{}".format(self.pk, self.file_type) | ||||
|             fname = f"{self.pk:07}{self.file_type}" | ||||
|             if self.storage_type == self.STORAGE_TYPE_GPG: | ||||
|                 fname += ".gpg"  # pragma: no cover | ||||
|  | ||||
| @@ -271,7 +290,7 @@ class Document(models.Model): | ||||
|  | ||||
|     @property | ||||
|     def thumbnail_path(self): | ||||
|         file_name = "{:07}.png".format(self.pk) | ||||
|         file_name = f"{self.pk:07}.png" | ||||
|         if self.storage_type == self.STORAGE_TYPE_GPG: | ||||
|             file_name += ".gpg" | ||||
|  | ||||
| @@ -383,6 +402,10 @@ class SavedViewFilterRule(models.Model): | ||||
|  | ||||
|  | ||||
| # TODO: why is this in the models file? | ||||
| # TODO: how about, what is this and where is it documented? | ||||
| # It appears to parsing JSON from an environment variable to get a title and date from | ||||
| # the filename, if possible, as a higher priority than either document filename or | ||||
| # content parsing | ||||
| class FileInfo: | ||||
|  | ||||
|     REGEXES = OrderedDict( | ||||
| @@ -390,8 +413,7 @@ class FileInfo: | ||||
|             ( | ||||
|                 "created-title", | ||||
|                 re.compile( | ||||
|                     r"^(?P<created>\d\d\d\d\d\d\d\d(\d\d\d\d\d\d)?Z) - " | ||||
|                     r"(?P<title>.*)$", | ||||
|                     r"^(?P<created>\d{8}(\d{6})?Z) - " r"(?P<title>.*)$", | ||||
|                     flags=re.IGNORECASE, | ||||
|                 ), | ||||
|             ), | ||||
| @@ -417,7 +439,7 @@ class FileInfo: | ||||
|     @classmethod | ||||
|     def _get_created(cls, created): | ||||
|         try: | ||||
|             return dateutil.parser.parse("{:0<14}Z".format(created[:-1])) | ||||
|             return dateutil.parser.parse(f"{created[:-1]:0<14}Z") | ||||
|         except ValueError: | ||||
|             return None | ||||
|  | ||||
| @@ -428,10 +450,10 @@ class FileInfo: | ||||
|     @classmethod | ||||
|     def _mangle_property(cls, properties, name): | ||||
|         if name in properties: | ||||
|             properties[name] = getattr(cls, "_get_{}".format(name))(properties[name]) | ||||
|             properties[name] = getattr(cls, f"_get_{name}")(properties[name]) | ||||
|  | ||||
|     @classmethod | ||||
|     def from_filename(cls, filename): | ||||
|     def from_filename(cls, filename) -> "FileInfo": | ||||
|         # Mutate filename in-place before parsing its components | ||||
|         # by applying at most one of the configured transformations. | ||||
|         for (pattern, repl) in settings.FILENAME_PARSE_TRANSFORMS: | ||||
| @@ -464,3 +486,17 @@ class FileInfo: | ||||
|                 cls._mangle_property(properties, "created") | ||||
|                 cls._mangle_property(properties, "title") | ||||
|                 return cls(**properties) | ||||
|  | ||||
|  | ||||
| # Extending User Model Using a One-To-One Link | ||||
| class UiSettings(models.Model): | ||||
|  | ||||
|     user = models.OneToOneField( | ||||
|         User, | ||||
|         on_delete=models.CASCADE, | ||||
|         related_name="ui_settings", | ||||
|     ) | ||||
|     settings = models.JSONField(null=True) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.user.username | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import datetime | ||||
| import logging | ||||
| import mimetypes | ||||
| import os | ||||
| @@ -5,6 +6,8 @@ import re | ||||
| import shutil | ||||
| import subprocess | ||||
| import tempfile | ||||
| from typing import Optional | ||||
| from typing import Set | ||||
|  | ||||
| import magic | ||||
| from django.conf import settings | ||||
| @@ -40,11 +43,11 @@ DATE_REGEX = re.compile( | ||||
| logger = logging.getLogger("paperless.parsing") | ||||
|  | ||||
|  | ||||
| def is_mime_type_supported(mime_type): | ||||
| def is_mime_type_supported(mime_type) -> bool: | ||||
|     return get_parser_class_for_mime_type(mime_type) is not None | ||||
|  | ||||
|  | ||||
| def get_default_file_extension(mime_type): | ||||
| def get_default_file_extension(mime_type) -> str: | ||||
|     for response in document_consumer_declaration.send(None): | ||||
|         parser_declaration = response[1] | ||||
|         supported_mime_types = parser_declaration["mime_types"] | ||||
| @@ -59,14 +62,14 @@ def get_default_file_extension(mime_type): | ||||
|         return "" | ||||
|  | ||||
|  | ||||
| def is_file_ext_supported(ext): | ||||
| def is_file_ext_supported(ext) -> bool: | ||||
|     if ext: | ||||
|         return ext.lower() in get_supported_file_extensions() | ||||
|     else: | ||||
|         return False | ||||
|  | ||||
|  | ||||
| def get_supported_file_extensions(): | ||||
| def get_supported_file_extensions() -> Set[str]: | ||||
|     extensions = set() | ||||
|     for response in document_consumer_declaration.send(None): | ||||
|         parser_declaration = response[1] | ||||
| @@ -121,7 +124,7 @@ def run_convert( | ||||
|     auto_orient=False, | ||||
|     extra=None, | ||||
|     logging_group=None, | ||||
| ): | ||||
| ) -> None: | ||||
|  | ||||
|     environment = os.environ.copy() | ||||
|     if settings.CONVERT_MEMORY_LIMIT: | ||||
| @@ -143,14 +146,14 @@ def run_convert( | ||||
|     logger.debug("Execute: " + " ".join(args), extra={"group": logging_group}) | ||||
|  | ||||
|     if not subprocess.Popen(args, env=environment).wait() == 0: | ||||
|         raise ParseError("Convert failed at {}".format(args)) | ||||
|         raise ParseError(f"Convert failed at {args}") | ||||
|  | ||||
|  | ||||
| def get_default_thumbnail(): | ||||
| def get_default_thumbnail() -> str: | ||||
|     return os.path.join(os.path.dirname(__file__), "resources", "document.png") | ||||
|  | ||||
|  | ||||
| def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None): | ||||
| def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None) -> str: | ||||
|     out_path = os.path.join(temp_dir, "convert_gs.png") | ||||
|  | ||||
|     # if convert fails, fall back to extracting | ||||
| @@ -164,7 +167,7 @@ def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None): | ||||
|     cmd = [settings.GS_BINARY, "-q", "-sDEVICE=pngalpha", "-o", gs_out_path, in_path] | ||||
|     try: | ||||
|         if not subprocess.Popen(cmd).wait() == 0: | ||||
|             raise ParseError("Thumbnail (gs) failed at {}".format(cmd)) | ||||
|             raise ParseError(f"Thumbnail (gs) failed at {cmd}") | ||||
|         # then run convert on the output from gs | ||||
|         run_convert( | ||||
|             density=300, | ||||
| @@ -184,7 +187,7 @@ def make_thumbnail_from_pdf_gs_fallback(in_path, temp_dir, logging_group=None): | ||||
|         return get_default_thumbnail() | ||||
|  | ||||
|  | ||||
| def make_thumbnail_from_pdf(in_path, temp_dir, logging_group=None): | ||||
| def make_thumbnail_from_pdf(in_path, temp_dir, logging_group=None) -> str: | ||||
|     """ | ||||
|     The thumbnail of a PDF is just a 500px wide image of the first page. | ||||
|     """ | ||||
| @@ -199,7 +202,7 @@ def make_thumbnail_from_pdf(in_path, temp_dir, logging_group=None): | ||||
|             strip=True, | ||||
|             trim=False, | ||||
|             auto_orient=True, | ||||
|             input_file="{}[0]".format(in_path), | ||||
|             input_file=f"{in_path}[0]", | ||||
|             output_file=out_path, | ||||
|             logging_group=logging_group, | ||||
|         ) | ||||
| @@ -209,12 +212,12 @@ def make_thumbnail_from_pdf(in_path, temp_dir, logging_group=None): | ||||
|     return out_path | ||||
|  | ||||
|  | ||||
| def parse_date(filename, text): | ||||
| def parse_date(filename, text) -> Optional[datetime.datetime]: | ||||
|     """ | ||||
|     Returns the date of the document. | ||||
|     """ | ||||
|  | ||||
|     def __parser(ds, date_order): | ||||
|     def __parser(ds: str, date_order: str) -> datetime.datetime: | ||||
|         """ | ||||
|         Call dateparser.parse with a particular date ordering | ||||
|         """ | ||||
| @@ -230,9 +233,9 @@ def parse_date(filename, text): | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def __filter(date): | ||||
|     def __filter(date: datetime.datetime) -> Optional[datetime.datetime]: | ||||
|         if ( | ||||
|             date | ||||
|             date is not None | ||||
|             and date.year > 1900 | ||||
|             and date <= timezone.now() | ||||
|             and date.date() not in settings.IGNORE_DATES | ||||
| @@ -269,7 +272,7 @@ def parse_date(filename, text): | ||||
|  | ||||
|         date = __filter(date) | ||||
|         if date is not None: | ||||
|             break | ||||
|             return date | ||||
|  | ||||
|     return date | ||||
|  | ||||
| @@ -294,7 +297,7 @@ class DocumentParser(LoggingMixin): | ||||
|  | ||||
|         self.archive_path = None | ||||
|         self.text = None | ||||
|         self.date = None | ||||
|         self.date: Optional[datetime.datetime] = None | ||||
|         self.progress_callback = progress_callback | ||||
|  | ||||
|     def progress(self, current_progress, max_progress): | ||||
| @@ -333,7 +336,7 @@ class DocumentParser(LoggingMixin): | ||||
|             self.log("debug", f"Execute: {' '.join(args)}") | ||||
|  | ||||
|             if not subprocess.Popen(args).wait() == 0: | ||||
|                 raise ParseError("Optipng failed at {}".format(args)) | ||||
|                 raise ParseError(f"Optipng failed at {args}") | ||||
|  | ||||
|             return out_path | ||||
|         else: | ||||
| @@ -342,7 +345,7 @@ class DocumentParser(LoggingMixin): | ||||
|     def get_text(self): | ||||
|         return self.text | ||||
|  | ||||
|     def get_date(self): | ||||
|     def get_date(self) -> Optional[datetime.datetime]: | ||||
|         return self.date | ||||
|  | ||||
|     def cleanup(self): | ||||
|   | ||||
| @@ -14,7 +14,9 @@ from .models import DocumentType | ||||
| from .models import MatchingModel | ||||
| from .models import SavedView | ||||
| from .models import SavedViewFilterRule | ||||
| from .models import StoragePath | ||||
| from .models import Tag | ||||
| from .models import UiSettings | ||||
| from .parsers import is_mime_type_supported | ||||
|  | ||||
|  | ||||
| @@ -30,7 +32,7 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer): | ||||
|         fields = kwargs.pop("fields", None) | ||||
|  | ||||
|         # Instantiate the superclass normally | ||||
|         super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs) | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|         if fields is not None: | ||||
|             # Drop any fields that are not specified in the `fields` argument. | ||||
| @@ -198,11 +200,17 @@ class DocumentTypeField(serializers.PrimaryKeyRelatedField): | ||||
|         return DocumentType.objects.all() | ||||
|  | ||||
|  | ||||
| class StoragePathField(serializers.PrimaryKeyRelatedField): | ||||
|     def get_queryset(self): | ||||
|         return StoragePath.objects.all() | ||||
|  | ||||
|  | ||||
| class DocumentSerializer(DynamicFieldsModelSerializer): | ||||
|  | ||||
|     correspondent = CorrespondentField(allow_null=True) | ||||
|     tags = TagsField(many=True) | ||||
|     document_type = DocumentTypeField(allow_null=True) | ||||
|     storage_path = StoragePathField(allow_null=True) | ||||
|  | ||||
|     original_file_name = SerializerMethodField() | ||||
|     archived_file_name = SerializerMethodField() | ||||
| @@ -223,6 +231,7 @@ class DocumentSerializer(DynamicFieldsModelSerializer): | ||||
|             "id", | ||||
|             "correspondent", | ||||
|             "document_type", | ||||
|             "storage_path", | ||||
|             "title", | ||||
|             "content", | ||||
|             "tags", | ||||
| @@ -263,7 +272,7 @@ class SavedViewSerializer(serializers.ModelSerializer): | ||||
|             rules_data = validated_data.pop("filter_rules") | ||||
|         else: | ||||
|             rules_data = None | ||||
|         super(SavedViewSerializer, self).update(instance, validated_data) | ||||
|         super().update(instance, validated_data) | ||||
|         if rules_data is not None: | ||||
|             SavedViewFilterRule.objects.filter(saved_view=instance).delete() | ||||
|             for rule_data in rules_data: | ||||
| @@ -309,6 +318,7 @@ class BulkEditSerializer(DocumentListSerializer): | ||||
|         choices=[ | ||||
|             "set_correspondent", | ||||
|             "set_document_type", | ||||
|             "set_storage_path", | ||||
|             "add_tag", | ||||
|             "remove_tag", | ||||
|             "modify_tags", | ||||
| @@ -336,6 +346,8 @@ class BulkEditSerializer(DocumentListSerializer): | ||||
|             return bulk_edit.set_correspondent | ||||
|         elif method == "set_document_type": | ||||
|             return bulk_edit.set_document_type | ||||
|         elif method == "set_storage_path": | ||||
|             return bulk_edit.set_storage_path | ||||
|         elif method == "add_tag": | ||||
|             return bulk_edit.add_tag | ||||
|         elif method == "remove_tag": | ||||
| @@ -382,6 +394,20 @@ class BulkEditSerializer(DocumentListSerializer): | ||||
|         else: | ||||
|             raise serializers.ValidationError("correspondent not specified") | ||||
|  | ||||
|     def _validate_storage_path(self, parameters): | ||||
|         if "storage_path" in parameters: | ||||
|             storage_path_id = parameters["storage_path"] | ||||
|             if storage_path_id is None: | ||||
|                 return | ||||
|             try: | ||||
|                 StoragePath.objects.get(id=storage_path_id) | ||||
|             except StoragePath.DoesNotExist: | ||||
|                 raise serializers.ValidationError( | ||||
|                     "Storage path does not exist", | ||||
|                 ) | ||||
|         else: | ||||
|             raise serializers.ValidationError("storage path not specified") | ||||
|  | ||||
|     def _validate_parameters_modify_tags(self, parameters): | ||||
|         if "add_tags" in parameters: | ||||
|             self._validate_tag_id_list(parameters["add_tags"], "add_tags") | ||||
| @@ -406,12 +432,21 @@ class BulkEditSerializer(DocumentListSerializer): | ||||
|             self._validate_parameters_tags(parameters) | ||||
|         elif method == bulk_edit.modify_tags: | ||||
|             self._validate_parameters_modify_tags(parameters) | ||||
|         elif method == bulk_edit.set_storage_path: | ||||
|             self._validate_storage_path(parameters) | ||||
|  | ||||
|         return attrs | ||||
|  | ||||
|  | ||||
| class PostDocumentSerializer(serializers.Serializer): | ||||
|  | ||||
|     created = serializers.DateTimeField( | ||||
|         label="Created", | ||||
|         allow_null=True, | ||||
|         write_only=True, | ||||
|         required=False, | ||||
|     ) | ||||
|  | ||||
|     document = serializers.FileField( | ||||
|         label="Document", | ||||
|         write_only=True, | ||||
| @@ -498,3 +533,65 @@ class BulkDownloadSerializer(DocumentListSerializer): | ||||
|             "bzip2": zipfile.ZIP_BZIP2, | ||||
|             "lzma": zipfile.ZIP_LZMA, | ||||
|         }[compression] | ||||
|  | ||||
|  | ||||
| class StoragePathSerializer(MatchingModelSerializer): | ||||
|     document_count = serializers.IntegerField(read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = StoragePath | ||||
|         fields = ( | ||||
|             "id", | ||||
|             "slug", | ||||
|             "name", | ||||
|             "path", | ||||
|             "match", | ||||
|             "matching_algorithm", | ||||
|             "is_insensitive", | ||||
|             "document_count", | ||||
|         ) | ||||
|  | ||||
|     def validate_path(self, path): | ||||
|         try: | ||||
|             path.format( | ||||
|                 title="title", | ||||
|                 correspondent="correspondent", | ||||
|                 document_type="document_type", | ||||
|                 created="created", | ||||
|                 created_year="created_year", | ||||
|                 created_month="created_month", | ||||
|                 created_day="created_day", | ||||
|                 added="added", | ||||
|                 added_year="added_year", | ||||
|                 added_month="added_month", | ||||
|                 added_day="added_day", | ||||
|                 asn="asn", | ||||
|                 tags="tags", | ||||
|                 tag_list="tag_list", | ||||
|             ) | ||||
|  | ||||
|         except (KeyError): | ||||
|             raise serializers.ValidationError(_("Invalid variable detected.")) | ||||
|  | ||||
|         return path | ||||
|  | ||||
|  | ||||
| class UiSettingsViewSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = UiSettings | ||||
|         depth = 1 | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "settings", | ||||
|         ] | ||||
|  | ||||
|     def update(self, instance, validated_data): | ||||
|         super().update(instance, validated_data) | ||||
|         return instance | ||||
|  | ||||
|     def create(self, validated_data): | ||||
|         ui_settings = UiSettings.objects.update_or_create( | ||||
|             user=validated_data.get("user"), | ||||
|             defaults={"settings": validated_data.get("settings", None)}, | ||||
|         ) | ||||
|         return ui_settings | ||||
|   | ||||
| @@ -230,6 +230,76 @@ def set_tags( | ||||
|         document.tags.add(*relevant_tags) | ||||
|  | ||||
|  | ||||
| def set_storage_path( | ||||
|     sender, | ||||
|     document=None, | ||||
|     logging_group=None, | ||||
|     classifier=None, | ||||
|     replace=False, | ||||
|     use_first=True, | ||||
|     suggest=False, | ||||
|     base_url=None, | ||||
|     color=False, | ||||
|     **kwargs, | ||||
| ): | ||||
|     if document.storage_path and not replace: | ||||
|         return | ||||
|  | ||||
|     potential_storage_path = matching.match_storage_paths( | ||||
|         document, | ||||
|         classifier, | ||||
|     ) | ||||
|  | ||||
|     potential_count = len(potential_storage_path) | ||||
|     if potential_storage_path: | ||||
|         selected = potential_storage_path[0] | ||||
|     else: | ||||
|         selected = None | ||||
|  | ||||
|     if potential_count > 1: | ||||
|         if use_first: | ||||
|             logger.info( | ||||
|                 f"Detected {potential_count} potential storage paths, " | ||||
|                 f"so we've opted for {selected}", | ||||
|                 extra={"group": logging_group}, | ||||
|             ) | ||||
|         else: | ||||
|             logger.info( | ||||
|                 f"Detected {potential_count} potential storage paths, " | ||||
|                 f"not assigning any storage directory", | ||||
|                 extra={"group": logging_group}, | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|     if selected or replace: | ||||
|         if suggest: | ||||
|             if base_url: | ||||
|                 print( | ||||
|                     termcolors.colorize(str(document), fg="green") | ||||
|                     if color | ||||
|                     else str(document), | ||||
|                 ) | ||||
|                 print(f"{base_url}/documents/{document.pk}") | ||||
|             else: | ||||
|                 print( | ||||
|                     ( | ||||
|                         termcolors.colorize(str(document), fg="green") | ||||
|                         if color | ||||
|                         else str(document) | ||||
|                     ) | ||||
|                     + f" [{document.pk}]", | ||||
|                 ) | ||||
|             print(f"Sugest storage directory {selected}") | ||||
|         else: | ||||
|             logger.info( | ||||
|                 f"Assigning storage path {selected} to {document}", | ||||
|                 extra={"group": logging_group}, | ||||
|             ) | ||||
|  | ||||
|             document.storage_path = selected | ||||
|             document.save(update_fields=("storage_path",)) | ||||
|  | ||||
|  | ||||
| @receiver(models.signals.post_delete, sender=Document) | ||||
| def cleanup_document_deletion(sender, instance, using, **kwargs): | ||||
|     with FileLock(settings.MEDIA_LOCK): | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import shutil | ||||
| import tempfile | ||||
| from typing import List  # for type hinting. Can be removed, if only Python >3.8 is used | ||||
|  | ||||
| import magic | ||||
| import tqdm | ||||
| from asgiref.sync import async_to_sync | ||||
| from channels.layers import get_channel_layer | ||||
| @@ -18,6 +19,7 @@ from documents.consumer import ConsumerError | ||||
| from documents.models import Correspondent | ||||
| from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| from documents.models import StoragePath | ||||
| from documents.models import Tag | ||||
| from documents.sanity_checker import SanityCheckFailedException | ||||
| from pdf2image import convert_from_path | ||||
| @@ -52,6 +54,7 @@ def train_classifier(): | ||||
|         not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||
|         and not DocumentType.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||
|         and not Correspondent.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||
|         and not StoragePath.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() | ||||
|     ): | ||||
|  | ||||
|         return | ||||
| @@ -64,7 +67,7 @@ def train_classifier(): | ||||
|     try: | ||||
|         if classifier.train(): | ||||
|             logger.info( | ||||
|                 "Saving updated classifier model to {}...".format(settings.MODEL_FILE), | ||||
|                 f"Saving updated classifier model to {settings.MODEL_FILE}...", | ||||
|             ) | ||||
|             classifier.save() | ||||
|         else: | ||||
| @@ -95,19 +98,33 @@ def barcode_reader(image) -> List[str]: | ||||
|     return barcodes | ||||
|  | ||||
|  | ||||
| def get_file_type(path: str) -> str: | ||||
|     """ | ||||
|     Determines the file type, based on MIME type. | ||||
|  | ||||
|     Returns the MIME type. | ||||
|     """ | ||||
|     mime_type = magic.from_file(path, mime=True) | ||||
|     logger.debug(f"Detected mime type: {mime_type}") | ||||
|     return mime_type | ||||
|  | ||||
|  | ||||
| def convert_from_tiff_to_pdf(filepath: str) -> str: | ||||
|     """ | ||||
|     converts a given TIFF image file to pdf into a temp. directory. | ||||
|     converts a given TIFF image file to pdf into a temporary directory. | ||||
|  | ||||
|     Returns the new pdf file. | ||||
|     """ | ||||
|     file_name = os.path.splitext(os.path.basename(filepath))[0] | ||||
|     file_extension = os.path.splitext(os.path.basename(filepath))[1].lower() | ||||
|     mime_type = get_file_type(filepath) | ||||
|     tempdir = tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR) | ||||
|     # use old file name with pdf extension | ||||
|     if file_extension == ".tif" or file_extension == ".tiff": | ||||
|     if mime_type == "image/tiff": | ||||
|         newpath = os.path.join(tempdir, file_name + ".pdf") | ||||
|     else: | ||||
|         logger.warning(f"Cannot convert from {str(file_extension)} to pdf.") | ||||
|         logger.warning( | ||||
|             f"Cannot convert mime type {str(mime_type)} from {str(filepath)} to pdf.", | ||||
|         ) | ||||
|         return None | ||||
|     with Image.open(filepath) as image: | ||||
|         images = [] | ||||
| @@ -165,7 +182,7 @@ def separate_pages(filepath: str, pages_to_split_on: List[int]) -> List[str]: | ||||
|         for n, page in enumerate(pdf.pages): | ||||
|             if n < pages_to_split_on[0]: | ||||
|                 dst.pages.append(page) | ||||
|         output_filename = "{}_document_0.pdf".format(fname) | ||||
|         output_filename = f"{fname}_document_0.pdf" | ||||
|         savepath = os.path.join(tempdir, output_filename) | ||||
|         with open(savepath, "wb") as out: | ||||
|             dst.save(out) | ||||
| @@ -185,7 +202,7 @@ def separate_pages(filepath: str, pages_to_split_on: List[int]) -> List[str]: | ||||
|                     f"page_number: {str(page_number)} next_page: {str(next_page)}", | ||||
|                 ) | ||||
|                 dst.pages.append(pdf.pages[page]) | ||||
|             output_filename = "{}_document_{}.pdf".format(fname, str(count + 1)) | ||||
|             output_filename = f"{fname}_document_{str(count + 1)}.pdf" | ||||
|             logger.debug(f"pdf no:{str(count)} has {str(len(dst.pages))} pages") | ||||
|             savepath = os.path.join(tempdir, output_filename) | ||||
|             with open(savepath, "wb") as out: | ||||
| @@ -223,6 +240,7 @@ def consume_file( | ||||
|     override_document_type_id=None, | ||||
|     override_tag_ids=None, | ||||
|     task_id=None, | ||||
|     override_created=None, | ||||
| ): | ||||
|  | ||||
|     # check for separators in current document | ||||
| @@ -231,17 +249,17 @@ def consume_file( | ||||
|         document_list = [] | ||||
|         converted_tiff = None | ||||
|         if settings.CONSUMER_BARCODE_TIFF_SUPPORT: | ||||
|             supported_extensions = [".pdf", ".tiff", ".tif"] | ||||
|             supported_mime = ["image/tiff", "application/pdf"] | ||||
|         else: | ||||
|             supported_extensions = [".pdf"] | ||||
|         file_extension = os.path.splitext(os.path.basename(path))[1].lower() | ||||
|         if file_extension not in supported_extensions: | ||||
|             supported_mime = ["application/pdf"] | ||||
|         mime_type = get_file_type(path) | ||||
|         if mime_type not in supported_mime: | ||||
|             # if not supported, skip this routine | ||||
|             logger.warning( | ||||
|                 f"Unsupported file format for barcode reader: {str(file_extension)}", | ||||
|                 f"Unsupported file format for barcode reader: {str(mime_type)}", | ||||
|             ) | ||||
|         else: | ||||
|             if file_extension in {".tif", ".tiff"}: | ||||
|             if mime_type == "image/tiff": | ||||
|                 file_to_process = convert_from_tiff_to_pdf(path) | ||||
|             else: | ||||
|                 file_to_process = path | ||||
| @@ -266,9 +284,9 @@ def consume_file( | ||||
|                 # if we got here, the document was successfully split | ||||
|                 # and can safely be deleted | ||||
|                 if converted_tiff: | ||||
|                     logger.debug("Deleting file {}".format(file_to_process)) | ||||
|                     logger.debug(f"Deleting file {file_to_process}") | ||||
|                     os.unlink(file_to_process) | ||||
|                 logger.debug("Deleting file {}".format(path)) | ||||
|                 logger.debug(f"Deleting file {path}") | ||||
|                 os.unlink(path) | ||||
|                 # notify the sender, otherwise the progress bar | ||||
|                 # in the UI stays stuck | ||||
| @@ -303,10 +321,11 @@ def consume_file( | ||||
|         override_document_type_id=override_document_type_id, | ||||
|         override_tag_ids=override_tag_ids, | ||||
|         task_id=task_id, | ||||
|         override_created=override_created, | ||||
|     ) | ||||
|  | ||||
|     if document: | ||||
|         return "Success. New document id {} created".format(document.pk) | ||||
|         return f"Success. New document id {document.pk} created" | ||||
|     else: | ||||
|         raise ConsumerError( | ||||
|             "Unknown error: Returned document was null, but " | ||||
|   | ||||
| @@ -9,8 +9,6 @@ | ||||
|   <title>Paperless-ngx</title> | ||||
|   <base href="{% url 'base' %}"> | ||||
|   <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}}"> | ||||
| 	<meta name="robots" content="noindex,nofollow"> | ||||
|   	<link rel="icon" type="image/x-icon" href="favicon.ico"> | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/documents/tests/samples/documents/originals/0000005.pdf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/documents/tests/samples/documents/originals/0000005.pdf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/documents/tests/samples/documents/originals/0000006.pdf
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/documents/tests/samples/documents/originals/0000006.pdf
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -16,7 +16,7 @@ class TestDocumentAdmin(DirectoriesMixin, TestCase): | ||||
|             return searcher.document(id=doc.id) | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super(TestDocumentAdmin, self).setUp() | ||||
|         super().setUp() | ||||
|         self.doc_admin = DocumentAdmin(model=Document, admin_site=AdminSite()) | ||||
|  | ||||
|     def test_save_model(self): | ||||
|   | ||||
| @@ -4,8 +4,15 @@ import json | ||||
| import os | ||||
| import shutil | ||||
| import tempfile | ||||
| import urllib.request | ||||
| import zipfile | ||||
| from unittest import mock | ||||
| from unittest.mock import MagicMock | ||||
|  | ||||
| try: | ||||
|     import zoneinfo | ||||
| except ImportError: | ||||
|     import backports.zoneinfo as zoneinfo | ||||
|  | ||||
| import pytest | ||||
| from django.conf import settings | ||||
| @@ -19,15 +26,19 @@ from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| from documents.models import MatchingModel | ||||
| from documents.models import SavedView | ||||
| from documents.models import StoragePath | ||||
| from documents.models import Tag | ||||
| from documents.models import UiSettings | ||||
| from documents.models import StoragePath | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
| from paperless import version | ||||
| from rest_framework.test import APITestCase | ||||
| from whoosh.writing import AsyncWriter | ||||
|  | ||||
|  | ||||
| class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|     def setUp(self): | ||||
|         super(TestDocumentApi, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|         self.user = User.objects.create_superuser(username="temp_admin") | ||||
|         self.client.force_login(user=self.user) | ||||
| @@ -70,7 +81,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         returned_doc["title"] = "the new title" | ||||
|  | ||||
|         response = self.client.put( | ||||
|             "/api/documents/{}/".format(doc.pk), | ||||
|             f"/api/documents/{doc.pk}/", | ||||
|             returned_doc, | ||||
|             format="json", | ||||
|         ) | ||||
| @@ -82,7 +93,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(doc_after_save.correspondent, c2) | ||||
|         self.assertEqual(doc_after_save.title, "the new title") | ||||
|  | ||||
|         self.client.delete("/api/documents/{}/".format(doc_after_save.pk)) | ||||
|         self.client.delete(f"/api/documents/{doc_after_save.pk}/") | ||||
|  | ||||
|         self.assertEqual(len(Document.objects.all()), 0) | ||||
|  | ||||
| @@ -90,6 +101,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         c = Correspondent.objects.create(name="c", pk=41) | ||||
|         dt = DocumentType.objects.create(name="dt", pk=63) | ||||
|         tag = Tag.objects.create(name="t", pk=85) | ||||
|         storage_path = StoragePath.objects.create(name="sp", pk=77, path="p") | ||||
|         doc = Document.objects.create( | ||||
|             title="WOW", | ||||
|             content="the content", | ||||
| @@ -97,6 +109,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|             document_type=dt, | ||||
|             checksum="123", | ||||
|             mime_type="application/pdf", | ||||
|             storage_path=storage_path, | ||||
|         ) | ||||
|  | ||||
|         response = self.client.get("/api/documents/", format="json") | ||||
| @@ -163,27 +176,27 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         ) | ||||
|  | ||||
|         with open( | ||||
|             os.path.join(self.dirs.thumbnail_dir, "{:07d}.png".format(doc.pk)), | ||||
|             os.path.join(self.dirs.thumbnail_dir, f"{doc.pk:07d}.png"), | ||||
|             "wb", | ||||
|         ) as f: | ||||
|             f.write(content_thumbnail) | ||||
|  | ||||
|         response = self.client.get("/api/documents/{}/download/".format(doc.pk)) | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/download/") | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual(response.content, content) | ||||
|  | ||||
|         response = self.client.get("/api/documents/{}/preview/".format(doc.pk)) | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/preview/") | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual(response.content, content) | ||||
|  | ||||
|         response = self.client.get("/api/documents/{}/thumb/".format(doc.pk)) | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/thumb/") | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual(response.content, content_thumbnail) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
|     @override_settings(FILENAME_FORMAT="") | ||||
|     def test_download_with_archive(self): | ||||
|  | ||||
|         content = b"This is a test" | ||||
| @@ -202,25 +215,25 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         with open(doc.archive_path, "wb") as f: | ||||
|             f.write(content_archive) | ||||
|  | ||||
|         response = self.client.get("/api/documents/{}/download/".format(doc.pk)) | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/download/") | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual(response.content, content_archive) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/documents/{}/download/?original=true".format(doc.pk), | ||||
|             f"/api/documents/{doc.pk}/download/?original=true", | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual(response.content, content) | ||||
|  | ||||
|         response = self.client.get("/api/documents/{}/preview/".format(doc.pk)) | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/preview/") | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual(response.content, content_archive) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/documents/{}/preview/?original=true".format(doc.pk), | ||||
|             f"/api/documents/{doc.pk}/preview/?original=true", | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
| @@ -234,13 +247,13 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|             mime_type="application/pdf", | ||||
|         ) | ||||
|  | ||||
|         response = self.client.get("/api/documents/{}/download/".format(doc.pk)) | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/download/") | ||||
|         self.assertEqual(response.status_code, 404) | ||||
|  | ||||
|         response = self.client.get("/api/documents/{}/preview/".format(doc.pk)) | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/preview/") | ||||
|         self.assertEqual(response.status_code, 404) | ||||
|  | ||||
|         response = self.client.get("/api/documents/{}/thumb/".format(doc.pk)) | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/thumb/") | ||||
|         self.assertEqual(response.status_code, 404) | ||||
|  | ||||
|     def test_document_filters(self): | ||||
| @@ -283,7 +296,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertCountEqual([results[0]["id"], results[1]["id"]], [doc2.id, doc3.id]) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/documents/?tags__id__in={},{}".format(tag_inbox.id, tag_3.id), | ||||
|             f"/api/documents/?tags__id__in={tag_inbox.id},{tag_3.id}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         results = response.data["results"] | ||||
| @@ -291,7 +304,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertCountEqual([results[0]["id"], results[1]["id"]], [doc1.id, doc3.id]) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/documents/?tags__id__in={},{}".format(tag_2.id, tag_3.id), | ||||
|             f"/api/documents/?tags__id__in={tag_2.id},{tag_3.id}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         results = response.data["results"] | ||||
| @@ -299,7 +312,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertCountEqual([results[0]["id"], results[1]["id"]], [doc2.id, doc3.id]) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/documents/?tags__id__all={},{}".format(tag_2.id, tag_3.id), | ||||
|             f"/api/documents/?tags__id__all={tag_2.id},{tag_3.id}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         results = response.data["results"] | ||||
| @@ -307,27 +320,27 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(results[0]["id"], doc3.id) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/documents/?tags__id__all={},{}".format(tag_inbox.id, tag_3.id), | ||||
|             f"/api/documents/?tags__id__all={tag_inbox.id},{tag_3.id}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         results = response.data["results"] | ||||
|         self.assertEqual(len(results), 0) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/documents/?tags__id__all={}a{}".format(tag_inbox.id, tag_3.id), | ||||
|             f"/api/documents/?tags__id__all={tag_inbox.id}a{tag_3.id}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         results = response.data["results"] | ||||
|         self.assertEqual(len(results), 3) | ||||
|  | ||||
|         response = self.client.get("/api/documents/?tags__id__none={}".format(tag_3.id)) | ||||
|         response = self.client.get(f"/api/documents/?tags__id__none={tag_3.id}") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         results = response.data["results"] | ||||
|         self.assertEqual(len(results), 2) | ||||
|         self.assertCountEqual([results[0]["id"], results[1]["id"]], [doc1.id, doc2.id]) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/documents/?tags__id__none={},{}".format(tag_3.id, tag_2.id), | ||||
|             f"/api/documents/?tags__id__none={tag_3.id},{tag_2.id}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         results = response.data["results"] | ||||
| @@ -335,7 +348,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(results[0]["id"], doc1.id) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/documents/?tags__id__none={},{}".format(tag_2.id, tag_inbox.id), | ||||
|             f"/api/documents/?tags__id__none={tag_2.id},{tag_inbox.id}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         results = response.data["results"] | ||||
| @@ -571,10 +584,12 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         t2 = Tag.objects.create(name="tag2") | ||||
|         c = Correspondent.objects.create(name="correspondent") | ||||
|         dt = DocumentType.objects.create(name="type") | ||||
|         sp = StoragePath.objects.create(name="path") | ||||
|  | ||||
|         d1 = Document.objects.create(checksum="1", correspondent=c, content="test") | ||||
|         d2 = Document.objects.create(checksum="2", document_type=dt, content="test") | ||||
|         d3 = Document.objects.create(checksum="3", content="test") | ||||
|  | ||||
|         d3.tags.add(t) | ||||
|         d3.tags.add(t2) | ||||
|         d4 = Document.objects.create( | ||||
| @@ -589,6 +604,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|             content="test", | ||||
|         ) | ||||
|         d6 = Document.objects.create(checksum="6", content="test2") | ||||
|         d7 = Document.objects.create(checksum="7", storage_path=sp, content="test") | ||||
|  | ||||
|         with AsyncWriter(index.open_index()) as writer: | ||||
|             for doc in Document.objects.all(): | ||||
| @@ -599,18 +615,30 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|             self.assertEqual(r.status_code, 200) | ||||
|             return [hit["id"] for hit in r.data["results"]] | ||||
|  | ||||
|         self.assertCountEqual(search_query(""), [d1.id, d2.id, d3.id, d4.id, d5.id]) | ||||
|         self.assertCountEqual( | ||||
|             search_query(""), | ||||
|             [d1.id, d2.id, d3.id, d4.id, d5.id, d7.id], | ||||
|         ) | ||||
|         self.assertCountEqual(search_query("&is_tagged=true"), [d3.id, d4.id]) | ||||
|         self.assertCountEqual(search_query("&is_tagged=false"), [d1.id, d2.id, d5.id]) | ||||
|         self.assertCountEqual( | ||||
|             search_query("&is_tagged=false"), | ||||
|             [d1.id, d2.id, d5.id, d7.id], | ||||
|         ) | ||||
|         self.assertCountEqual(search_query("&correspondent__id=" + str(c.id)), [d1.id]) | ||||
|         self.assertCountEqual(search_query("&document_type__id=" + str(dt.id)), [d2.id]) | ||||
|         self.assertCountEqual(search_query("&storage_path__id=" + str(sp.id)), [d7.id]) | ||||
|  | ||||
|         self.assertCountEqual( | ||||
|             search_query("&storage_path__isnull"), | ||||
|             [d1.id, d2.id, d3.id, d4.id, d5.id], | ||||
|         ) | ||||
|         self.assertCountEqual( | ||||
|             search_query("&correspondent__isnull"), | ||||
|             [d2.id, d3.id, d4.id, d5.id], | ||||
|             [d2.id, d3.id, d4.id, d5.id, d7.id], | ||||
|         ) | ||||
|         self.assertCountEqual( | ||||
|             search_query("&document_type__isnull"), | ||||
|             [d1.id, d3.id, d4.id, d5.id], | ||||
|             [d1.id, d3.id, d4.id, d5.id, d7.id], | ||||
|         ) | ||||
|         self.assertCountEqual( | ||||
|             search_query("&tags__id__all=" + str(t.id) + "," + str(t2.id)), | ||||
| @@ -952,6 +980,34 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|  | ||||
|         async_task.assert_not_called() | ||||
|  | ||||
|     @mock.patch("documents.views.async_task") | ||||
|     def test_upload_with_created(self, async_task): | ||||
|         created = datetime.datetime( | ||||
|             2022, | ||||
|             5, | ||||
|             12, | ||||
|             0, | ||||
|             0, | ||||
|             0, | ||||
|             0, | ||||
|             tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles"), | ||||
|         ) | ||||
|         with open( | ||||
|             os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), | ||||
|             "rb", | ||||
|         ) as f: | ||||
|             response = self.client.post( | ||||
|                 "/api/documents/post_document/", | ||||
|                 {"document": f, "created": created}, | ||||
|             ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         async_task.assert_called_once() | ||||
|  | ||||
|         args, kwargs = async_task.call_args | ||||
|  | ||||
|         self.assertEqual(kwargs["override_created"], created) | ||||
|  | ||||
|     def test_get_metadata(self): | ||||
|         doc = Document.objects.create( | ||||
|             title="test", | ||||
| @@ -1043,35 +1099,49 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertEqual( | ||||
|             response.data, | ||||
|             {"correspondents": [], "tags": [], "document_types": []}, | ||||
|             { | ||||
|                 "correspondents": [], | ||||
|                 "tags": [], | ||||
|                 "document_types": [], | ||||
|                 "storage_paths": [], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_get_suggestions_invalid_doc(self): | ||||
|         response = self.client.get(f"/api/documents/34676/suggestions/") | ||||
|         self.assertEqual(response.status_code, 404) | ||||
|  | ||||
|     @mock.patch("documents.views.match_correspondents") | ||||
|     @mock.patch("documents.views.match_tags") | ||||
|     @mock.patch("documents.views.match_storage_paths") | ||||
|     @mock.patch("documents.views.match_document_types") | ||||
|     @mock.patch("documents.views.match_tags") | ||||
|     @mock.patch("documents.views.match_correspondents") | ||||
|     def test_get_suggestions( | ||||
|         self, | ||||
|         match_document_types, | ||||
|         match_tags, | ||||
|         match_correspondents, | ||||
|         match_tags, | ||||
|         match_document_types, | ||||
|         match_storage_paths, | ||||
|     ): | ||||
|         doc = Document.objects.create( | ||||
|             title="test", | ||||
|             mime_type="application/pdf", | ||||
|             content="this is an invoice!", | ||||
|         ) | ||||
|  | ||||
|         match_correspondents.return_value = [Correspondent(id=88), Correspondent(id=2)] | ||||
|         match_tags.return_value = [Tag(id=56), Tag(id=123)] | ||||
|         match_document_types.return_value = [DocumentType(id=23)] | ||||
|         match_correspondents.return_value = [Correspondent(id=88), Correspondent(id=2)] | ||||
|         match_storage_paths.return_value = [StoragePath(id=99), StoragePath(id=77)] | ||||
|  | ||||
|         response = self.client.get(f"/api/documents/{doc.pk}/suggestions/") | ||||
|         self.assertEqual( | ||||
|             response.data, | ||||
|             {"correspondents": [88, 2], "tags": [56, 123], "document_types": [23]}, | ||||
|             { | ||||
|                 "correspondents": [88, 2], | ||||
|                 "tags": [56, 123], | ||||
|                 "document_types": [23], | ||||
|                 "storage_paths": [99, 77], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_saved_views(self): | ||||
| @@ -1284,7 +1354,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|  | ||||
| class TestDocumentApiV2(DirectoriesMixin, APITestCase): | ||||
|     def setUp(self): | ||||
|         super(TestDocumentApiV2, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|         self.user = User.objects.create_superuser(username="temp_admin") | ||||
|  | ||||
| @@ -1362,10 +1432,45 @@ class TestDocumentApiV2(DirectoriesMixin, APITestCase): | ||||
|             "#000000", | ||||
|         ) | ||||
|  | ||||
|     def test_ui_settings(self): | ||||
|         test_user = User.objects.create_superuser(username="test") | ||||
|         self.client.force_login(user=test_user) | ||||
|  | ||||
|         response = self.client.get("/api/ui_settings/", format="json") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertDictEqual( | ||||
|             response.data["settings"], | ||||
|             {}, | ||||
|         ) | ||||
|  | ||||
|         settings = { | ||||
|             "settings": { | ||||
|                 "dark_mode": { | ||||
|                     "enabled": True, | ||||
|                 }, | ||||
|             }, | ||||
|         } | ||||
|  | ||||
|         response = self.client.post( | ||||
|             "/api/ui_settings/", | ||||
|             json.dumps(settings), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = self.client.get("/api/ui_settings/", format="json") | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertDictEqual( | ||||
|             response.data["settings"], | ||||
|             settings["settings"], | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class TestBulkEdit(DirectoriesMixin, APITestCase): | ||||
|     def setUp(self): | ||||
|         super(TestBulkEdit, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|         user = User.objects.create_superuser(username="temp_admin") | ||||
|         self.client.force_login(user=user) | ||||
| @@ -1397,6 +1502,7 @@ class TestBulkEdit(DirectoriesMixin, APITestCase): | ||||
|         self.doc2.tags.add(self.t1) | ||||
|         self.doc3.tags.add(self.t2) | ||||
|         self.doc4.tags.add(self.t1, self.t2) | ||||
|         self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}") | ||||
|  | ||||
|     def test_set_correspondent(self): | ||||
|         self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 1) | ||||
| @@ -1436,6 +1542,60 @@ class TestBulkEdit(DirectoriesMixin, APITestCase): | ||||
|         args, kwargs = self.async_task.call_args | ||||
|         self.assertCountEqual(kwargs["document_ids"], [self.doc2.id, self.doc3.id]) | ||||
|  | ||||
|     def test_set_document_storage_path(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - 5 documents without defined storage path | ||||
|         WHEN: | ||||
|             - Bulk edit called to add storage path to 1 document | ||||
|         THEN: | ||||
|             - Single document storage path update | ||||
|         """ | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 5) | ||||
|  | ||||
|         bulk_edit.set_storage_path( | ||||
|             [self.doc1.id], | ||||
|             self.sp1.id, | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 4) | ||||
|  | ||||
|         self.async_task.assert_called_once() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|  | ||||
|         self.assertCountEqual(kwargs["document_ids"], [self.doc1.id]) | ||||
|  | ||||
|     def test_unset_document_storage_path(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - 4 documents without defined storage path | ||||
|             - 1 document with a defined storage | ||||
|         WHEN: | ||||
|             - Bulk edit called to remove storage path from 1 document | ||||
|         THEN: | ||||
|             - Single document storage path removed | ||||
|         """ | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 5) | ||||
|  | ||||
|         bulk_edit.set_storage_path( | ||||
|             [self.doc1.id], | ||||
|             self.sp1.id, | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 4) | ||||
|  | ||||
|         bulk_edit.set_storage_path( | ||||
|             [self.doc1.id], | ||||
|             None, | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(Document.objects.filter(storage_path=None).count(), 5) | ||||
|  | ||||
|         self.async_task.assert_called() | ||||
|         args, kwargs = self.async_task.call_args | ||||
|  | ||||
|         self.assertCountEqual(kwargs["document_ids"], [self.doc1.id]) | ||||
|  | ||||
|     def test_add_tag(self): | ||||
|         self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2) | ||||
|         bulk_edit.add_tag( | ||||
| @@ -1886,7 +2046,7 @@ class TestBulkEdit(DirectoriesMixin, APITestCase): | ||||
|  | ||||
| class TestBulkDownload(DirectoriesMixin, APITestCase): | ||||
|     def setUp(self): | ||||
|         super(TestBulkDownload, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|         user = User.objects.create_superuser(username="temp_admin") | ||||
|         self.client.force_login(user=user) | ||||
| @@ -2094,3 +2254,170 @@ class TestApiAuth(APITestCase): | ||||
|         response = self.client.get("/api/") | ||||
|         self.assertIn("X-Api-Version", response) | ||||
|         self.assertIn("X-Version", response) | ||||
|  | ||||
|  | ||||
| class TestRemoteVersion(APITestCase): | ||||
|     ENDPOINT = "/api/remote_version/" | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|  | ||||
|     def test_remote_version_default(self): | ||||
|         response = self.client.get(self.ENDPOINT) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertDictEqual( | ||||
|             response.data, | ||||
|             { | ||||
|                 "version": "0.0.0", | ||||
|                 "update_available": False, | ||||
|                 "feature_is_set": False, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         ENABLE_UPDATE_CHECK=False, | ||||
|     ) | ||||
|     def test_remote_version_disabled(self): | ||||
|         response = self.client.get(self.ENDPOINT) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertDictEqual( | ||||
|             response.data, | ||||
|             { | ||||
|                 "version": "0.0.0", | ||||
|                 "update_available": False, | ||||
|                 "feature_is_set": True, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         ENABLE_UPDATE_CHECK=True, | ||||
|     ) | ||||
|     @mock.patch("urllib.request.urlopen") | ||||
|     def test_remote_version_enabled_no_update_prefix(self, urlopen_mock): | ||||
|  | ||||
|         cm = MagicMock() | ||||
|         cm.getcode.return_value = 200 | ||||
|         cm.read.return_value = json.dumps({"tag_name": "ngx-1.6.0"}).encode() | ||||
|         cm.__enter__.return_value = cm | ||||
|         urlopen_mock.return_value = cm | ||||
|  | ||||
|         response = self.client.get(self.ENDPOINT) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertDictEqual( | ||||
|             response.data, | ||||
|             { | ||||
|                 "version": "1.6.0", | ||||
|                 "update_available": False, | ||||
|                 "feature_is_set": True, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         ENABLE_UPDATE_CHECK=True, | ||||
|     ) | ||||
|     @mock.patch("urllib.request.urlopen") | ||||
|     def test_remote_version_enabled_no_update_no_prefix(self, urlopen_mock): | ||||
|  | ||||
|         cm = MagicMock() | ||||
|         cm.getcode.return_value = 200 | ||||
|         cm.read.return_value = json.dumps( | ||||
|             {"tag_name": version.__full_version_str__}, | ||||
|         ).encode() | ||||
|         cm.__enter__.return_value = cm | ||||
|         urlopen_mock.return_value = cm | ||||
|  | ||||
|         response = self.client.get(self.ENDPOINT) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertDictEqual( | ||||
|             response.data, | ||||
|             { | ||||
|                 "version": version.__full_version_str__, | ||||
|                 "update_available": False, | ||||
|                 "feature_is_set": True, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         ENABLE_UPDATE_CHECK=True, | ||||
|     ) | ||||
|     @mock.patch("urllib.request.urlopen") | ||||
|     def test_remote_version_enabled_update(self, urlopen_mock): | ||||
|  | ||||
|         new_version = ( | ||||
|             version.__version__[0], | ||||
|             version.__version__[1], | ||||
|             version.__version__[2] + 1, | ||||
|         ) | ||||
|         new_version_str = ".".join(map(str, new_version)) | ||||
|  | ||||
|         cm = MagicMock() | ||||
|         cm.getcode.return_value = 200 | ||||
|         cm.read.return_value = json.dumps( | ||||
|             {"tag_name": new_version_str}, | ||||
|         ).encode() | ||||
|         cm.__enter__.return_value = cm | ||||
|         urlopen_mock.return_value = cm | ||||
|  | ||||
|         response = self.client.get(self.ENDPOINT) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertDictEqual( | ||||
|             response.data, | ||||
|             { | ||||
|                 "version": new_version_str, | ||||
|                 "update_available": True, | ||||
|                 "feature_is_set": True, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         ENABLE_UPDATE_CHECK=True, | ||||
|     ) | ||||
|     @mock.patch("urllib.request.urlopen") | ||||
|     def test_remote_version_bad_json(self, urlopen_mock): | ||||
|  | ||||
|         cm = MagicMock() | ||||
|         cm.getcode.return_value = 200 | ||||
|         cm.read.return_value = b'{ "blah":' | ||||
|         cm.__enter__.return_value = cm | ||||
|         urlopen_mock.return_value = cm | ||||
|  | ||||
|         response = self.client.get(self.ENDPOINT) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertDictEqual( | ||||
|             response.data, | ||||
|             { | ||||
|                 "version": "0.0.0", | ||||
|                 "update_available": False, | ||||
|                 "feature_is_set": True, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         ENABLE_UPDATE_CHECK=True, | ||||
|     ) | ||||
|     @mock.patch("urllib.request.urlopen") | ||||
|     def test_remote_version_exception(self, urlopen_mock): | ||||
|  | ||||
|         cm = MagicMock() | ||||
|         cm.getcode.return_value = 200 | ||||
|         cm.read.side_effect = urllib.error.URLError("an error") | ||||
|         cm.__enter__.return_value = cm | ||||
|         urlopen_mock.return_value = cm | ||||
|  | ||||
|         response = self.client.get(self.ENDPOINT) | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertDictEqual( | ||||
|             response.data, | ||||
|             { | ||||
|                 "version": "0.0.0", | ||||
|                 "update_available": False, | ||||
|                 "feature_is_set": True, | ||||
|             }, | ||||
|         ) | ||||
|   | ||||
| @@ -13,13 +13,14 @@ from documents.classifier import load_classifier | ||||
| from documents.models import Correspondent | ||||
| from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| from documents.models import StoragePath | ||||
| from documents.models import Tag | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
|  | ||||
|  | ||||
| class TestClassifier(DirectoriesMixin, TestCase): | ||||
|     def setUp(self): | ||||
|         super(TestClassifier, self).setUp() | ||||
|         super().setUp() | ||||
|         self.classifier = DocumentClassifier() | ||||
|  | ||||
|     def generate_test_data(self): | ||||
| @@ -56,6 +57,16 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|             name="dt2", | ||||
|             matching_algorithm=DocumentType.MATCH_AUTO, | ||||
|         ) | ||||
|         self.sp1 = StoragePath.objects.create( | ||||
|             name="sp1", | ||||
|             path="path1", | ||||
|             matching_algorithm=DocumentType.MATCH_AUTO, | ||||
|         ) | ||||
|         self.sp2 = StoragePath.objects.create( | ||||
|             name="sp2", | ||||
|             path="path2", | ||||
|             matching_algorithm=DocumentType.MATCH_AUTO, | ||||
|         ) | ||||
|  | ||||
|         self.doc1 = Document.objects.create( | ||||
|             title="doc1", | ||||
| @@ -64,12 +75,14 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|             checksum="A", | ||||
|             document_type=self.dt, | ||||
|         ) | ||||
|  | ||||
|         self.doc2 = Document.objects.create( | ||||
|             title="doc1", | ||||
|             content="this is another document, but from c2", | ||||
|             correspondent=self.c2, | ||||
|             checksum="B", | ||||
|         ) | ||||
|  | ||||
|         self.doc_inbox = Document.objects.create( | ||||
|             title="doc235", | ||||
|             content="aa", | ||||
| @@ -81,6 +94,8 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|         self.doc2.tags.add(self.t3) | ||||
|         self.doc_inbox.tags.add(self.t2) | ||||
|  | ||||
|         self.doc1.storage_path = self.sp1 | ||||
|  | ||||
|     def testNoTrainingData(self): | ||||
|         try: | ||||
|             self.classifier.train() | ||||
| @@ -177,6 +192,14 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|         new_classifier.load() | ||||
|         self.assertFalse(new_classifier.train()) | ||||
|  | ||||
|     # @override_settings( | ||||
|     #     MODEL_FILE=os.path.join(os.path.dirname(__file__), "data", "model.pickle"), | ||||
|     # ) | ||||
|     # def test_create_test_load_and_classify(self): | ||||
|     #     self.generate_test_data() | ||||
|     #     self.classifier.train() | ||||
|     #     self.classifier.save() | ||||
|  | ||||
|     @override_settings( | ||||
|         MODEL_FILE=os.path.join(os.path.dirname(__file__), "data", "model.pickle"), | ||||
|     ) | ||||
| @@ -263,6 +286,45 @@ class TestClassifier(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(self.classifier.predict_document_type(doc1.content), dt.pk) | ||||
|         self.assertIsNone(self.classifier.predict_document_type(doc2.content)) | ||||
|  | ||||
|     def test_one_path_predict(self): | ||||
|         sp = StoragePath.objects.create( | ||||
|             name="sp", | ||||
|             matching_algorithm=StoragePath.MATCH_AUTO, | ||||
|         ) | ||||
|  | ||||
|         doc1 = Document.objects.create( | ||||
|             title="doc1", | ||||
|             content="this is a document from c1", | ||||
|             checksum="A", | ||||
|             storage_path=sp, | ||||
|         ) | ||||
|  | ||||
|         self.classifier.train() | ||||
|         self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk) | ||||
|  | ||||
|     def test_one_path_predict_manydocs(self): | ||||
|         sp = StoragePath.objects.create( | ||||
|             name="sp", | ||||
|             matching_algorithm=StoragePath.MATCH_AUTO, | ||||
|         ) | ||||
|  | ||||
|         doc1 = Document.objects.create( | ||||
|             title="doc1", | ||||
|             content="this is a document from c1", | ||||
|             checksum="A", | ||||
|             storage_path=sp, | ||||
|         ) | ||||
|  | ||||
|         doc2 = Document.objects.create( | ||||
|             title="doc1", | ||||
|             content="this is a document from c2", | ||||
|             checksum="B", | ||||
|         ) | ||||
|  | ||||
|         self.classifier.train() | ||||
|         self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk) | ||||
|         self.assertIsNone(self.classifier.predict_storage_path(doc2.content)) | ||||
|  | ||||
|     def test_one_tag_predict(self): | ||||
|         t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import datetime | ||||
| import os | ||||
| import re | ||||
| import shutil | ||||
| @@ -5,6 +6,8 @@ import tempfile | ||||
| from unittest import mock | ||||
| from unittest.mock import MagicMock | ||||
|  | ||||
| from dateutil import tz | ||||
|  | ||||
| try: | ||||
|     import zoneinfo | ||||
| except ImportError: | ||||
| @@ -41,7 +44,7 @@ class TestAttributes(TestCase): | ||||
|  | ||||
|         self.assertEqual(file_info.title, title, filename) | ||||
|  | ||||
|         self.assertEqual(tuple([t.name for t in file_info.tags]), tags, filename) | ||||
|         self.assertEqual(tuple(t.name for t in file_info.tags), tags, filename) | ||||
|  | ||||
|     def test_guess_attributes_from_name_when_title_starts_with_dash(self): | ||||
|         self._test_guess_attributes_from_name( | ||||
| @@ -176,7 +179,7 @@ class DummyParser(DocumentParser): | ||||
|         raise NotImplementedError() | ||||
|  | ||||
|     def __init__(self, logging_group, scratch_dir, archive_path): | ||||
|         super(DummyParser, self).__init__(logging_group, None) | ||||
|         super().__init__(logging_group, None) | ||||
|         _, self.fake_thumb = tempfile.mkstemp(suffix=".png", dir=scratch_dir) | ||||
|         self.archive_path = archive_path | ||||
|  | ||||
| @@ -195,7 +198,7 @@ class CopyParser(DocumentParser): | ||||
|         return self.fake_thumb | ||||
|  | ||||
|     def __init__(self, logging_group, progress_callback=None): | ||||
|         super(CopyParser, self).__init__(logging_group, progress_callback) | ||||
|         super().__init__(logging_group, progress_callback) | ||||
|         _, self.fake_thumb = tempfile.mkstemp(suffix=".png", dir=self.tempdir) | ||||
|  | ||||
|     def parse(self, document_path, mime_type, file_name=None): | ||||
| @@ -210,7 +213,7 @@ class FaultyParser(DocumentParser): | ||||
|         raise NotImplementedError() | ||||
|  | ||||
|     def __init__(self, logging_group, scratch_dir): | ||||
|         super(FaultyParser, self).__init__(logging_group) | ||||
|         super().__init__(logging_group) | ||||
|         _, self.fake_thumb = tempfile.mkstemp(suffix=".png", dir=scratch_dir) | ||||
|  | ||||
|     def get_optimised_thumbnail(self, document_path, mime_type, file_name=None): | ||||
| @@ -270,7 +273,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|         return FaultyParser(logging_group, self.dirs.scratch_dir) | ||||
|  | ||||
|     def setUp(self): | ||||
|         super(TestConsumer, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|         patcher = mock.patch("documents.parsers.document_consumer_declaration.send") | ||||
|         m = patcher.start() | ||||
| @@ -317,7 +320,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|         shutil.copy(src, dst) | ||||
|         return dst | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None, TIME_ZONE="America/Chicago") | ||||
|     @override_settings(FILENAME_FORMAT=None, TIME_ZONE="America/Chicago") | ||||
|     def testNormalOperation(self): | ||||
|  | ||||
|         filename = self.get_test_file() | ||||
| @@ -348,7 +351,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(document.created.tzinfo, zoneinfo.ZoneInfo("America/Chicago")) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None) | ||||
|     @override_settings(FILENAME_FORMAT=None) | ||||
|     def testDeleteMacFiles(self): | ||||
|         # https://github.com/jonaswinkler/paperless-ng/discussions/1037 | ||||
|  | ||||
| @@ -502,7 +505,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertRaisesMessage( | ||||
|             ConsumerError, | ||||
|             "sample.pdf: The following error occured while consuming sample.pdf: NO.", | ||||
|             "sample.pdf: The following error occurred while consuming sample.pdf: NO.", | ||||
|             self.consumer.try_consume_file, | ||||
|             filename, | ||||
|         ) | ||||
| @@ -515,7 +518,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|         # Database empty | ||||
|         self.assertEqual(len(Document.objects.all()), 0) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def testFilenameHandling(self): | ||||
|         filename = self.get_test_file() | ||||
|  | ||||
| @@ -527,7 +530,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self._assert_first_last_send_progress() | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @mock.patch("documents.signals.handlers.generate_unique_filename") | ||||
|     def testFilenameHandlingUnstableFormat(self, m): | ||||
|  | ||||
| @@ -609,7 +612,7 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self._assert_first_last_send_progress(last_status="FAILED") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     @mock.patch("documents.parsers.document_consumer_declaration.send") | ||||
|     def test_similar_filenames(self, m): | ||||
|         shutil.copy( | ||||
| @@ -654,6 +657,127 @@ class TestConsumer(DirectoriesMixin, TestCase): | ||||
|         sanity_check() | ||||
|  | ||||
|  | ||||
| @mock.patch("documents.consumer.magic.from_file", fake_magic_from_file) | ||||
| class TestConsumerCreatedDate(DirectoriesMixin, TestCase): | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|  | ||||
|         # this prevents websocket message reports during testing. | ||||
|         patcher = mock.patch("documents.consumer.Consumer._send_progress") | ||||
|         self._send_progress = patcher.start() | ||||
|         self.addCleanup(patcher.stop) | ||||
|  | ||||
|         self.consumer = Consumer() | ||||
|  | ||||
|     def test_consume_date_from_content(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - File content with date in DMY (default) format | ||||
|  | ||||
|         THEN: | ||||
|             - Should parse the date from the file content | ||||
|         """ | ||||
|         src = os.path.join( | ||||
|             os.path.dirname(__file__), | ||||
|             "samples", | ||||
|             "documents", | ||||
|             "originals", | ||||
|             "0000005.pdf", | ||||
|         ) | ||||
|         dst = os.path.join(self.dirs.scratch_dir, "sample.pdf") | ||||
|         shutil.copy(src, dst) | ||||
|  | ||||
|         document = self.consumer.try_consume_file(dst) | ||||
|  | ||||
|         self.assertEqual( | ||||
|             document.created, | ||||
|             datetime.datetime(1996, 2, 20, tzinfo=tz.gettz(settings.TIME_ZONE)), | ||||
|         ) | ||||
|  | ||||
|     @override_settings(FILENAME_DATE_ORDER="YMD") | ||||
|     def test_consume_date_from_filename(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - File content with date in DMY (default) format | ||||
|             - Filename with date in YMD format | ||||
|  | ||||
|         THEN: | ||||
|             - Should parse the date from the filename | ||||
|         """ | ||||
|         src = os.path.join( | ||||
|             os.path.dirname(__file__), | ||||
|             "samples", | ||||
|             "documents", | ||||
|             "originals", | ||||
|             "0000005.pdf", | ||||
|         ) | ||||
|         dst = os.path.join(self.dirs.scratch_dir, "Scan - 2022-02-01.pdf") | ||||
|         shutil.copy(src, dst) | ||||
|  | ||||
|         document = self.consumer.try_consume_file(dst) | ||||
|  | ||||
|         self.assertEqual( | ||||
|             document.created, | ||||
|             datetime.datetime(2022, 2, 1, tzinfo=tz.gettz(settings.TIME_ZONE)), | ||||
|         ) | ||||
|  | ||||
|     def test_consume_date_filename_date_use_content(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - File content with date in DMY (default) format | ||||
|             - Filename date parsing disabled | ||||
|             - Filename with date in YMD format | ||||
|  | ||||
|         THEN: | ||||
|             - Should parse the date from the content | ||||
|         """ | ||||
|         src = os.path.join( | ||||
|             os.path.dirname(__file__), | ||||
|             "samples", | ||||
|             "documents", | ||||
|             "originals", | ||||
|             "0000005.pdf", | ||||
|         ) | ||||
|         dst = os.path.join(self.dirs.scratch_dir, "Scan - 2022-02-01.pdf") | ||||
|         shutil.copy(src, dst) | ||||
|  | ||||
|         document = self.consumer.try_consume_file(dst) | ||||
|  | ||||
|         self.assertEqual( | ||||
|             document.created, | ||||
|             datetime.datetime(1996, 2, 20, tzinfo=tz.gettz(settings.TIME_ZONE)), | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         IGNORE_DATES=(datetime.date(2010, 12, 13), datetime.date(2011, 11, 12)), | ||||
|     ) | ||||
|     def test_consume_date_use_content_with_ignore(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - File content with dates in DMY (default) format | ||||
|             - File content includes ignored dates | ||||
|  | ||||
|         THEN: | ||||
|             - Should parse the date from the filename | ||||
|         """ | ||||
|         src = os.path.join( | ||||
|             os.path.dirname(__file__), | ||||
|             "samples", | ||||
|             "documents", | ||||
|             "originals", | ||||
|             "0000006.pdf", | ||||
|         ) | ||||
|         dst = os.path.join(self.dirs.scratch_dir, "0000006.pdf") | ||||
|         shutil.copy(src, dst) | ||||
|  | ||||
|         document = self.consumer.try_consume_file(dst) | ||||
|  | ||||
|         self.assertEqual( | ||||
|             document.created, | ||||
|             datetime.datetime(1997, 2, 20, tzinfo=tz.gettz(settings.TIME_ZONE)), | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class PreConsumeTestCase(TestCase): | ||||
|     @mock.patch("documents.consumer.Popen") | ||||
|     @override_settings(PRE_CONSUME_SCRIPT=None) | ||||
|   | ||||
| @@ -8,6 +8,7 @@ from django.conf import settings | ||||
| from django.test import override_settings | ||||
| from django.test import TestCase | ||||
| from documents.parsers import parse_date | ||||
| from paperless.settings import DATE_ORDER | ||||
|  | ||||
|  | ||||
| class TestDate(TestCase): | ||||
| @@ -16,7 +17,7 @@ class TestDate(TestCase): | ||||
|         os.path.dirname(__file__), | ||||
|         "../../paperless_tesseract/tests/samples", | ||||
|     ) | ||||
|     SCRATCH = "/tmp/paperless-tests-{}".format(str(uuid4())[:8]) | ||||
|     SCRATCH = f"/tmp/paperless-tests-{str(uuid4())[:8]}" | ||||
|  | ||||
|     def setUp(self): | ||||
|         os.makedirs(self.SCRATCH, exist_ok=True) | ||||
| @@ -160,19 +161,112 @@ class TestDate(TestCase): | ||||
|     def test_crazy_date_with_spaces(self, *args): | ||||
|         self.assertIsNone(parse_date("", "20 408000l 2475")) | ||||
|  | ||||
|     @override_settings(FILENAME_DATE_ORDER="YMD") | ||||
|     def test_filename_date_parse_valid_ymd(self, *args): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Date parsing from the filename is enabled | ||||
|             - Filename date format is with Year Month Day (YMD) | ||||
|             - Filename contains date matching the format | ||||
|  | ||||
|         THEN: | ||||
|             - Should parse the date from the filename | ||||
|         """ | ||||
|         self.assertEqual( | ||||
|             parse_date("/tmp/Scan-2022-04-01.pdf", "No date in here"), | ||||
|             datetime.datetime(2022, 4, 1, 0, 0, tzinfo=tz.gettz(settings.TIME_ZONE)), | ||||
|         ) | ||||
|  | ||||
|     @override_settings(FILENAME_DATE_ORDER="DMY") | ||||
|     def test_filename_date_parse_valid_dmy(self, *args): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Date parsing from the filename is enabled | ||||
|             - Filename date format is with Day Month Year (DMY) | ||||
|             - Filename contains date matching the format | ||||
|  | ||||
|         THEN: | ||||
|             - Should parse the date from the filename | ||||
|         """ | ||||
|         self.assertEqual( | ||||
|             parse_date("/tmp/Scan-10.01.2021.pdf", "No date in here"), | ||||
|             datetime.datetime(2021, 1, 10, 0, 0, tzinfo=tz.gettz(settings.TIME_ZONE)), | ||||
|         ) | ||||
|  | ||||
|     @override_settings(FILENAME_DATE_ORDER="YMD") | ||||
|     def test_filename_date_parse_invalid(self, *args): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Date parsing from the filename is enabled | ||||
|             - Filename includes no date | ||||
|             - File content includes no date | ||||
|  | ||||
|         THEN: | ||||
|             - No date is parsed | ||||
|         """ | ||||
|         self.assertIsNone( | ||||
|             parse_date("/tmp/20 408000l 2475 - test.pdf", "No date in here"), | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         FILENAME_DATE_ORDER="YMD", | ||||
|         IGNORE_DATES=(datetime.date(2022, 4, 1),), | ||||
|     ) | ||||
|     def test_filename_date_ignored_use_content(self, *args): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Date parsing from the filename is enabled | ||||
|             - Filename date format is with Day Month Year (YMD) | ||||
|             - Date order is Day Month Year (DMY, the default) | ||||
|             - Filename contains date matching the format | ||||
|             - Filename date is an ignored date | ||||
|             - File content includes a date | ||||
|  | ||||
|         THEN: | ||||
|             - Should parse the date from the content not filename | ||||
|         """ | ||||
|         self.assertEqual( | ||||
|             parse_date("/tmp/Scan-2022-04-01.pdf", "The matching date is 24.03.2022"), | ||||
|             datetime.datetime(2022, 3, 24, 0, 0, tzinfo=tz.gettz(settings.TIME_ZONE)), | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         IGNORE_DATES=(datetime.date(2019, 11, 3), datetime.date(2020, 1, 17)), | ||||
|     ) | ||||
|     def test_ignored_dates(self, *args): | ||||
|     def test_ignored_dates_default_order(self, *args): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Ignore dates have been set | ||||
|             - File content includes ignored dates | ||||
|             - File content includes 1 non-ignored date | ||||
|  | ||||
|         THEN: | ||||
|             - Should parse the date non-ignored date from content | ||||
|         """ | ||||
|         text = "lorem ipsum 110319, 20200117 and lorem 13.02.2018 lorem " "ipsum" | ||||
|         date = parse_date("", text) | ||||
|         self.assertEqual( | ||||
|             date, | ||||
|             parse_date("", text), | ||||
|             datetime.datetime(2018, 2, 13, 0, 0, tzinfo=tz.gettz(settings.TIME_ZONE)), | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         IGNORE_DATES=(datetime.date(2019, 11, 3), datetime.date(2020, 1, 17)), | ||||
|         DATE_ORDER="YMD", | ||||
|     ) | ||||
|     def test_ignored_dates_order_ymd(self, *args): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Ignore dates have been set | ||||
|             - Date order is Year Month Date (YMD) | ||||
|             - File content includes ignored dates | ||||
|             - File content includes 1 non-ignored date | ||||
|  | ||||
|         THEN: | ||||
|             - Should parse the date non-ignored date from content | ||||
|         """ | ||||
|         text = "lorem ipsum 190311, 20200117 and lorem 13.02.2018 lorem " "ipsum" | ||||
|  | ||||
|         self.assertEqual( | ||||
|             parse_date("", text), | ||||
|             datetime.datetime(2018, 2, 13, 0, 0, tzinfo=tz.gettz(settings.TIME_ZONE)), | ||||
|         ) | ||||
|   | ||||
| @@ -3,6 +3,11 @@ import tempfile | ||||
| from pathlib import Path | ||||
| from unittest import mock | ||||
|  | ||||
| try: | ||||
|     import zoneinfo | ||||
| except ImportError: | ||||
|     import backports.zoneinfo as zoneinfo | ||||
|  | ||||
| from django.test import override_settings | ||||
| from django.test import TestCase | ||||
| from django.utils import timezone | ||||
| @@ -51,16 +56,62 @@ class TestDocument(TestCase): | ||||
|         doc = Document( | ||||
|             mime_type="application/pdf", | ||||
|             title="test", | ||||
|             created=timezone.datetime(2020, 12, 25), | ||||
|             created=timezone.datetime(2020, 12, 25, tzinfo=zoneinfo.ZoneInfo("UTC")), | ||||
|         ) | ||||
|         self.assertEqual(doc.get_public_filename(), "2020-12-25 test.pdf") | ||||
|  | ||||
|     @override_settings( | ||||
|         TIME_ZONE="Europe/Berlin", | ||||
|     ) | ||||
|     def test_file_name_with_timezone(self): | ||||
|  | ||||
|         # See https://docs.djangoproject.com/en/4.0/ref/utils/#django.utils.timezone.now | ||||
|         # The default for created is an aware datetime in UTC | ||||
|         # This does that, just manually, with a fixed date | ||||
|         local_create_date = timezone.datetime( | ||||
|             2020, | ||||
|             12, | ||||
|             25, | ||||
|             tzinfo=zoneinfo.ZoneInfo("Europe/Berlin"), | ||||
|         ) | ||||
|  | ||||
|         utc_create_date = local_create_date.astimezone(zoneinfo.ZoneInfo("UTC")) | ||||
|  | ||||
|         doc = Document( | ||||
|             mime_type="application/pdf", | ||||
|             title="test", | ||||
|             created=utc_create_date, | ||||
|         ) | ||||
|  | ||||
|         # Ensure the create date would cause an off by 1 if not properly created above | ||||
|         self.assertEqual(utc_create_date.date().day, 24) | ||||
|         self.assertEqual(doc.get_public_filename(), "2020-12-25 test.pdf") | ||||
|  | ||||
|         local_create_date = timezone.datetime( | ||||
|             2020, | ||||
|             1, | ||||
|             1, | ||||
|             tzinfo=zoneinfo.ZoneInfo("Europe/Berlin"), | ||||
|         ) | ||||
|  | ||||
|         utc_create_date = local_create_date.astimezone(zoneinfo.ZoneInfo("UTC")) | ||||
|  | ||||
|         doc = Document( | ||||
|             mime_type="application/pdf", | ||||
|             title="test", | ||||
|             created=utc_create_date, | ||||
|         ) | ||||
|  | ||||
|         # Ensure the create date would cause an off by 1 in the year if not properly created above | ||||
|         self.assertEqual(utc_create_date.date().year, 2019) | ||||
|         self.assertEqual(doc.get_public_filename(), "2020-01-01 test.pdf") | ||||
|  | ||||
|     def test_file_name_jpg(self): | ||||
|  | ||||
|         doc = Document( | ||||
|             mime_type="image/jpeg", | ||||
|             title="test", | ||||
|             created=timezone.datetime(2020, 12, 25), | ||||
|             created=timezone.datetime(2020, 12, 25, tzinfo=zoneinfo.ZoneInfo("UTC")), | ||||
|         ) | ||||
|         self.assertEqual(doc.get_public_filename(), "2020-12-25 test.jpg") | ||||
|  | ||||
| @@ -69,7 +120,7 @@ class TestDocument(TestCase): | ||||
|         doc = Document( | ||||
|             mime_type="application/zip", | ||||
|             title="test", | ||||
|             created=timezone.datetime(2020, 12, 25), | ||||
|             created=timezone.datetime(2020, 12, 25, tzinfo=zoneinfo.ZoneInfo("UTC")), | ||||
|         ) | ||||
|         self.assertEqual(doc.get_public_filename(), "2020-12-25 test.zip") | ||||
|  | ||||
| @@ -78,6 +129,6 @@ class TestDocument(TestCase): | ||||
|         doc = Document( | ||||
|             mime_type="image/jpegasd", | ||||
|             title="test", | ||||
|             created=timezone.datetime(2020, 12, 25), | ||||
|             created=timezone.datetime(2020, 12, 25, tzinfo=zoneinfo.ZoneInfo("UTC")), | ||||
|         ) | ||||
|         self.assertEqual(doc.get_public_filename(), "2020-12-25 test") | ||||
|   | ||||
| @@ -20,27 +20,27 @@ from ..file_handling import generate_unique_filename | ||||
| from ..models import Correspondent | ||||
| from ..models import Document | ||||
| from ..models import DocumentType | ||||
| from ..models import Tag | ||||
| from ..models import StoragePath | ||||
| from .utils import DirectoriesMixin | ||||
|  | ||||
|  | ||||
| class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
|     @override_settings(FILENAME_FORMAT="") | ||||
|     def test_generate_source_filename(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
|         document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED | ||||
|         document.save() | ||||
|  | ||||
|         self.assertEqual(generate_filename(document), "{:07d}.pdf".format(document.pk)) | ||||
|         self.assertEqual(generate_filename(document), f"{document.pk:07d}.pdf") | ||||
|  | ||||
|         document.storage_type = Document.STORAGE_TYPE_GPG | ||||
|         self.assertEqual( | ||||
|             generate_filename(document), | ||||
|             "{:07d}.pdf.gpg".format(document.pk), | ||||
|             f"{document.pk:07d}.pdf.gpg", | ||||
|         ) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_file_renaming(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -50,7 +50,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Test default source_path | ||||
|         self.assertEqual( | ||||
|             document.source_path, | ||||
|             settings.ORIGINALS_DIR + "/{:07d}.pdf".format(document.pk), | ||||
|             settings.ORIGINALS_DIR + f"/{document.pk:07d}.pdf", | ||||
|         ) | ||||
|  | ||||
|         document.filename = generate_filename(document) | ||||
| @@ -82,7 +82,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|             True, | ||||
|         ) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_file_renaming_missing_permissions(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -117,7 +117,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         os.chmod(settings.ORIGINALS_DIR + "/none", 0o777) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_file_renaming_database_error(self): | ||||
|  | ||||
|         document1 = Document.objects.create( | ||||
| @@ -156,7 +156,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|             ) | ||||
|             self.assertEqual(document.filename, "none/none.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_document_delete(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -180,7 +180,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), False) | ||||
|  | ||||
|     @override_settings( | ||||
|         PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}", | ||||
|         FILENAME_FORMAT="{correspondent}/{correspondent}", | ||||
|         TRASH_DIR=tempfile.mkdtemp(), | ||||
|     ) | ||||
|     def test_document_delete_trash(self): | ||||
| @@ -218,7 +218,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         document.delete() | ||||
|         self.assertEqual(os.path.isfile(settings.TRASH_DIR + "/none_01.pdf"), True) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_document_delete_nofile(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -227,7 +227,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         document.delete() | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") | ||||
|     def test_directory_not_empty(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -253,7 +253,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), True) | ||||
|         self.assertTrue(os.path.isfile(important_file)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{document_type} - {title}") | ||||
|     @override_settings(FILENAME_FORMAT="{document_type} - {title}") | ||||
|     def test_document_type(self): | ||||
|         dt = DocumentType.objects.create(name="my_doc_type") | ||||
|         d = Document.objects.create(title="the_doc", mime_type="application/pdf") | ||||
| @@ -264,7 +264,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(d), "my_doc_type - the_doc.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{asn} - {title}") | ||||
|     @override_settings(FILENAME_FORMAT="{asn} - {title}") | ||||
|     def test_asn(self): | ||||
|         d1 = Document.objects.create( | ||||
|             title="the_doc", | ||||
| @@ -281,7 +281,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(generate_filename(d1), "652 - the_doc.pdf") | ||||
|         self.assertEqual(generate_filename(d2), "none - the_doc.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[type]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[type]}") | ||||
|     def test_tags_with_underscore(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -296,7 +296,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "demo.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[type]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[type]}") | ||||
|     def test_tags_with_dash(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -311,7 +311,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "demo.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[type]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[type]}") | ||||
|     def test_tags_malformed(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -326,7 +326,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "none.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[0]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[0]}") | ||||
|     def test_tags_all(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -340,7 +340,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "demo.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags[1]}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags[1]}") | ||||
|     def test_tags_out_of_bounds(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -354,7 +354,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         # Ensure that filename is properly generated | ||||
|         self.assertEqual(generate_filename(document), "none.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{tags}") | ||||
|     @override_settings(FILENAME_FORMAT="{tags}") | ||||
|     def test_tags_without_args(self): | ||||
|         document = Document() | ||||
|         document.mime_type = "application/pdf" | ||||
| @@ -363,7 +363,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(document), f"{document.pk:07}.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title} {tag_list}") | ||||
|     @override_settings(FILENAME_FORMAT="{title} {tag_list}") | ||||
|     def test_tag_list(self): | ||||
|         doc = Document.objects.create(title="doc1", mime_type="application/pdf") | ||||
|         doc.tags.create(name="tag2") | ||||
| @@ -379,7 +379,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(doc), "doc2.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="//etc/something/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="//etc/something/{title}") | ||||
|     def test_filename_relative(self): | ||||
|         doc = Document.objects.create(title="doc1", mime_type="application/pdf") | ||||
|         doc.filename = generate_filename(doc) | ||||
| @@ -391,7 +391,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         ) | ||||
|  | ||||
|     @override_settings( | ||||
|         PAPERLESS_FILENAME_FORMAT="{created_year}-{created_month}-{created_day}", | ||||
|         FILENAME_FORMAT="{created_year}-{created_month}-{created_day}", | ||||
|     ) | ||||
|     def test_created_year_month_day(self): | ||||
|         d1 = timezone.make_aware(datetime.datetime(2020, 3, 6, 1, 1, 1)) | ||||
| @@ -408,7 +408,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(generate_filename(doc1), "2020-11-16.pdf") | ||||
|  | ||||
|     @override_settings( | ||||
|         PAPERLESS_FILENAME_FORMAT="{added_year}-{added_month}-{added_day}", | ||||
|         FILENAME_FORMAT="{added_year}-{added_month}-{added_day}", | ||||
|     ) | ||||
|     def test_added_year_month_day(self): | ||||
|         d1 = timezone.make_aware(datetime.datetime(232, 1, 9, 1, 1, 1)) | ||||
| @@ -425,7 +425,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(generate_filename(doc1), "2020-11-16.pdf") | ||||
|  | ||||
|     @override_settings( | ||||
|         PAPERLESS_FILENAME_FORMAT="{correspondent}/{correspondent}/{correspondent}", | ||||
|         FILENAME_FORMAT="{correspondent}/{correspondent}/{correspondent}", | ||||
|     ) | ||||
|     def test_nested_directory_cleanup(self): | ||||
|         document = Document() | ||||
| @@ -453,7 +453,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR + "/none"), False) | ||||
|         self.assertEqual(os.path.isdir(settings.ORIGINALS_DIR), True) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None) | ||||
|     @override_settings(FILENAME_FORMAT=None) | ||||
|     def test_format_none(self): | ||||
|         document = Document() | ||||
|         document.pk = 1 | ||||
| @@ -479,7 +479,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual(os.path.isfile(os.path.join(tmp, "notempty", "file")), True) | ||||
|         self.assertEqual(os.path.isdir(os.path.join(tmp, "notempty", "empty")), False) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{created/[title]") | ||||
|     @override_settings(FILENAME_FORMAT="{created/[title]") | ||||
|     def test_invalid_format(self): | ||||
|         document = Document() | ||||
|         document.pk = 1 | ||||
| @@ -488,7 +488,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(document), "0000001.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{created__year}") | ||||
|     @override_settings(FILENAME_FORMAT="{created__year}") | ||||
|     def test_invalid_format_key(self): | ||||
|         document = Document() | ||||
|         document.pk = 1 | ||||
| @@ -497,7 +497,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertEqual(generate_filename(document), "0000001.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_duplicates(self): | ||||
|         document = Document.objects.create( | ||||
|             mime_type="application/pdf", | ||||
| @@ -548,7 +548,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(document.source_path)) | ||||
|         self.assertEqual(document2.filename, "qwe.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     @mock.patch("documents.signals.handlers.Document.objects.filter") | ||||
|     def test_no_update_without_change(self, m): | ||||
|         doc = Document.objects.create( | ||||
| @@ -568,7 +568,7 @@ class TestFileHandling(DirectoriesMixin, TestCase): | ||||
|  | ||||
|  | ||||
| class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT=None) | ||||
|     @override_settings(FILENAME_FORMAT=None) | ||||
|     def test_create_no_format(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -587,7 +587,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_create_with_format(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -615,7 +615,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|             os.path.join(settings.ARCHIVE_DIR, "none", "my_doc.pdf"), | ||||
|         ) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_move_archive_gone(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -634,7 +634,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertFalse(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_move_archive_exists(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -659,7 +659,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(existing_archive_file)) | ||||
|         self.assertEqual(doc.archive_filename, "none/my_doc_01.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_move_original_only(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "document_01.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "document.pdf") | ||||
| @@ -681,7 +681,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_move_archive_only(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "document.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "document_01.pdf") | ||||
| @@ -703,7 +703,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @mock.patch("documents.signals.handlers.os.rename") | ||||
|     def test_move_archive_error(self, m): | ||||
|         def fake_rename(src, dst): | ||||
| @@ -734,7 +734,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_move_file_gone(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -754,7 +754,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertFalse(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @mock.patch("documents.signals.handlers.os.rename") | ||||
|     def test_move_file_error(self, m): | ||||
|         def fake_rename(src, dst): | ||||
| @@ -785,7 +785,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|         self.assertTrue(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
|     @override_settings(FILENAME_FORMAT="") | ||||
|     def test_archive_deleted(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
|         archive = os.path.join(settings.ARCHIVE_DIR, "0000001.pdf") | ||||
| @@ -812,7 +812,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertFalse(os.path.isfile(doc.source_path)) | ||||
|         self.assertFalse(os.path.isfile(doc.archive_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_archive_deleted2(self): | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "document.png") | ||||
|         original2 = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
| @@ -846,7 +846,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|         self.assertTrue(os.path.isfile(doc1.archive_path)) | ||||
|         self.assertFalse(os.path.isfile(doc2.source_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
|     def test_database_error(self): | ||||
|  | ||||
|         original = os.path.join(settings.ORIGINALS_DIR, "0000001.pdf") | ||||
| @@ -872,7 +872,7 @@ class TestFileHandlingWithArchive(DirectoriesMixin, TestCase): | ||||
|  | ||||
|  | ||||
| class TestFilenameGeneration(TestCase): | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_invalid_characters(self): | ||||
|  | ||||
|         doc = Document.objects.create( | ||||
| @@ -891,7 +891,7 @@ class TestFilenameGeneration(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "my-invalid-..-title-yay.pdf") | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{created}") | ||||
|     @override_settings(FILENAME_FORMAT="{created}") | ||||
|     def test_date(self): | ||||
|         doc = Document.objects.create( | ||||
|             title="does not matter", | ||||
| @@ -902,6 +902,140 @@ class TestFilenameGeneration(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "2020-05-21.pdf") | ||||
|  | ||||
|     def test_dynamic_path(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A document with a defined storage path | ||||
|         WHEN: | ||||
|             - the filename is generated for the document | ||||
|         THEN: | ||||
|             - the generated filename uses the defined storage path for the document | ||||
|         """ | ||||
|         doc = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             storage_path=StoragePath.objects.create(path="TestFolder/{created}"), | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf") | ||||
|  | ||||
|     def test_dynamic_path_with_none(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A document with a defined storage path | ||||
|             - The defined storage path uses an undefined field for the document | ||||
|         WHEN: | ||||
|             - the filename is generated for the document | ||||
|         THEN: | ||||
|             - the generated filename uses the defined storage path for the document | ||||
|             - the generated filename includes "none" in the place undefined field | ||||
|         """ | ||||
|         doc = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             storage_path=StoragePath.objects.create(path="{asn} - {created}"), | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "none - 2020-06-25.pdf") | ||||
|  | ||||
|     @override_settings( | ||||
|         FILENAME_FORMAT_REMOVE_NONE=True, | ||||
|     ) | ||||
|     def test_dynamic_path_remove_none(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A document with a defined storage path | ||||
|             - The defined storage path uses an undefined field for the document | ||||
|             - The setting for removing undefined fields is enabled | ||||
|         WHEN: | ||||
|             - the filename is generated for the document | ||||
|         THEN: | ||||
|             - the generated filename uses the defined storage path for the document | ||||
|             - the generated filename does not include "none" in the place undefined field | ||||
|         """ | ||||
|         doc = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             storage_path=StoragePath.objects.create(path="TestFolder/{asn}/{created}"), | ||||
|         ) | ||||
|         self.assertEqual(generate_filename(doc), "TestFolder/2020-06-25.pdf") | ||||
|  | ||||
|     def test_multiple_doc_paths(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Two documents, each with different storage paths | ||||
|         WHEN: | ||||
|             - the filename is generated for the documents | ||||
|         THEN: | ||||
|             - Each document generated filename uses its storage path | ||||
|         """ | ||||
|         doc_a = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             archive_serial_number=4, | ||||
|             storage_path=StoragePath.objects.create( | ||||
|                 name="sp1", | ||||
|                 path="ThisIsAFolder/{asn}/{created}", | ||||
|             ), | ||||
|         ) | ||||
|         doc_b = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 7, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=5, | ||||
|             checksum="abcde", | ||||
|             storage_path=StoragePath.objects.create( | ||||
|                 name="sp2", | ||||
|                 path="SomeImportantNone/{created}", | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(generate_filename(doc_a), "ThisIsAFolder/4/2020-06-25.pdf") | ||||
|         self.assertEqual(generate_filename(doc_b), "SomeImportantNone/2020-07-25.pdf") | ||||
|  | ||||
|     def test_no_path_fallback(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Two documents, one with defined storage path, the other not | ||||
|         WHEN: | ||||
|             - the filename is generated for the documents | ||||
|         THEN: | ||||
|             - Document with defined path uses its format | ||||
|             - Document without defined path uses the default path | ||||
|         """ | ||||
|         doc_a = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 6, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=2, | ||||
|             checksum="2", | ||||
|             archive_serial_number=4, | ||||
|         ) | ||||
|         doc_b = Document.objects.create( | ||||
|             title="does not matter", | ||||
|             created=timezone.make_aware(datetime.datetime(2020, 7, 25, 7, 36, 51, 153)), | ||||
|             mime_type="application/pdf", | ||||
|             pk=5, | ||||
|             checksum="abcde", | ||||
|             storage_path=StoragePath.objects.create( | ||||
|                 name="sp2", | ||||
|                 path="SomeImportantNone/{created}", | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         self.assertEqual(generate_filename(doc_a), "0000002.pdf") | ||||
|         self.assertEqual(generate_filename(doc_b), "SomeImportantNone/2020-07-25.pdf") | ||||
|  | ||||
|  | ||||
| def run(): | ||||
|     doc = Document.objects.create( | ||||
|   | ||||
| @@ -18,7 +18,7 @@ from documents.tests.utils import DirectoriesMixin | ||||
| sample_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf") | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
| @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
| class TestArchiver(DirectoriesMixin, TestCase): | ||||
|     def make_models(self): | ||||
|         return Document.objects.create( | ||||
| @@ -72,7 +72,7 @@ class TestArchiver(DirectoriesMixin, TestCase): | ||||
|         self.assertIsNone(doc.archive_filename) | ||||
|         self.assertTrue(os.path.isfile(doc.source_path)) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}") | ||||
|     def test_naming_priorities(self): | ||||
|         doc1 = Document.objects.create( | ||||
|             checksum="A", | ||||
| @@ -109,7 +109,7 @@ class TestDecryptDocuments(TestCase): | ||||
|         ORIGINALS_DIR=os.path.join(os.path.dirname(__file__), "samples", "originals"), | ||||
|         THUMBNAIL_DIR=os.path.join(os.path.dirname(__file__), "samples", "thumb"), | ||||
|         PASSPHRASE="test", | ||||
|         PAPERLESS_FILENAME_FORMAT=None, | ||||
|         FILENAME_FORMAT=None, | ||||
|     ) | ||||
|     @mock.patch("documents.management.commands.decrypt_documents.input") | ||||
|     def test_decrypt(self, m): | ||||
| @@ -184,7 +184,7 @@ class TestMakeIndex(TestCase): | ||||
|  | ||||
|  | ||||
| class TestRenamer(DirectoriesMixin, TestCase): | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
|     @override_settings(FILENAME_FORMAT="") | ||||
|     def test_rename(self): | ||||
|         doc = Document.objects.create(title="test", mime_type="image/jpeg") | ||||
|         doc.filename = generate_filename(doc) | ||||
| @@ -194,7 +194,7 @@ class TestRenamer(DirectoriesMixin, TestCase): | ||||
|         Path(doc.source_path).touch() | ||||
|         Path(doc.archive_path).touch() | ||||
|  | ||||
|         with override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}"): | ||||
|         with override_settings(FILENAME_FORMAT="{correspondent}/{title}"): | ||||
|             call_command("document_renamer") | ||||
|  | ||||
|         doc2 = Document.objects.get(id=doc.id) | ||||
|   | ||||
| @@ -39,7 +39,7 @@ class ConsumerMixin: | ||||
|     sample_file = os.path.join(os.path.dirname(__file__), "samples", "simple.pdf") | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super(ConsumerMixin, self).setUp() | ||||
|         super().setUp() | ||||
|         self.t = None | ||||
|         patcher = mock.patch( | ||||
|             "documents.management.commands.document_consumer.async_task", | ||||
| @@ -60,7 +60,7 @@ class ConsumerMixin: | ||||
|             # wait for the consumer to exit. | ||||
|             self.t.join() | ||||
|  | ||||
|         super(ConsumerMixin, self).tearDown() | ||||
|         super().tearDown() | ||||
|  | ||||
|     def wait_for_task_mock_call(self, excpeted_call_count=1): | ||||
|         n = 0 | ||||
| @@ -98,6 +98,9 @@ class ConsumerMixin: | ||||
|             print("file completed.") | ||||
|  | ||||
|  | ||||
| @override_settings( | ||||
|     CONSUMER_INOTIFY_DELAY=0.01, | ||||
| ) | ||||
| class TestConsumer(DirectoriesMixin, ConsumerMixin, TransactionTestCase): | ||||
|     def test_consume_file(self): | ||||
|         self.t_start() | ||||
| @@ -286,7 +289,7 @@ class TestConsumerPolling(TestConsumer): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| @override_settings(CONSUMER_RECURSIVE=True) | ||||
| @override_settings(CONSUMER_INOTIFY_DELAY=0.01, CONSUMER_RECURSIVE=True) | ||||
| class TestConsumerRecursive(TestConsumer): | ||||
|     # just do all the tests with recursive | ||||
|     pass | ||||
|   | ||||
| @@ -65,7 +65,7 @@ class TestExportImport(DirectoriesMixin, TestCase): | ||||
|         self.d1.correspondent = self.c1 | ||||
|         self.d1.document_type = self.dt1 | ||||
|         self.d1.save() | ||||
|         super(TestExportImport, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|     def _get_document_from_manifest(self, manifest, id): | ||||
|         f = list( | ||||
| @@ -200,7 +200,7 @@ class TestExportImport(DirectoriesMixin, TestCase): | ||||
|         ) | ||||
|  | ||||
|         with override_settings( | ||||
|             PAPERLESS_FILENAME_FORMAT="{created_year}/{correspondent}/{title}", | ||||
|             FILENAME_FORMAT="{created_year}/{correspondent}/{title}", | ||||
|         ): | ||||
|             self.test_exporter(use_filename_format=True) | ||||
|  | ||||
| @@ -309,7 +309,7 @@ class TestExportImport(DirectoriesMixin, TestCase): | ||||
|  | ||||
|         self.assertTrue(len(manifest), 6) | ||||
|  | ||||
|     @override_settings(PAPERLESS_FILENAME_FORMAT="{title}/{correspondent}") | ||||
|     @override_settings(FILENAME_FORMAT="{title}/{correspondent}") | ||||
|     def test_update_export_changed_location(self): | ||||
|         shutil.rmtree(os.path.join(self.dirs.media_dir, "documents")) | ||||
|         shutil.copytree( | ||||
|   | ||||
| @@ -82,7 +82,7 @@ class TestRetagger(DirectoriesMixin, TestCase): | ||||
|         ) | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super(TestRetagger, self).setUp() | ||||
|         super().setUp() | ||||
|         self.make_models() | ||||
|  | ||||
|     def test_add_tags(self): | ||||
|   | ||||
| @@ -1,67 +1,180 @@ | ||||
| import os | ||||
| import shutil | ||||
| from io import StringIO | ||||
| from unittest import mock | ||||
|  | ||||
| from django.contrib.auth.models import User | ||||
| from django.core.management import call_command | ||||
| from django.test import TestCase | ||||
| from documents.management.commands.document_thumbnails import _process_document | ||||
| from documents.models import Correspondent | ||||
| from documents.models import Document | ||||
| from documents.models import DocumentType | ||||
| from documents.models import Tag | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
|  | ||||
|  | ||||
| class TestManageSuperUser(DirectoriesMixin, TestCase): | ||||
|     def reset_environment(self): | ||||
|         if "PAPERLESS_ADMIN_USER" in os.environ: | ||||
|             del os.environ["PAPERLESS_ADMIN_USER"] | ||||
|         if "PAPERLESS_ADMIN_PASSWORD" in os.environ: | ||||
|             del os.environ["PAPERLESS_ADMIN_PASSWORD"] | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         self.reset_environment() | ||||
|  | ||||
|     def tearDown(self) -> None: | ||||
|         super().tearDown() | ||||
|         self.reset_environment() | ||||
|     def call_command(self, environ): | ||||
|         out = StringIO() | ||||
|         with mock.patch.dict(os.environ, environ): | ||||
|             call_command( | ||||
|                 "manage_superuser", | ||||
|                 "--no-color", | ||||
|                 stdout=out, | ||||
|                 stderr=StringIO(), | ||||
|             ) | ||||
|         return out.getvalue() | ||||
|  | ||||
|     def test_no_user(self): | ||||
|         call_command("manage_superuser") | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Environment does not contain admin user info | ||||
|         THEN: | ||||
|             - No admin user is created | ||||
|         """ | ||||
|  | ||||
|         # just the consumer user. | ||||
|         out = self.call_command(environ={}) | ||||
|  | ||||
|         # just the consumer user which is created | ||||
|         # during migration | ||||
|         self.assertEqual(User.objects.count(), 1) | ||||
|         self.assertTrue(User.objects.filter(username="consumer").exists()) | ||||
|         self.assertEqual(User.objects.filter(is_superuser=True).count(), 0) | ||||
|         self.assertEqual( | ||||
|             out, | ||||
|             "Please check if PAPERLESS_ADMIN_PASSWORD has been set in the environment\n", | ||||
|         ) | ||||
|  | ||||
|     def test_create(self): | ||||
|         os.environ["PAPERLESS_ADMIN_USER"] = "new_user" | ||||
|         os.environ["PAPERLESS_ADMIN_PASSWORD"] = "123456" | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Environment does contain admin user password | ||||
|         THEN: | ||||
|             - admin user is created | ||||
|         """ | ||||
|  | ||||
|         call_command("manage_superuser") | ||||
|         out = self.call_command(environ={"PAPERLESS_ADMIN_PASSWORD": "123456"}) | ||||
|  | ||||
|         user: User = User.objects.get_by_natural_key("new_user") | ||||
|         self.assertTrue(user.check_password("123456")) | ||||
|         # count is 2 as there's the consumer | ||||
|         # user already created during migration | ||||
|         user: User = User.objects.get_by_natural_key("admin") | ||||
|         self.assertEqual(User.objects.count(), 2) | ||||
|         self.assertTrue(user.is_superuser) | ||||
|         self.assertEqual(user.email, "root@localhost") | ||||
|         self.assertEqual(out, 'Created superuser "admin" with provided password.\n') | ||||
|  | ||||
|     def test_update(self): | ||||
|         os.environ["PAPERLESS_ADMIN_USER"] = "new_user" | ||||
|         os.environ["PAPERLESS_ADMIN_PASSWORD"] = "123456" | ||||
|     def test_some_superuser_exists(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A super user already exists | ||||
|             - Environment does contain admin user password | ||||
|         THEN: | ||||
|             - admin user is NOT created | ||||
|         """ | ||||
|         User.objects.create_superuser("someuser", "root@localhost", "password") | ||||
|  | ||||
|         call_command("manage_superuser") | ||||
|         out = self.call_command(environ={"PAPERLESS_ADMIN_PASSWORD": "123456"}) | ||||
|  | ||||
|         os.environ["PAPERLESS_ADMIN_USER"] = "new_user" | ||||
|         os.environ["PAPERLESS_ADMIN_PASSWORD"] = "more_secure_pwd_7645" | ||||
|         self.assertEqual(User.objects.count(), 2) | ||||
|         with self.assertRaises(User.DoesNotExist): | ||||
|             User.objects.get_by_natural_key("admin") | ||||
|         self.assertEqual( | ||||
|             out, | ||||
|             "Did not create superuser, the DB already contains superusers\n", | ||||
|         ) | ||||
|  | ||||
|         call_command("manage_superuser") | ||||
|     def test_admin_superuser_exists(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A super user already exists | ||||
|             - The existing superuser's username is admin | ||||
|             - Environment does contain admin user password | ||||
|         THEN: | ||||
|             - Password remains unchanged | ||||
|         """ | ||||
|         User.objects.create_superuser("admin", "root@localhost", "password") | ||||
|  | ||||
|         user: User = User.objects.get_by_natural_key("new_user") | ||||
|         self.assertTrue(user.check_password("more_secure_pwd_7645")) | ||||
|         out = self.call_command(environ={"PAPERLESS_ADMIN_PASSWORD": "123456"}) | ||||
|  | ||||
|         self.assertEqual(User.objects.count(), 2) | ||||
|         user: User = User.objects.get_by_natural_key("admin") | ||||
|         self.assertTrue(user.check_password("password")) | ||||
|         self.assertEqual(out, "Did not create superuser, a user admin already exists\n") | ||||
|  | ||||
|     def test_admin_user_exists(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A user already exists with the username admin | ||||
|             - Environment does contain admin user password | ||||
|         THEN: | ||||
|             - Password remains unchanged | ||||
|             - User is not upgraded to superuser | ||||
|         """ | ||||
|  | ||||
|         User.objects.create_user("admin", "root@localhost", "password") | ||||
|  | ||||
|         out = self.call_command(environ={"PAPERLESS_ADMIN_PASSWORD": "123456"}) | ||||
|  | ||||
|         self.assertEqual(User.objects.count(), 2) | ||||
|         user: User = User.objects.get_by_natural_key("admin") | ||||
|         self.assertTrue(user.check_password("password")) | ||||
|         self.assertFalse(user.is_superuser) | ||||
|         self.assertEqual(out, "Did not create superuser, a user admin already exists\n") | ||||
|  | ||||
|     def test_no_password(self): | ||||
|         os.environ["PAPERLESS_ADMIN_USER"] = "new_user" | ||||
|  | ||||
|         call_command("manage_superuser") | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - No environment data is set | ||||
|         THEN: | ||||
|             - No user is created | ||||
|         """ | ||||
|         out = self.call_command(environ={}) | ||||
|  | ||||
|         with self.assertRaises(User.DoesNotExist): | ||||
|             User.objects.get_by_natural_key("new_user") | ||||
|             User.objects.get_by_natural_key("admin") | ||||
|         self.assertEqual( | ||||
|             out, | ||||
|             "Please check if PAPERLESS_ADMIN_PASSWORD has been set in the environment\n", | ||||
|         ) | ||||
|  | ||||
|     def test_user_email(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Environment does contain admin user password | ||||
|             - Environment contains user email | ||||
|         THEN: | ||||
|             - admin user is created | ||||
|         """ | ||||
|  | ||||
|         out = self.call_command( | ||||
|             environ={ | ||||
|                 "PAPERLESS_ADMIN_PASSWORD": "123456", | ||||
|                 "PAPERLESS_ADMIN_MAIL": "hello@world.com", | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|         user: User = User.objects.get_by_natural_key("admin") | ||||
|         self.assertEqual(User.objects.count(), 2) | ||||
|         self.assertTrue(user.is_superuser) | ||||
|         self.assertEqual(user.email, "hello@world.com") | ||||
|         self.assertEqual(user.username, "admin") | ||||
|         self.assertEqual(out, 'Created superuser "admin" with provided password.\n') | ||||
|  | ||||
|     def test_user_username(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Environment does contain admin user password | ||||
|             - Environment contains user username | ||||
|         THEN: | ||||
|             - admin user is created | ||||
|         """ | ||||
|  | ||||
|         out = self.call_command( | ||||
|             environ={ | ||||
|                 "PAPERLESS_ADMIN_PASSWORD": "123456", | ||||
|                 "PAPERLESS_ADMIN_MAIL": "hello@world.com", | ||||
|                 "PAPERLESS_ADMIN_USER": "super", | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|         user: User = User.objects.get_by_natural_key("super") | ||||
|         self.assertEqual(User.objects.count(), 2) | ||||
|         self.assertTrue(user.is_superuser) | ||||
|         self.assertEqual(user.email, "hello@world.com") | ||||
|         self.assertEqual(user.username, "super") | ||||
|         self.assertEqual(out, 'Created superuser "super" with provided password.\n') | ||||
|   | ||||
| @@ -39,7 +39,7 @@ class TestMakeThumbnails(DirectoriesMixin, TestCase): | ||||
|         ) | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super(TestMakeThumbnails, self).setUp() | ||||
|         super().setUp() | ||||
|         self.make_models() | ||||
|  | ||||
|     def test_process_document(self): | ||||
|   | ||||
| @@ -36,13 +36,13 @@ class _TestMatchingBase(TestCase): | ||||
|                 doc = Document(content=string) | ||||
|                 self.assertTrue( | ||||
|                     matching.matches(instance, doc), | ||||
|                     '"%s" should match "%s" but it does not' % (match_text, string), | ||||
|                     f'"{match_text}" should match "{string}" but it does not', | ||||
|                 ) | ||||
|             for string in no_match: | ||||
|                 doc = Document(content=string) | ||||
|                 self.assertFalse( | ||||
|                     matching.matches(instance, doc), | ||||
|                     '"%s" should not match "%s" but it does' % (match_text, string), | ||||
|                     f'"{match_text}" should not match "{string}" but it does', | ||||
|                 ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -22,7 +22,7 @@ def archive_path_old(self): | ||||
|     if self.filename: | ||||
|         fname = archive_name_from_filename(self.filename) | ||||
|     else: | ||||
|         fname = "{:07}.pdf".format(self.pk) | ||||
|         fname = f"{self.pk:07}.pdf" | ||||
|  | ||||
|     return os.path.join(settings.ARCHIVE_DIR, fname) | ||||
|  | ||||
| @@ -38,7 +38,7 @@ def source_path(doc): | ||||
|     if doc.filename: | ||||
|         fname = str(doc.filename) | ||||
|     else: | ||||
|         fname = "{:07}{}".format(doc.pk, doc.file_type) | ||||
|         fname = f"{doc.pk:07}{doc.file_type}" | ||||
|         if doc.storage_type == STORAGE_TYPE_GPG: | ||||
|             fname += ".gpg"  # pragma: no cover | ||||
|  | ||||
| @@ -46,7 +46,7 @@ def source_path(doc): | ||||
|  | ||||
|  | ||||
| def thumbnail_path(doc): | ||||
|     file_name = "{:07}.png".format(doc.pk) | ||||
|     file_name = f"{doc.pk:07}.png" | ||||
|     if doc.storage_type == STORAGE_TYPE_GPG: | ||||
|         file_name += ".gpg" | ||||
|  | ||||
| @@ -111,7 +111,7 @@ simple_png = os.path.join(os.path.dirname(__file__), "samples", "simple-noalpha. | ||||
| simple_png2 = os.path.join(os.path.dirname(__file__), "examples", "no-text.png") | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
| @override_settings(FILENAME_FORMAT="") | ||||
| class TestMigrateArchiveFiles(DirectoriesMixin, TestMigrations): | ||||
|  | ||||
|     migrate_from = "1011_auto_20210101_2340" | ||||
| @@ -240,7 +240,7 @@ class TestMigrateArchiveFiles(DirectoriesMixin, TestMigrations): | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
| @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
| class TestMigrateArchiveFilesWithFilenameFormat(TestMigrateArchiveFiles): | ||||
|     def test_filenames(self): | ||||
|         Document = self.apps.get_model("documents", "Document") | ||||
| @@ -279,7 +279,7 @@ def fake_parse_wrapper(parser, path, mime_type, file_name): | ||||
|     parser.text = "the text" | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
| @override_settings(FILENAME_FORMAT="") | ||||
| class TestMigrateArchiveFilesErrors(DirectoriesMixin, TestMigrations): | ||||
|  | ||||
|     migrate_from = "1011_auto_20210101_2340" | ||||
| @@ -447,7 +447,7 @@ class TestMigrateArchiveFilesErrors(DirectoriesMixin, TestMigrations): | ||||
|         self.assertIsNone(doc2.archive_filename) | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
| @override_settings(FILENAME_FORMAT="") | ||||
| class TestMigrateArchiveFilesBackwards(DirectoriesMixin, TestMigrations): | ||||
|  | ||||
|     migrate_from = "1012_fix_archive_files" | ||||
| @@ -505,14 +505,14 @@ class TestMigrateArchiveFilesBackwards(DirectoriesMixin, TestMigrations): | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="{correspondent}/{title}") | ||||
| @override_settings(FILENAME_FORMAT="{correspondent}/{title}") | ||||
| class TestMigrateArchiveFilesBackwardsWithFilenameFormat( | ||||
|     TestMigrateArchiveFilesBackwards, | ||||
| ): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| @override_settings(PAPERLESS_FILENAME_FORMAT="") | ||||
| @override_settings(FILENAME_FORMAT="") | ||||
| class TestMigrateArchiveFilesBackwardsErrors(DirectoriesMixin, TestMigrations): | ||||
|  | ||||
|     migrate_from = "1012_fix_archive_files" | ||||
|   | ||||
| @@ -15,7 +15,7 @@ def source_path_before(self): | ||||
|     if self.filename: | ||||
|         fname = str(self.filename) | ||||
|     else: | ||||
|         fname = "{:07}.{}".format(self.pk, self.file_type) | ||||
|         fname = f"{self.pk:07}.{self.file_type}" | ||||
|         if self.storage_type == STORAGE_TYPE_GPG: | ||||
|             fname += ".gpg" | ||||
|  | ||||
| @@ -30,7 +30,7 @@ def source_path_after(doc): | ||||
|     if doc.filename: | ||||
|         fname = str(doc.filename) | ||||
|     else: | ||||
|         fname = "{:07}{}".format(doc.pk, file_type_after(doc)) | ||||
|         fname = f"{doc.pk:07}{file_type_after(doc)}" | ||||
|         if doc.storage_type == STORAGE_TYPE_GPG: | ||||
|             fname += ".gpg"  # pragma: no cover | ||||
|  | ||||
|   | ||||
| @@ -31,7 +31,7 @@ def fake_magic_from_file(file, mime=False): | ||||
| class TestParserDiscovery(TestCase): | ||||
|     @mock.patch("documents.parsers.document_consumer_declaration.send") | ||||
|     def test__get_parser_class_1_parser(self, m, *args): | ||||
|         class DummyParser(object): | ||||
|         class DummyParser: | ||||
|             pass | ||||
|  | ||||
|         m.return_value = ( | ||||
| @@ -49,10 +49,10 @@ class TestParserDiscovery(TestCase): | ||||
|  | ||||
|     @mock.patch("documents.parsers.document_consumer_declaration.send") | ||||
|     def test__get_parser_class_n_parsers(self, m, *args): | ||||
|         class DummyParser1(object): | ||||
|         class DummyParser1: | ||||
|             pass | ||||
|  | ||||
|         class DummyParser2(object): | ||||
|         class DummyParser2: | ||||
|             pass | ||||
|  | ||||
|         m.return_value = ( | ||||
|   | ||||
| @@ -204,6 +204,34 @@ class TestTasks(DirectoriesMixin, TestCase): | ||||
|         img = Image.open(test_file) | ||||
|         self.assertEqual(tasks.barcode_reader(img), ["CUSTOM BARCODE"]) | ||||
|  | ||||
|     def test_get_mime_type(self): | ||||
|         tiff_file = os.path.join( | ||||
|             os.path.dirname(__file__), | ||||
|             "samples", | ||||
|             "simple.tiff", | ||||
|         ) | ||||
|         pdf_file = os.path.join( | ||||
|             os.path.dirname(__file__), | ||||
|             "samples", | ||||
|             "simple.pdf", | ||||
|         ) | ||||
|         png_file = os.path.join( | ||||
|             os.path.dirname(__file__), | ||||
|             "samples", | ||||
|             "barcodes", | ||||
|             "barcode-128-custom.png", | ||||
|         ) | ||||
|         tiff_file_no_extension = os.path.join(settings.SCRATCH_DIR, "testfile1") | ||||
|         pdf_file_no_extension = os.path.join(settings.SCRATCH_DIR, "testfile2") | ||||
|         shutil.copy(tiff_file, tiff_file_no_extension) | ||||
|         shutil.copy(pdf_file, pdf_file_no_extension) | ||||
|  | ||||
|         self.assertEqual(tasks.get_file_type(tiff_file), "image/tiff") | ||||
|         self.assertEqual(tasks.get_file_type(pdf_file), "application/pdf") | ||||
|         self.assertEqual(tasks.get_file_type(tiff_file_no_extension), "image/tiff") | ||||
|         self.assertEqual(tasks.get_file_type(pdf_file_no_extension), "application/pdf") | ||||
|         self.assertEqual(tasks.get_file_type(png_file), "image/png") | ||||
|  | ||||
|     def test_convert_from_tiff_to_pdf(self): | ||||
|         test_file = os.path.join( | ||||
|             os.path.dirname(__file__), | ||||
| @@ -469,7 +497,7 @@ class TestTasks(DirectoriesMixin, TestCase): | ||||
|         self.assertEqual( | ||||
|             cm.output, | ||||
|             [ | ||||
|                 "WARNING:paperless.tasks:Unsupported file format for barcode reader: .jpg", | ||||
|                 "WARNING:paperless.tasks:Unsupported file format for barcode reader: image/jpeg", | ||||
|             ], | ||||
|         ) | ||||
|         m.assert_called_once() | ||||
| @@ -481,6 +509,26 @@ class TestTasks(DirectoriesMixin, TestCase): | ||||
|         self.assertIsNone(kwargs["override_document_type_id"]) | ||||
|         self.assertIsNone(kwargs["override_tag_ids"]) | ||||
|  | ||||
|     @override_settings( | ||||
|         CONSUMER_ENABLE_BARCODES=True, | ||||
|         CONSUMER_BARCODE_TIFF_SUPPORT=True, | ||||
|     ) | ||||
|     def test_consume_barcode_supported_no_extension_file(self): | ||||
|         """ | ||||
|         This test assumes barcode and TIFF support are enabled and | ||||
|         the user uploads a supported image file, but without extension | ||||
|         """ | ||||
|         test_file = os.path.join( | ||||
|             os.path.dirname(__file__), | ||||
|             "samples", | ||||
|             "barcodes", | ||||
|             "patch-code-t-middle.tiff", | ||||
|         ) | ||||
|         dst = os.path.join(settings.SCRATCH_DIR, "patch-code-t-middle") | ||||
|         shutil.copy(test_file, dst) | ||||
|  | ||||
|         self.assertEqual(tasks.consume_file(dst), "File successfully split") | ||||
|  | ||||
|     @mock.patch("documents.tasks.sanity_checker.check_sanity") | ||||
|     def test_sanity_check_success(self, m): | ||||
|         m.return_value = SanityCheckMessages() | ||||
|   | ||||
| @@ -76,10 +76,10 @@ class DirectoriesMixin: | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.dirs = setup_directories() | ||||
|         super(DirectoriesMixin, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|     def tearDown(self) -> None: | ||||
|         super(DirectoriesMixin, self).tearDown() | ||||
|         super().tearDown() | ||||
|         remove_dirs(self.dirs) | ||||
|  | ||||
|  | ||||
| @@ -93,7 +93,7 @@ class TestMigrations(TransactionTestCase): | ||||
|     auto_migrate = True | ||||
|  | ||||
|     def setUp(self): | ||||
|         super(TestMigrations, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|         assert ( | ||||
|             self.migrate_from and self.migrate_to | ||||
|   | ||||
| @@ -11,6 +11,7 @@ from unicodedata import normalize | ||||
| from urllib.parse import quote | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import User | ||||
| from django.db.models import Case | ||||
| from django.db.models import Count | ||||
| from django.db.models import IntegerField | ||||
| @@ -54,14 +55,17 @@ from .classifier import load_classifier | ||||
| from .filters import CorrespondentFilterSet | ||||
| from .filters import DocumentFilterSet | ||||
| from .filters import DocumentTypeFilterSet | ||||
| from .filters import StoragePathFilterSet | ||||
| from .filters import TagFilterSet | ||||
| 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 Correspondent | ||||
| from .models import Document | ||||
| from .models import DocumentType | ||||
| from .models import SavedView | ||||
| from .models import StoragePath | ||||
| from .models import Tag | ||||
| from .parsers import get_parser_class_for_mime_type | ||||
| from .serialisers import BulkDownloadSerializer | ||||
| @@ -72,8 +76,10 @@ from .serialisers import DocumentSerializer | ||||
| from .serialisers import DocumentTypeSerializer | ||||
| from .serialisers import PostDocumentSerializer | ||||
| from .serialisers import SavedViewSerializer | ||||
| from .serialisers import StoragePathSerializer | ||||
| from .serialisers import TagSerializer | ||||
| from .serialisers import TagSerializerVersion1 | ||||
| from .serialisers import UiSettingsViewSerializer | ||||
|  | ||||
| logger = logging.getLogger("paperless.api") | ||||
|  | ||||
| @@ -81,12 +87,18 @@ logger = logging.getLogger("paperless.api") | ||||
| class IndexView(TemplateView): | ||||
|     template_name = "index.html" | ||||
|  | ||||
|     def get_language(self): | ||||
|     def get_frontend_language(self): | ||||
|         if hasattr( | ||||
|             self.request.user, | ||||
|             "ui_settings", | ||||
|         ) and self.request.user.ui_settings.settings.get("language"): | ||||
|             lang = self.request.user.ui_settings.settings.get("language") | ||||
|         else: | ||||
|             lang = get_language() | ||||
|         # This is here for the following reason: | ||||
|         # Django identifies languages in the form "en-us" | ||||
|         # However, angular generates locales as "en-US". | ||||
|         # this translates between these two forms. | ||||
|         lang = get_language() | ||||
|         if "-" in lang: | ||||
|             first = lang[: lang.index("-")] | ||||
|             second = lang[lang.index("-") + 1 :] | ||||
| @@ -99,16 +111,18 @@ class IndexView(TemplateView): | ||||
|         context["cookie_prefix"] = settings.COOKIE_PREFIX | ||||
|         context["username"] = self.request.user.username | ||||
|         context["full_name"] = self.request.user.get_full_name() | ||||
|         context["styles_css"] = f"frontend/{self.get_language()}/styles.css" | ||||
|         context["runtime_js"] = f"frontend/{self.get_language()}/runtime.js" | ||||
|         context["polyfills_js"] = f"frontend/{self.get_language()}/polyfills.js" | ||||
|         context["main_js"] = f"frontend/{self.get_language()}/main.js" | ||||
|         context["styles_css"] = f"frontend/{self.get_frontend_language()}/styles.css" | ||||
|         context["runtime_js"] = f"frontend/{self.get_frontend_language()}/runtime.js" | ||||
|         context[ | ||||
|             "polyfills_js" | ||||
|         ] = f"frontend/{self.get_frontend_language()}/polyfills.js" | ||||
|         context["main_js"] = f"frontend/{self.get_frontend_language()}/main.js" | ||||
|         context[ | ||||
|             "webmanifest" | ||||
|         ] = f"frontend/{self.get_language()}/manifest.webmanifest"  # noqa: E501 | ||||
|         ] = f"frontend/{self.get_frontend_language()}/manifest.webmanifest"  # noqa: E501 | ||||
|         context[ | ||||
|             "apple_touch_icon" | ||||
|         ] = f"frontend/{self.get_language()}/apple-touch-icon.png"  # noqa: E501 | ||||
|         ] = f"frontend/{self.get_frontend_language()}/apple-touch-icon.png"  # noqa: E501 | ||||
|         return context | ||||
|  | ||||
|  | ||||
| @@ -210,7 +224,7 @@ class DocumentViewSet( | ||||
|         return serializer_class(*args, **kwargs) | ||||
|  | ||||
|     def update(self, request, *args, **kwargs): | ||||
|         response = super(DocumentViewSet, self).update(request, *args, **kwargs) | ||||
|         response = super().update(request, *args, **kwargs) | ||||
|         from documents import index | ||||
|  | ||||
|         index.add_or_update_document(self.get_object()) | ||||
| @@ -220,7 +234,7 @@ class DocumentViewSet( | ||||
|         from documents import index | ||||
|  | ||||
|         index.remove_document_from_index(self.get_object()) | ||||
|         return super(DocumentViewSet, self).destroy(request, *args, **kwargs) | ||||
|         return super().destroy(request, *args, **kwargs) | ||||
|  | ||||
|     @staticmethod | ||||
|     def original_requested(request): | ||||
| @@ -325,6 +339,7 @@ class DocumentViewSet( | ||||
|                 "document_types": [ | ||||
|                     dt.id for dt in match_document_types(doc, classifier) | ||||
|                 ], | ||||
|                 "storage_paths": [dt.id for dt in match_storage_paths(doc, classifier)], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @@ -362,7 +377,7 @@ class DocumentViewSet( | ||||
| class SearchResultSerializer(DocumentSerializer): | ||||
|     def to_representation(self, instance): | ||||
|         doc = Document.objects.get(id=instance["id"]) | ||||
|         r = super(SearchResultSerializer, self).to_representation(doc) | ||||
|         r = super().to_representation(doc) | ||||
|         r["__search_hit__"] = { | ||||
|             "score": instance.score, | ||||
|             "highlights": instance.highlights("content", text=doc.content) | ||||
| @@ -376,7 +391,7 @@ class SearchResultSerializer(DocumentSerializer): | ||||
|  | ||||
| class UnifiedSearchViewSet(DocumentViewSet): | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super(UnifiedSearchViewSet, self).__init__(*args, **kwargs) | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.searcher = None | ||||
|  | ||||
|     def get_serializer_class(self): | ||||
| @@ -408,7 +423,7 @@ class UnifiedSearchViewSet(DocumentViewSet): | ||||
|                 self.paginator.get_page_size(self.request), | ||||
|             ) | ||||
|         else: | ||||
|             return super(UnifiedSearchViewSet, self).filter_queryset(queryset) | ||||
|             return super().filter_queryset(queryset) | ||||
|  | ||||
|     def list(self, request, *args, **kwargs): | ||||
|         if self._is_search_request(): | ||||
| @@ -417,13 +432,13 @@ class UnifiedSearchViewSet(DocumentViewSet): | ||||
|             try: | ||||
|                 with index.open_index_searcher() as s: | ||||
|                     self.searcher = s | ||||
|                     return super(UnifiedSearchViewSet, self).list(request) | ||||
|                     return super().list(request) | ||||
|             except NotFound: | ||||
|                 raise | ||||
|             except Exception as e: | ||||
|                 return HttpResponseBadRequest(str(e)) | ||||
|         else: | ||||
|             return super(UnifiedSearchViewSet, self).list(request) | ||||
|             return super().list(request) | ||||
|  | ||||
|  | ||||
| class LogViewSet(ViewSet): | ||||
| @@ -441,7 +456,7 @@ class LogViewSet(ViewSet): | ||||
|         if not os.path.isfile(filename): | ||||
|             raise Http404() | ||||
|  | ||||
|         with open(filename, "r") as f: | ||||
|         with open(filename) as f: | ||||
|             lines = [line.rstrip() for line in f.readlines()] | ||||
|  | ||||
|         return Response(lines) | ||||
| @@ -504,6 +519,7 @@ class PostDocumentView(GenericAPIView): | ||||
|         document_type_id = serializer.validated_data.get("document_type") | ||||
|         tag_ids = serializer.validated_data.get("tags") | ||||
|         title = serializer.validated_data.get("title") | ||||
|         created = serializer.validated_data.get("created") | ||||
|  | ||||
|         t = int(mktime(datetime.now().timetuple())) | ||||
|  | ||||
| @@ -530,6 +546,7 @@ class PostDocumentView(GenericAPIView): | ||||
|             override_tag_ids=tag_ids, | ||||
|             task_id=task_id, | ||||
|             task_name=os.path.basename(doc_name)[:100], | ||||
|             override_created=created, | ||||
|         ) | ||||
|  | ||||
|         return Response("OK") | ||||
| @@ -565,6 +582,12 @@ class SelectionDataView(GenericAPIView): | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         storage_paths = StoragePath.objects.annotate( | ||||
|             document_count=Count( | ||||
|                 Case(When(documents__id__in=ids, then=1), output_field=IntegerField()), | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         r = Response( | ||||
|             { | ||||
|                 "selected_correspondents": [ | ||||
| @@ -577,6 +600,10 @@ class SelectionDataView(GenericAPIView): | ||||
|                 "selected_document_types": [ | ||||
|                     {"id": t.id, "document_count": t.document_count} for t in types | ||||
|                 ], | ||||
|                 "selected_storage_paths": [ | ||||
|                     {"id": t.id, "document_count": t.document_count} | ||||
|                     for t in storage_paths | ||||
|                 ], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @@ -692,7 +719,10 @@ class RemoteVersionView(GenericAPIView): | ||||
|                     remote = response.read().decode("utf-8") | ||||
|                 try: | ||||
|                     remote_json = json.loads(remote) | ||||
|                     remote_version = remote_json["tag_name"].removeprefix("ngx-") | ||||
|                     remote_version = remote_json["tag_name"] | ||||
|                     # Basically PEP 616 but that only went in 3.9 | ||||
|                     if remote_version.startswith("ngx-"): | ||||
|                         remote_version = remote_version[len("ngx-") :] | ||||
|                 except ValueError: | ||||
|                     logger.debug("An error occurred parsing remote version json") | ||||
|             except urllib.error.URLError: | ||||
| @@ -712,3 +742,56 @@ class RemoteVersionView(GenericAPIView): | ||||
|                 "feature_is_set": feature_is_set, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class StoragePathViewSet(ModelViewSet): | ||||
|     model = DocumentType | ||||
|  | ||||
|     queryset = StoragePath.objects.annotate(document_count=Count("documents")).order_by( | ||||
|         Lower("name"), | ||||
|     ) | ||||
|  | ||||
|     serializer_class = StoragePathSerializer | ||||
|     pagination_class = StandardPagination | ||||
|     permission_classes = (IsAuthenticated,) | ||||
|     filter_backends = (DjangoFilterBackend, OrderingFilter) | ||||
|     filterset_class = StoragePathFilterSet | ||||
|     ordering_fields = ("name", "path", "matching_algorithm", "match", "document_count") | ||||
|  | ||||
|  | ||||
| class UiSettingsView(GenericAPIView): | ||||
|  | ||||
|     permission_classes = (IsAuthenticated,) | ||||
|     serializer_class = UiSettingsViewSerializer | ||||
|  | ||||
|     def get(self, request, format=None): | ||||
|         serializer = self.get_serializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         user = User.objects.get(pk=request.user.id) | ||||
|         displayname = user.username | ||||
|         if user.first_name or user.last_name: | ||||
|             displayname = " ".join([user.first_name, user.last_name]) | ||||
|         settings = {} | ||||
|         if hasattr(user, "ui_settings"): | ||||
|             settings = user.ui_settings.settings | ||||
|         return Response( | ||||
|             { | ||||
|                 "user_id": user.id, | ||||
|                 "username": user.username, | ||||
|                 "display_name": displayname, | ||||
|                 "settings": settings, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def post(self, request, format=None): | ||||
|         serializer = self.get_serializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         serializer.save(user=self.request.user) | ||||
|  | ||||
|         return Response( | ||||
|             { | ||||
|                 "success": True, | ||||
|             }, | ||||
|         ) | ||||
|   | ||||
| @@ -2,7 +2,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: paperless-ngx\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2022-03-02 11:20-0800\n" | ||||
| "POT-Creation-Date: 2022-05-19 15:24-0700\n" | ||||
| "PO-Revision-Date: 2022-02-17 04:17\n" | ||||
| "Last-Translator: \n" | ||||
| "Language-Team: English\n" | ||||
| @@ -17,373 +17,397 @@ msgstr "" | ||||
| "X-Crowdin-File: /dev/src/locale/en_US/LC_MESSAGES/django.po\n" | ||||
| "X-Crowdin-File-ID: 14\n" | ||||
|  | ||||
| #: documents/apps.py:10 | ||||
| #: documents/apps.py:9 | ||||
| msgid "Documents" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:32 | ||||
| #: documents/models.py:27 | ||||
| msgid "Any word" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:33 | ||||
| #: documents/models.py:28 | ||||
| msgid "All words" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:34 | ||||
| #: documents/models.py:29 | ||||
| msgid "Exact match" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:35 | ||||
| #: documents/models.py:30 | ||||
| msgid "Regular expression" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:36 | ||||
| #: documents/models.py:31 | ||||
| msgid "Fuzzy word" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:37 | ||||
| #: documents/models.py:32 | ||||
| msgid "Automatic" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:40 documents/models.py:314 paperless_mail/models.py:23 | ||||
| #: paperless_mail/models.py:107 | ||||
| #: documents/models.py:35 documents/models.py:343 paperless_mail/models.py:16 | ||||
| #: paperless_mail/models.py:79 | ||||
| msgid "name" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:42 | ||||
| #: documents/models.py:37 | ||||
| msgid "match" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:45 | ||||
| #: documents/models.py:40 | ||||
| msgid "matching algorithm" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:48 | ||||
| #: documents/models.py:45 | ||||
| msgid "is insensitive" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:61 documents/models.py:104 | ||||
| #: documents/models.py:58 documents/models.py:113 | ||||
| msgid "correspondent" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:62 | ||||
| #: documents/models.py:59 | ||||
| msgid "correspondents" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:67 | ||||
| #: documents/models.py:64 | ||||
| msgid "color" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:70 | ||||
| #: documents/models.py:67 | ||||
| msgid "is inbox tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:73 | ||||
| #: documents/models.py:70 | ||||
| msgid "" | ||||
| "Marks this tag as an inbox tag: All newly consumed documents will be tagged " | ||||
| "with inbox tags." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:79 | ||||
| #: documents/models.py:76 | ||||
| msgid "tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:80 documents/models.py:130 | ||||
| #: documents/models.py:77 documents/models.py:151 | ||||
| msgid "tags" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:85 documents/models.py:115 | ||||
| #: documents/models.py:82 documents/models.py:133 | ||||
| msgid "document type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:86 | ||||
| #: documents/models.py:83 | ||||
| msgid "document types" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:94 | ||||
| msgid "Unencrypted" | ||||
| #: documents/models.py:88 | ||||
| msgid "path" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:94 documents/models.py:122 | ||||
| msgid "storage path" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:95 | ||||
| msgid "storage paths" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:103 | ||||
| msgid "Unencrypted" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:104 | ||||
| msgid "Encrypted with GNU Privacy Guard" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:107 | ||||
| #: documents/models.py:125 | ||||
| msgid "title" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:119 | ||||
| #: documents/models.py:137 | ||||
| msgid "content" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:122 | ||||
| #: documents/models.py:140 | ||||
| msgid "" | ||||
| "The raw, text-only data of the document. This field is primarily used for " | ||||
| "searching." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:127 | ||||
| #: documents/models.py:145 | ||||
| msgid "mime type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:134 | ||||
| #: documents/models.py:155 | ||||
| msgid "checksum" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:138 | ||||
| #: documents/models.py:159 | ||||
| msgid "The checksum of the original document." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:142 | ||||
| #: documents/models.py:163 | ||||
| msgid "archive checksum" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:147 | ||||
| #: documents/models.py:168 | ||||
| msgid "The checksum of the archived document." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:150 documents/models.py:295 | ||||
| #: documents/models.py:171 documents/models.py:324 | ||||
| msgid "created" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:153 | ||||
| #: documents/models.py:174 | ||||
| msgid "modified" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:157 | ||||
| #: documents/models.py:181 | ||||
| msgid "storage type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:165 | ||||
| #: documents/models.py:189 | ||||
| msgid "added" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:169 | ||||
| #: documents/models.py:196 | ||||
| msgid "filename" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:175 | ||||
| #: documents/models.py:202 | ||||
| msgid "Current filename in storage" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:179 | ||||
| #: documents/models.py:206 | ||||
| msgid "archive filename" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:185 | ||||
| #: documents/models.py:212 | ||||
| msgid "Current archive filename in storage" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:189 | ||||
| #: documents/models.py:216 | ||||
| msgid "archive serial number" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:195 | ||||
| #: documents/models.py:222 | ||||
| msgid "The position of this document in your physical document archive." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:201 | ||||
| #: documents/models.py:228 | ||||
| msgid "document" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:202 | ||||
| #: documents/models.py:229 | ||||
| msgid "documents" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:280 | ||||
| #: documents/models.py:307 | ||||
| msgid "debug" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:281 | ||||
| #: documents/models.py:308 | ||||
| msgid "information" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:282 | ||||
| #: documents/models.py:309 | ||||
| msgid "warning" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:283 | ||||
| #: documents/models.py:310 | ||||
| msgid "error" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:284 | ||||
| #: documents/models.py:311 | ||||
| msgid "critical" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:287 | ||||
| #: documents/models.py:314 | ||||
| msgid "group" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:289 | ||||
| #: documents/models.py:316 | ||||
| msgid "message" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:292 | ||||
| #: documents/models.py:319 | ||||
| msgid "level" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:299 | ||||
| #: documents/models.py:328 | ||||
| msgid "log" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:300 | ||||
| #: documents/models.py:329 | ||||
| msgid "logs" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:310 documents/models.py:360 | ||||
| #: documents/models.py:339 documents/models.py:392 | ||||
| msgid "saved view" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:311 | ||||
| #: documents/models.py:340 | ||||
| msgid "saved views" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:313 | ||||
| #: documents/models.py:342 | ||||
| msgid "user" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:317 | ||||
| #: documents/models.py:346 | ||||
| msgid "show on dashboard" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:320 | ||||
| #: documents/models.py:349 | ||||
| msgid "show in sidebar" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:324 | ||||
| #: documents/models.py:353 | ||||
| msgid "sort field" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:326 | ||||
| #: documents/models.py:358 | ||||
| msgid "sort reverse" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:331 | ||||
| #: documents/models.py:363 | ||||
| msgid "title contains" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:332 | ||||
| #: documents/models.py:364 | ||||
| msgid "content contains" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:333 | ||||
| #: documents/models.py:365 | ||||
| msgid "ASN is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:334 | ||||
| #: documents/models.py:366 | ||||
| msgid "correspondent is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:335 | ||||
| #: documents/models.py:367 | ||||
| msgid "document type is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:336 | ||||
| #: documents/models.py:368 | ||||
| msgid "is in inbox" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:337 | ||||
| #: documents/models.py:369 | ||||
| msgid "has tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:338 | ||||
| #: documents/models.py:370 | ||||
| msgid "has any tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:339 | ||||
| #: documents/models.py:371 | ||||
| msgid "created before" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:340 | ||||
| #: documents/models.py:372 | ||||
| msgid "created after" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:341 | ||||
| #: documents/models.py:373 | ||||
| msgid "created year is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:342 | ||||
| #: documents/models.py:374 | ||||
| msgid "created month is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:343 | ||||
| #: documents/models.py:375 | ||||
| msgid "created day is" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:344 | ||||
| #: documents/models.py:376 | ||||
| msgid "added before" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:345 | ||||
| #: documents/models.py:377 | ||||
| msgid "added after" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:346 | ||||
| #: documents/models.py:378 | ||||
| msgid "modified before" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:347 | ||||
| #: documents/models.py:379 | ||||
| msgid "modified after" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:348 | ||||
| #: documents/models.py:380 | ||||
| msgid "does not have tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:349 | ||||
| #: documents/models.py:381 | ||||
| msgid "does not have ASN" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:350 | ||||
| #: documents/models.py:382 | ||||
| msgid "title or content contains" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:351 | ||||
| #: documents/models.py:383 | ||||
| msgid "fulltext query" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:352 | ||||
| #: documents/models.py:384 | ||||
| msgid "more like this" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:353 | ||||
| #: documents/models.py:385 | ||||
| msgid "has tags in" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:363 | ||||
| #: documents/models.py:395 | ||||
| msgid "rule type" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:365 | ||||
| #: documents/models.py:397 | ||||
| msgid "value" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:368 | ||||
| #: documents/models.py:400 | ||||
| msgid "filter rule" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/models.py:369 | ||||
| #: documents/models.py:401 | ||||
| msgid "filter rules" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:64 | ||||
| #: documents/serialisers.py:63 | ||||
| #, python-format | ||||
| msgid "Invalid regular expression: %(error)s" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:185 | ||||
| #: documents/serialisers.py:184 | ||||
| msgid "Invalid color." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:459 | ||||
| #: documents/serialisers.py:491 | ||||
| #, python-format | ||||
| msgid "File type %(type)s not supported" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/templates/index.html:22 | ||||
| #: documents/serialisers.py:574 | ||||
| msgid "Invalid variable detected." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/templates/index.html:78 | ||||
| msgid "Paperless-ngx is loading..." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/templates/index.html:79 | ||||
| msgid "Still here?! Hmm, something might be wrong." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/templates/index.html:79 | ||||
| msgid "Here's a link to the docs." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/templates/registration/logged_out.html:14 | ||||
| msgid "Paperless-ngx signed out" | ||||
| msgstr "" | ||||
| @@ -420,71 +444,91 @@ msgstr "" | ||||
| msgid "Sign in" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:299 | ||||
| #: paperless/settings.py:338 | ||||
| msgid "English (US)" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:300 | ||||
| #: paperless/settings.py:339 | ||||
| msgid "Belarusian" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:340 | ||||
| msgid "Czech" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:301 | ||||
| #: paperless/settings.py:341 | ||||
| msgid "Danish" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:302 | ||||
| #: paperless/settings.py:342 | ||||
| msgid "German" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:303 | ||||
| #: paperless/settings.py:343 | ||||
| msgid "English (GB)" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:304 | ||||
| #: paperless/settings.py:344 | ||||
| msgid "Spanish" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:305 | ||||
| #: paperless/settings.py:345 | ||||
| msgid "French" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:306 | ||||
| #: paperless/settings.py:346 | ||||
| msgid "Italian" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:307 | ||||
| #: paperless/settings.py:347 | ||||
| msgid "Luxembourgish" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:308 | ||||
| #: paperless/settings.py:348 | ||||
| msgid "Dutch" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:309 | ||||
| #: paperless/settings.py:349 | ||||
| msgid "Polish" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:310 | ||||
| #: paperless/settings.py:350 | ||||
| msgid "Portuguese (Brazil)" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:311 | ||||
| #: paperless/settings.py:351 | ||||
| msgid "Portuguese" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:312 | ||||
| #: paperless/settings.py:352 | ||||
| msgid "Romanian" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:313 | ||||
| #: paperless/settings.py:353 | ||||
| msgid "Russian" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:314 | ||||
| #: paperless/settings.py:354 | ||||
| msgid "Slovenian" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:355 | ||||
| msgid "Serbian" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:356 | ||||
| msgid "Swedish" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/urls.py:139 | ||||
| #: paperless/settings.py:357 | ||||
| msgid "Turkish" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/settings.py:358 | ||||
| msgid "Chinese Simplified" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/urls.py:153 | ||||
| msgid "Paperless-ngx administration" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -527,208 +571,210 @@ msgid "" | ||||
| "process all matching rules that you have defined." | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/apps.py:9 | ||||
| #: paperless_mail/apps.py:8 | ||||
| msgid "Paperless mail" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:10 | ||||
| #: paperless_mail/models.py:8 | ||||
| msgid "mail account" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:11 | ||||
| #: paperless_mail/models.py:9 | ||||
| msgid "mail accounts" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:18 | ||||
| #: paperless_mail/models.py:12 | ||||
| msgid "No encryption" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:19 | ||||
| #: paperless_mail/models.py:13 | ||||
| msgid "Use SSL" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:20 | ||||
| #: paperless_mail/models.py:14 | ||||
| msgid "Use STARTTLS" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:25 | ||||
| #: paperless_mail/models.py:18 | ||||
| msgid "IMAP server" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:28 | ||||
| #: paperless_mail/models.py:21 | ||||
| msgid "IMAP port" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:32 | ||||
| #: paperless_mail/models.py:25 | ||||
| msgid "" | ||||
| "This is usually 143 for unencrypted and STARTTLS connections, and 993 for " | ||||
| "SSL connections." | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:38 | ||||
| #: paperless_mail/models.py:31 | ||||
| msgid "IMAP security" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:41 | ||||
| #: paperless_mail/models.py:36 | ||||
| msgid "username" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:43 | ||||
| #: paperless_mail/models.py:38 | ||||
| msgid "password" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:46 | ||||
| #: paperless_mail/models.py:41 | ||||
| msgid "character set" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:50 | ||||
| #: paperless_mail/models.py:45 | ||||
| msgid "" | ||||
| "The character set to use when communicating with the mail server, such as " | ||||
| "'UTF-8' or 'US-ASCII'." | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:61 | ||||
| #: paperless_mail/models.py:56 | ||||
| msgid "mail rule" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:62 | ||||
| #: paperless_mail/models.py:57 | ||||
| msgid "mail rules" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:68 | ||||
| #: paperless_mail/models.py:60 | ||||
| msgid "Only process attachments." | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:71 | ||||
| #: paperless_mail/models.py:61 | ||||
| msgid "Process all files, including 'inline' attachments." | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:81 | ||||
| msgid "Mark as read, don't process read mails" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:82 | ||||
| msgid "Flag the mail, don't process flagged mails" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:83 | ||||
| msgid "Move to specified folder" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:84 | ||||
| #: paperless_mail/models.py:64 | ||||
| msgid "Delete" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:91 | ||||
| #: paperless_mail/models.py:65 | ||||
| msgid "Move to specified folder" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:66 | ||||
| msgid "Mark as read, don't process read mails" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:67 | ||||
| msgid "Flag the mail, don't process flagged mails" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:70 | ||||
| msgid "Use subject as title" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:92 | ||||
| #: paperless_mail/models.py:71 | ||||
| msgid "Use attachment filename as title" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:101 | ||||
| #: paperless_mail/models.py:74 | ||||
| msgid "Do not assign a correspondent" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:102 | ||||
| #: paperless_mail/models.py:75 | ||||
| msgid "Use mail address" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:103 | ||||
| #: paperless_mail/models.py:76 | ||||
| msgid "Use name (or mail address if not available)" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:104 | ||||
| #: paperless_mail/models.py:77 | ||||
| msgid "Use correspondent selected below" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:109 | ||||
| #: paperless_mail/models.py:81 | ||||
| msgid "order" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:115 | ||||
| #: paperless_mail/models.py:87 | ||||
| msgid "account" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:119 | ||||
| #: paperless_mail/models.py:91 | ||||
| msgid "folder" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:122 | ||||
| msgid "Subfolders must be separated by dots." | ||||
| #: paperless_mail/models.py:95 | ||||
| msgid "" | ||||
| "Subfolders must be separated by a delimiter, often a dot ('.') or slash " | ||||
| "('/'), but it varies by mail server." | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:126 | ||||
| #: paperless_mail/models.py:101 | ||||
| msgid "filter from" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:129 | ||||
| #: paperless_mail/models.py:107 | ||||
| msgid "filter subject" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:132 | ||||
| #: paperless_mail/models.py:113 | ||||
| msgid "filter body" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:136 | ||||
| #: paperless_mail/models.py:120 | ||||
| msgid "filter attachment filename" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:141 | ||||
| #: paperless_mail/models.py:125 | ||||
| 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:148 | ||||
| #: paperless_mail/models.py:132 | ||||
| msgid "maximum age" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:148 | ||||
| #: paperless_mail/models.py:134 | ||||
| msgid "Specified in days." | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:152 | ||||
| #: paperless_mail/models.py:138 | ||||
| msgid "attachment type" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:156 | ||||
| #: paperless_mail/models.py:142 | ||||
| msgid "" | ||||
| "Inline attachments include embedded images, so it's best to combine this " | ||||
| "option with a filename filter." | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:162 | ||||
| #: paperless_mail/models.py:148 | ||||
| msgid "action" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:168 | ||||
| #: paperless_mail/models.py:154 | ||||
| msgid "action parameter" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:173 | ||||
| #: paperless_mail/models.py:159 | ||||
| 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:181 | ||||
| #: paperless_mail/models.py:167 | ||||
| msgid "assign title from" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:189 | ||||
| #: paperless_mail/models.py:175 | ||||
| msgid "assign this tag" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:197 | ||||
| #: paperless_mail/models.py:183 | ||||
| msgid "assign this document type" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:201 | ||||
| #: paperless_mail/models.py:187 | ||||
| msgid "assign correspondent from" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless_mail/models.py:211 | ||||
| #: paperless_mail/models.py:197 | ||||
| msgid "assign this correspondent" | ||||
| msgstr "" | ||||
|   | ||||
| @@ -3,7 +3,7 @@ msgstr "" | ||||
| "Project-Id-Version: paperless-ngx\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2022-03-02 11:20-0800\n" | ||||
| "PO-Revision-Date: 2022-04-12 15:26\n" | ||||
| "PO-Revision-Date: 2022-05-13 03:55\n" | ||||
| "Last-Translator: \n" | ||||
| "Language-Team: Polish\n" | ||||
| "Language: pl_PL\n" | ||||
| @@ -638,7 +638,7 @@ msgstr "konto" | ||||
|  | ||||
| #: paperless_mail/models.py:119 | ||||
| msgid "folder" | ||||
| msgstr "folder" | ||||
| msgstr "katalog" | ||||
|  | ||||
| #: paperless_mail/models.py:122 | ||||
| msgid "Subfolders must be separated by dots." | ||||
|   | ||||
| @@ -3,7 +3,7 @@ msgstr "" | ||||
| "Project-Id-Version: paperless-ngx\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2022-03-02 11:20-0800\n" | ||||
| "PO-Revision-Date: 2022-03-27 17:08\n" | ||||
| "PO-Revision-Date: 2022-05-13 03:55\n" | ||||
| "Last-Translator: \n" | ||||
| "Language-Team: Serbian (Latin)\n" | ||||
| "Language: sr_CS\n" | ||||
| @@ -356,11 +356,11 @@ msgstr "vrednost" | ||||
|  | ||||
| #: documents/models.py:368 | ||||
| msgid "filter rule" | ||||
| msgstr "" | ||||
| msgstr "filter pravilo" | ||||
|  | ||||
| #: documents/models.py:369 | ||||
| msgid "filter rules" | ||||
| msgstr "" | ||||
| msgstr "filter pravila" | ||||
|  | ||||
| #: documents/serialisers.py:64 | ||||
| #, python-format | ||||
| @@ -369,7 +369,7 @@ msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:185 | ||||
| msgid "Invalid color." | ||||
| msgstr "" | ||||
| msgstr "Nevažeća boja." | ||||
|  | ||||
| #: documents/serialisers.py:459 | ||||
| #, python-format | ||||
| @@ -378,7 +378,7 @@ msgstr "" | ||||
|  | ||||
| #: documents/templates/index.html:22 | ||||
| msgid "Paperless-ngx is loading..." | ||||
| msgstr "" | ||||
| msgstr "Paperless-ngx se učitava..." | ||||
|  | ||||
| #: documents/templates/registration/logged_out.html:14 | ||||
| msgid "Paperless-ngx signed out" | ||||
|   | ||||
| @@ -27,7 +27,7 @@ class AngularApiAuthenticationOverride(authentication.BaseAuthentication): | ||||
|             and request.headers["Referer"].startswith("http://localhost:4200/") | ||||
|         ): | ||||
|             user = User.objects.filter(is_staff=True).first() | ||||
|             print("Auto-Login with user {}".format(user)) | ||||
|             print(f"Auto-Login with user {user}") | ||||
|             return (user, None) | ||||
|         else: | ||||
|             return None | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import datetime | ||||
| import json | ||||
| import math | ||||
| import multiprocessing | ||||
| import os | ||||
| import re | ||||
| from typing import Final | ||||
| from typing import Set | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| from concurrent_log_handler.queue import setup_logging_queues | ||||
| @@ -46,6 +48,13 @@ def __get_int(key: str, default: int) -> int: | ||||
|     return int(os.getenv(key, default)) | ||||
|  | ||||
|  | ||||
| def __get_float(key: str, default: float) -> float: | ||||
|     """ | ||||
|     Return an integer value based on the environment variable or a default | ||||
|     """ | ||||
|     return float(os.getenv(key, default)) | ||||
|  | ||||
|  | ||||
| # NEVER RUN WITH DEBUG IN PRODUCTION. | ||||
| DEBUG = __get_boolean("PAPERLESS_DEBUG", "NO") | ||||
|  | ||||
| @@ -483,6 +492,11 @@ CONSUMER_POLLING_RETRY_COUNT = int( | ||||
|     os.getenv("PAPERLESS_CONSUMER_POLLING_RETRY_COUNT", 5), | ||||
| ) | ||||
|  | ||||
| CONSUMER_INOTIFY_DELAY: Final[float] = __get_float( | ||||
|     "PAPERLESS_CONSUMER_INOTIFY_DELAY", | ||||
|     0.5, | ||||
| ) | ||||
|  | ||||
| CONSUMER_DELETE_DUPLICATES = __get_boolean("PAPERLESS_CONSUMER_DELETE_DUPLICATES") | ||||
|  | ||||
| CONSUMER_RECURSIVE = __get_boolean("PAPERLESS_CONSUMER_RECURSIVE") | ||||
| @@ -583,15 +597,22 @@ FILENAME_PARSE_TRANSFORMS = [] | ||||
| for t in json.loads(os.getenv("PAPERLESS_FILENAME_PARSE_TRANSFORMS", "[]")): | ||||
|     FILENAME_PARSE_TRANSFORMS.append((re.compile(t["pattern"]), t["repl"])) | ||||
|  | ||||
| # TODO: this should not have a prefix. | ||||
| # Specify the filename format for out files | ||||
| PAPERLESS_FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT") | ||||
| FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT") | ||||
|  | ||||
| # If this is enabled, variables in filename format will resolve to empty-string instead of 'none'. | ||||
| # Directories with 'empty names' are omitted, too. | ||||
| FILENAME_FORMAT_REMOVE_NONE = __get_boolean( | ||||
|     "PAPERLESS_FILENAME_FORMAT_REMOVE_NONE", | ||||
|     "NO", | ||||
| ) | ||||
|  | ||||
| THUMBNAIL_FONT_NAME = os.getenv( | ||||
|     "PAPERLESS_THUMBNAIL_FONT_NAME", | ||||
|     "/usr/share/fonts/liberation/LiberationSerif-Regular.ttf", | ||||
| ) | ||||
|  | ||||
| # TODO: this should not have a prefix. | ||||
| # Tika settings | ||||
| PAPERLESS_TIKA_ENABLED = __get_boolean("PAPERLESS_TIKA_ENABLED", "NO") | ||||
| PAPERLESS_TIKA_ENDPOINT = os.getenv("PAPERLESS_TIKA_ENDPOINT", "http://localhost:9998") | ||||
| @@ -603,16 +624,42 @@ PAPERLESS_TIKA_GOTENBERG_ENDPOINT = os.getenv( | ||||
| if PAPERLESS_TIKA_ENABLED: | ||||
|     INSTALLED_APPS.append("paperless_tika.apps.PaperlessTikaConfig") | ||||
|  | ||||
| # List dates that should be ignored when trying to parse date from document text | ||||
| IGNORE_DATES = set() | ||||
|  | ||||
| if os.getenv("PAPERLESS_IGNORE_DATES", ""): | ||||
| def _parse_ignore_dates( | ||||
|     env_ignore: str, | ||||
|     date_order: str = DATE_ORDER, | ||||
| ) -> Set[datetime.datetime]: | ||||
|     """ | ||||
|     If the PAPERLESS_IGNORE_DATES environment variable is set, parse the | ||||
|     user provided string(s) into dates | ||||
|  | ||||
|     Args: | ||||
|         env_ignore (str): The value of the environment variable, comma seperated dates | ||||
|         date_order (str, optional): The format of the date strings. Defaults to DATE_ORDER. | ||||
|  | ||||
|     Returns: | ||||
|         Set[datetime.datetime]: The set of parsed date objects | ||||
|     """ | ||||
|     import dateparser | ||||
|  | ||||
|     for s in os.getenv("PAPERLESS_IGNORE_DATES", "").split(","): | ||||
|         d = dateparser.parse(s) | ||||
|     ignored_dates = set() | ||||
|     for s in env_ignore.split(","): | ||||
|         d = dateparser.parse( | ||||
|             s, | ||||
|             settings={ | ||||
|                 "DATE_ORDER": date_order, | ||||
|             }, | ||||
|         ) | ||||
|         if d: | ||||
|             IGNORE_DATES.add(d.date()) | ||||
|             ignored_dates.add(d.date()) | ||||
|     return ignored_dates | ||||
|  | ||||
|  | ||||
| # List dates that should be ignored when trying to parse date from document text | ||||
| IGNORE_DATES: Set[datetime.date] = set() | ||||
|  | ||||
| if os.getenv("PAPERLESS_IGNORE_DATES") is not None: | ||||
|     IGNORE_DATES = _parse_ignore_dates(os.getenv("PAPERLESS_IGNORE_DATES")) | ||||
|  | ||||
| ENABLE_UPDATE_CHECK = os.getenv("PAPERLESS_ENABLE_UPDATE_CHECK", "default") | ||||
| if ENABLE_UPDATE_CHECK != "default": | ||||
|   | ||||
							
								
								
									
										58
									
								
								src/paperless/tests/test_settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/paperless/tests/test_settings.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| import datetime | ||||
| from unittest import TestCase | ||||
|  | ||||
| from paperless.settings import _parse_ignore_dates | ||||
|  | ||||
|  | ||||
| class TestIgnoreDateParsing(TestCase): | ||||
|     """ | ||||
|     Tests the parsing of the PAPERLESS_IGNORE_DATES setting value | ||||
|     """ | ||||
|  | ||||
|     def _parse_checker(self, test_cases): | ||||
|         """ | ||||
|         Helper function to check ignore date parsing | ||||
|  | ||||
|         Args: | ||||
|             test_cases (_type_): _description_ | ||||
|         """ | ||||
|         for env_str, date_format, expected_date_set in test_cases: | ||||
|  | ||||
|             self.assertSetEqual( | ||||
|                 _parse_ignore_dates(env_str, date_format), | ||||
|                 expected_date_set, | ||||
|             ) | ||||
|  | ||||
|     def test_no_ignore_dates_set(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - No ignore dates are set | ||||
|         THEN: | ||||
|             - No ignore dates are parsed | ||||
|         """ | ||||
|         self.assertSetEqual(_parse_ignore_dates(""), set()) | ||||
|  | ||||
|     def test_single_ignore_dates_set(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Ignore dates are set per certain inputs | ||||
|         THEN: | ||||
|             - All ignore dates are parsed | ||||
|         """ | ||||
|         test_cases = [ | ||||
|             ("1985-05-01", "YMD", {datetime.date(1985, 5, 1)}), | ||||
|             ( | ||||
|                 "1985-05-01,1991-12-05", | ||||
|                 "YMD", | ||||
|                 {datetime.date(1985, 5, 1), datetime.date(1991, 12, 5)}, | ||||
|             ), | ||||
|             ("2010-12-13", "YMD", {datetime.date(2010, 12, 13)}), | ||||
|             ("11.01.10", "DMY", {datetime.date(2010, 1, 11)}), | ||||
|             ( | ||||
|                 "11.01.2001,15-06-1996", | ||||
|                 "DMY", | ||||
|                 {datetime.date(2001, 1, 11), datetime.date(1996, 6, 15)}, | ||||
|             ), | ||||
|         ] | ||||
|  | ||||
|         self._parse_checker(test_cases) | ||||
| @@ -19,7 +19,9 @@ from documents.views import SavedViewViewSet | ||||
| from documents.views import SearchAutoCompleteView | ||||
| from documents.views import SelectionDataView | ||||
| from documents.views import StatisticsView | ||||
| from documents.views import StoragePathViewSet | ||||
| from documents.views import TagViewSet | ||||
| from documents.views import UiSettingsView | ||||
| from documents.views import UnifiedSearchViewSet | ||||
| from paperless.consumers import StatusConsumer | ||||
| from paperless.views import FaviconView | ||||
| @@ -33,6 +35,7 @@ api_router.register(r"documents", UnifiedSearchViewSet) | ||||
| api_router.register(r"logs", LogViewSet, basename="logs") | ||||
| api_router.register(r"tags", TagViewSet) | ||||
| api_router.register(r"saved_views", SavedViewViewSet) | ||||
| api_router.register(r"storage_paths", StoragePathViewSet) | ||||
|  | ||||
|  | ||||
| urlpatterns = [ | ||||
| @@ -78,6 +81,11 @@ urlpatterns = [ | ||||
|                     RemoteVersionView.as_view(), | ||||
|                     name="remoteversion", | ||||
|                 ), | ||||
|                 re_path( | ||||
|                     r"^ui_settings/", | ||||
|                     UiSettingsView.as_view(), | ||||
|                     name="ui_settings", | ||||
|                 ), | ||||
|                 path("token/", views.obtain_auth_token), | ||||
|             ] | ||||
|             + api_router.urls, | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| from typing import Final | ||||
| from typing import Tuple | ||||
|  | ||||
| __version__: Final[Tuple[int, int, int]] = (1, 7, 0) | ||||
| __version__: Final[Tuple[int, int, int]] = (1, 7, 1) | ||||
| # Version string like X.Y.Z | ||||
| __full_version_str__: Final[str] = ".".join(map(str, __version__)) | ||||
| # Version string like X.Y | ||||
|   | ||||
| @@ -28,7 +28,7 @@ from paperless_mail.models import MailRule | ||||
|  | ||||
|  | ||||
| @dataclasses.dataclass | ||||
| class _AttachmentDef(object): | ||||
| class _AttachmentDef: | ||||
|     filename: str = "a_file.pdf" | ||||
|     maintype: str = "application/pdf" | ||||
|     subtype: str = "pdf" | ||||
| @@ -45,7 +45,7 @@ class BogusFolderManager: | ||||
|         self.current_folder = new_folder | ||||
|  | ||||
|  | ||||
| class BogusClient(object): | ||||
| class BogusClient: | ||||
|     def authenticate(self, mechanism, authobject): | ||||
|         # authobject must be a callable object | ||||
|         auth_bytes = authobject(None) | ||||
| @@ -205,7 +205,7 @@ class TestMail(DirectoriesMixin, TestCase): | ||||
|         self.reset_bogus_mailbox() | ||||
|  | ||||
|         self.mail_account_handler = MailAccountHandler() | ||||
|         super(TestMail, self).setUp() | ||||
|         super().setUp() | ||||
|  | ||||
|     def reset_bogus_mailbox(self): | ||||
|         self.bogus_mailbox.messages = [] | ||||
| @@ -473,7 +473,7 @@ class TestMail(DirectoriesMixin, TestCase): | ||||
|  | ||||
|             self.assertEqual(result, len(matches), f"Error with pattern: {pattern}") | ||||
|             filenames = sorted( | ||||
|                 [a[1]["override_filename"] for a in self.async_task.call_args_list], | ||||
|                 a[1]["override_filename"] for a in self.async_task.call_args_list | ||||
|             ) | ||||
|             self.assertListEqual(filenames, matches) | ||||
|  | ||||
|   | ||||
| @@ -98,7 +98,7 @@ class RasterisedDocumentParser(DocumentParser): | ||||
|  | ||||
|     def extract_text(self, sidecar_file, pdf_file): | ||||
|         if sidecar_file and os.path.isfile(sidecar_file): | ||||
|             with open(sidecar_file, "r") as f: | ||||
|             with open(sidecar_file) as f: | ||||
|                 text = f.read() | ||||
|  | ||||
|             if "[OCR skipped on page" not in text: | ||||
|   | ||||
| @@ -18,7 +18,7 @@ class TextDocumentParser(DocumentParser): | ||||
|  | ||||
|     def get_thumbnail(self, document_path, mime_type, file_name=None): | ||||
|         def read_text(): | ||||
|             with open(document_path, "r") as src: | ||||
|             with open(document_path) as src: | ||||
|                 lines = [line.strip() for line in src.readlines()] | ||||
|                 text = "\n".join(lines[:50]) | ||||
|                 return text | ||||
| @@ -38,5 +38,5 @@ class TextDocumentParser(DocumentParser): | ||||
|         return out_path | ||||
|  | ||||
|     def parse(self, document_path, mime_type, file_name=None): | ||||
|         with open(document_path, "r") as f: | ||||
|         with open(document_path) as f: | ||||
|             self.text = f.read() | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 phail
					phail