diff --git a/docker-compose.env.example b/docker-compose.env.example index 3c1664573..51332437d 100644 --- a/docker-compose.env.example +++ b/docker-compose.env.example @@ -1,38 +1,22 @@ # Environment variables to set for Paperless -# Commented out variables will be replaced by a default within Paperless. - -# Passphrase Paperless uses to encrypt and decrypt your documents, if you want -# encryption at all. -# PAPERLESS_PASSPHRASE=CHANGE_ME - -# The amount of threads to use for text recognition -# PAPERLESS_OCR_THREADS=4 - -# Additional languages to install for text recognition +# Commented out variables will be replaced with a default within Paperless. +# +# In addition to what you see here, you can also define any values you find in +# paperless.conf.example here. Values like: +# +# * PAPERLESS_PASSPHRASE +# * PAPERLESS_CONSUMPTION_DIR +# * PAPERLESS_CONSUME_MAIL_HOST +# +# ...are all explained in that file but can be defined here, since the Docker +# installation doesn't make use of paperless.conf. + + +# Additional languages to install for text recognition. Note that this is +# different from PAPERLESS_OCR_LANGUAGE (default=eng), which defines the +# default language used when guessing the language from the OCR output. # PAPERLESS_OCR_LANGUAGES=deu ita # You can change the default user and group id to a custom one # USERMAP_UID=1000 # USERMAP_GID=1000 - -############################################################################### -#### Mail Consumption #### -############################################################################### - -# These values are required if you want paperless to check a particular email -# box every 10 minutes and attempt to consume documents from there. If you -# don't define a HOST, mail checking will just be disabled. -# Don't use quotes after = or it will crash your docker -# PAPERLESS_CONSUME_MAIL_HOST= -# PAPERLESS_CONSUME_MAIL_PORT= -# PAPERLESS_CONSUME_MAIL_USER= -# PAPERLESS_CONSUME_MAIL_PASS= - -# Override the default IMAP inbox here. If it's not set, Paperless defaults to -# INBOX. -# PAPERLESS_CONSUME_MAIL_INBOX=INBOX - -# Any email sent to the target account that does not contain this text will be -# ignored. Mail checking won't work without this. -# PAPERLESS_EMAIL_SECRET= - diff --git a/docs/changelog.rst b/docs/changelog.rst index 804447855..6ce2e49a4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,27 @@ Changelog ######### +2.4.0 +===== + +* A new set of actions are now available thanks to `jonaswinkler`_'s very first + pull request! You can now do nifty things like tag documents in bulk, or set + correspondents in bulk. `#405`_ +* The import/export system is now a little smarter. By default, documents are + tagged as ``unencrypted``, since exports are by their nature unencrypted. + It's now in the import step that we decide the storage type. This allows you + to export from an encrypted system and import into an unencrypted one, or + vice-versa. +* The migration history has been slightly modified to accomodate PostgreSQL + users. Additionally, you can now tell paperless to use PostgreSQL simply by + declaring ``PAPERLESS_DBUSER`` in your environment. This will attempt to + connect to your Postgres database without a password unless you also set + ``PAPERLESS_DBPASS``. +* A bug was found in the REST API filter system that was the result of an + update of django-filter some time ago. This has now been patched `#412`_. + Thanks to `thepill`_ for spotting it! + + 2.3.0 ===== @@ -15,7 +36,8 @@ Changelog * As his last bit of effort on this release, Joshua also added some code to allow you to view the documents inline rather than download them as an attachment. `#400`_ -* Finally, `ahyear`_ found a slip in the Docker documentation and patched it. `#401`_ +* Finally, `ahyear`_ found a slip in the Docker documentation and patched it. + `#401`_ 2.2.1 @@ -32,14 +54,14 @@ Changelog version of Paperless that supports Django 2.0! As a result of their hard work, you can now also run Paperless on Python 3.7 as well: `#386`_ & `#390`_. -* `Stéphane Brunner`_ added a few lines of code that made tagging interface a lot - easier on those of us with lots of different tags: `#391`_. +* `Stéphane Brunner`_ added a few lines of code that made tagging interface a + lot easier on those of us with lots of different tags: `#391`_. * `Kilian Koeltzsch`_ noticed a bug in how we capture & automatically create tags, so that's fixed now too: `#384`_. * `erikarvstedt`_ tweaked the behaviour of the test suite to be better behaved for packaging environments: `#383`_. -* `Lukasz Soluch`_ added CORS support to make building a new Javascript-based front-end - cleaner & easier: `#387`_. +* `Lukasz Soluch`_ added CORS support to make building a new Javascript-based + front-end cleaner & easier: `#387`_. 2.1.0 @@ -499,8 +521,10 @@ bulk of the work on this big change. .. _Kilian Koeltzsch: https://github.com/kiliankoe .. _Lukasz Soluch: https://github.com/LukaszSolo .. _Joshua Taillon: https://github.com/jat255 -.. _dubit0: https://github.com/dubit0 -.. _ahyear: https://github.com/ahyear +.. _dubit0: https://github.com/dubit0 +.. _ahyear: https://github.com/ahyear +.. _jonaswinkler: https://github.com/jonaswinkler +.. _thepill: https://github.com/thepill .. _#20: https://github.com/danielquinn/paperless/issues/20 .. _#44: https://github.com/danielquinn/paperless/issues/44 @@ -587,6 +611,8 @@ bulk of the work on this big change. .. _#399: https://github.com/danielquinn/paperless/pull/399 .. _#400: https://github.com/danielquinn/paperless/pull/400 .. _#401: https://github.com/danielquinn/paperless/pull/401 +.. _#405: https://github.com/danielquinn/paperless/pull/405 +.. _#412: https://github.com/danielquinn/paperless/issues/412 .. _pipenv: https://docs.pipenv.org/ .. _a new home on Docker Hub: https://hub.docker.com/r/danielquinn/paperless/ diff --git a/docs/consumption.rst b/docs/consumption.rst index bf62ed0a2..15f6c6393 100644 --- a/docs/consumption.rst +++ b/docs/consumption.rst @@ -76,6 +76,31 @@ Pre-consumption script * Document file name +A simple but common example for this would be creating a simple script like +this: + +``/usr/local/bin/ocr-pdf`` + +.. code:: bash + + #!/usr/bin/env bash + pdf2pdfocr.py -i ${1} + +``/etc/paperless.conf`` + +.. code:: bash + + ... + PAPERLESS_PRE_CONSUME_SCRIPT="/usr/local/bin/ocr-pdf" + ... + +This will pass the path to the document about to be consumed to ``/usr/local/bin/ocr-pdf``, +which will in turn call `pdf2pdfocr.py`_ on your document, which will then +overwrite the file with an OCR'd version of the file and exit. At which point, +the consumption process will begin with the newly modified file. + +.. _pdf2pdfocr.py: https://github.com/LeoFCardoso/pdf2pdfocr + .. _consumption-director-hook-variables-post: diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 000000000..4678ff3aa --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,141 @@ +.. _contributing: + +Contributing to Paperless +######################### + +Maybe you've been using Paperless for a while and want to add a feature or two, +or maybe you've come across a bug that you have some ideas how to solve. The +beauty of Free software is that you can see what's wrong and help to get it +fixed for everyone! + + +How to Get Your Changes Rolled Into Paperless +============================================= + +If you've found a bug, but don't know how to fix it, you can always post an +issue on `GitHub`_ in the hopes that someone will have the time to fix it for +you. If however you're the one with the time, pull requests are always +welcome, you just have to make sure that your code conforms to a few standards: + +Pep8 +---- + +It's the standard for all Python development, so it's `very well documented`_. +The short version is: + +* Lines should wrap at 79 characters +* Use ``snake_case`` for variables, ``CamelCase`` for classes, and ``ALL_CAPS`` + for constants. +* Space out your operators: ``stuff + 7`` instead of ``stuff+7`` +* Two empty lines between classes, and functions, but 1 empty line between + class methods. + +There's more to it than that, but if you follow those, you'll probably be +alright. When you submit your pull request, there's a pep8 checker that'll +look at your code to see if anything is off. If it finds anything, it'll +complain at you until you fix it. + + +Additional Style Guides +----------------------- + +Where pep8 is ambiguous, I've tried to be a little more specific. These rules +aren't hard-and-fast, but if you can conform to them, I'll appreciate it and +spend less time trying to conform your PR before merging: + + +Function calls +.............. + +If you're calling a function and that necessitates more than one line of code, +please format it like this: + +.. code:: python + + my_function( + argument1, + kwarg1="x", + kwarg2="y" + another_really_long_kwarg="some big value" + a_kwarg_calling_another_long_function=another_function( + another_arg, + another_kwarg="kwarg!" + ) + ) + +This is all in the interest of code uniformity rather than anything else. If +we stick to a style, everything is understandable in the same way. + + +Quoting Strings +............... + +pep8 is a little too open-minded on this for my liking. Python strings should +be quoted with double quotes (``"``) except in cases where the resulting string +would require too much escaping of a double quote, in which case, a single +quoted, or triple-quoted string will do: + +.. code:: python + + my_string = "This is my string" + problematic_string = 'This is a "string" with "quotes" in it' + +In HTML templates, please use double-quotes for tag attributes, and single +quotes for arguments passed to Django tempalte tags: + +.. code:: html + +
+ link this +
+ +This is to keep linters happy they look at an HTML file and see an attribute +closing the ``"`` before it should have been. + +-- + +That's all there is in terms of guidelines, so I hope it's not too daunting. + + +Indentation & Spacing +..................... + +When it comes to indentation: + +* For Python, the rule is: follow pep8 and use 4 spaces. +* For Javascript, CSS, and HTML, please use 1 tab. + +Additionally, Django templates making use of block elements like ``{% if %}``, +``{% for %}``, and ``{% block %}`` etc. should be indented: + +Good: + +.. code:: html + + {% block stuff %} +

This is the stuff

+ {% endblock %} + +Bad: + +.. code:: html + + {% block stuff %} +

This is the stuff

+ {% endblock %} + + +The Code of Conduct +=================== + +Paperless has a `code of conduct`_. It's a lot like the other ones you see out +there, with a few small changes, but basically it boils down to: + +> Don't be an ass, or you might get banned. + +I'm proud to say that the CoC has never had to be enforced because everyone has +been awesome, friendly, and professional. + +.. _GitHub: https://github.com/danielquinn/paperless/issues +.. _very well documented: https://www.python.org/dev/peps/pep-0008/ +.. _code of conduct: https://github.com/danielquinn/paperless/blob/master/CODE_OF_CONDUCT.md diff --git a/docs/index.rst b/docs/index.rst index 7710a330c..fd9d57d4e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -43,5 +43,6 @@ Contents customising extending troubleshooting + contributing scanners changelog diff --git a/requirements.txt b/requirements.txt index 89c7e296f..84ec2e68b 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -i https://pypi.python.org/simple -apipkg==1.5; python_version != '3.1.*' -atomicwrites==1.2.1; python_version != '3.1.*' +apipkg==1.5; python_version != '3.3.*' +atomicwrites==1.2.1; python_version != '3.3.*' attrs==18.2.0 certifi==2018.8.24 chardet==3.0.4 -coverage==4.5.1; python_version != '3.1.*' +coverage==4.5.1; python_version < '4' coveralls==1.5.0 dateparser==0.7.0 django-cors-headers==2.4.0 @@ -14,9 +14,9 @@ django-filter==2.0.0 django==2.0.8 djangorestframework==3.8.2 docopt==0.6.2 -execnet==1.5.0; python_version != '3.1.*' +execnet==1.5.0; python_version != '3.3.*' factory-boy==2.11.1 -faker==0.9.0 +faker==0.9.0; python_version >= '2.7' filemagic==1.6 fuzzywuzzy==0.15.0 gunicorn==19.9.0 @@ -27,17 +27,17 @@ more-itertools==4.3.0 numpy==1.15.1 pdftotext==2.1.0 pillow==5.2.0 -pluggy==0.7.1; python_version != '3.1.*' -py==1.6.0; python_version != '3.1.*' +pluggy==0.7.1; python_version != '3.3.*' +py==1.6.0; python_version != '3.3.*' pycodestyle==2.4.0 pyocr==0.5.3 -pytest-cov==2.5.1 +pytest-cov==2.6.0 pytest-django==3.4.2 pytest-env==0.6.2 -pytest-forked==0.2 +pytest-forked==0.2; python_version != '3.3.*' pytest-sugar==0.9.1 pytest-xdist==1.23.0 -pytest==3.7.4 +pytest==3.8.0 python-dateutil==2.7.3 python-dotenv==0.9.1 python-gnupg==0.4.3 @@ -51,4 +51,4 @@ scipy==1.1.0 termcolor==1.1.0 text-unidecode==1.2 tzlocal==1.5.1 -urllib3==1.23; python_version != '3.0.*' +urllib3==1.23; python_version != '3.3.*' diff --git a/src/documents/actions.py b/src/documents/actions.py old mode 100644 new mode 100755 index 0b68d6c89..96ce893aa --- a/src/documents/actions.py +++ b/src/documents/actions.py @@ -5,10 +5,13 @@ from django.template.response import TemplateResponse from documents.classifier import DocumentClassifier -from documents.models import Tag, Correspondent, DocumentType +from documents.models import Correspondent, DocumentType, Tag -def select_action(modeladmin, request, queryset, title, action, modelclass, success_message="", document_action=None, queryset_action=None): +def select_action( + modeladmin, request, queryset, title, action, modelclass, + success_message="", document_action=None, queryset_action=None): + opts = modeladmin.model._meta app_label = opts.app_label @@ -28,7 +31,9 @@ def select_action(modeladmin, request, queryset, title, action, modelclass, succ queryset_action(queryset, selected_object) modeladmin.message_user(request, success_message % { - "selected_object": selected_object.name, "count": n, "items": model_ngettext(modeladmin.opts, n) + "selected_object": selected_object.name, + "count": n, + "items": model_ngettext(modeladmin.opts, n) }, messages.SUCCESS) # Return None to display the change list page again. @@ -48,10 +53,17 @@ def select_action(modeladmin, request, queryset, title, action, modelclass, succ request.current_app = modeladmin.admin_site.name - return TemplateResponse(request, "admin/%s/%s/select_object.html" % (app_label, opts.model_name), context) + return TemplateResponse( + request, + "admin/{}/{}/select_object.html".format(app_label, opts.model_name), + context + ) + +def simple_action( + modeladmin, request, queryset, success_message="", + document_action=None, queryset_action=None): -def simple_action(modeladmin, request, queryset, success_message="", document_action=None, queryset_action=None): if not modeladmin.has_change_permission(request): raise PermissionDenied @@ -73,40 +85,57 @@ def simple_action(modeladmin, request, queryset, success_message="", document_ac def add_tag_to_selected(modeladmin, request, queryset): - return select_action(modeladmin=modeladmin, request=request, queryset=queryset, - title="Add tag to multiple documents", - action="add_tag_to_selected", - modelclass=Tag, - success_message="Successfully added tag %(selected_object)s to %(count)d %(items)s.", - document_action=lambda doc, tag: doc.tags.add(tag)) -add_tag_to_selected.short_description = "Add tag to selected documents" + return select_action( + modeladmin=modeladmin, + request=request, + queryset=queryset, + title="Add tag to multiple documents", + action="add_tag_to_selected", + modelclass=Tag, + success_message="Successfully added tag %(selected_object)s to " + "%(count)d %(items)s.", + document_action=lambda doc, tag: doc.tags.add(tag) + ) def remove_tag_from_selected(modeladmin, request, queryset): - return select_action(modeladmin=modeladmin, request=request, queryset=queryset, - title="Remove tag from multiple documents", - action="remove_tag_from_selected", - modelclass=Tag, - success_message="Successfully removed tag %(selected_object)s from %(count)d %(items)s.", - document_action=lambda doc, tag: doc.tags.remove(tag)) -remove_tag_from_selected.short_description = "Remove tag from selected documents" + return select_action( + modeladmin=modeladmin, + request=request, + queryset=queryset, + title="Remove tag from multiple documents", + action="remove_tag_from_selected", + modelclass=Tag, + success_message="Successfully removed tag %(selected_object)s from " + "%(count)d %(items)s.", + document_action=lambda doc, tag: doc.tags.remove(tag) + ) def set_correspondent_on_selected(modeladmin, request, queryset): - return select_action(modeladmin=modeladmin, request=request, queryset=queryset, - title="Set correspondent on multiple documents", - action="set_correspondent_on_selected", - modelclass=Correspondent, - success_message="Successfully set correspondent %(selected_object)s on %(count)d %(items)s.", - queryset_action=lambda qs, correspondent: qs.update(correspondent=correspondent)) -set_correspondent_on_selected.short_description = "Set correspondent on selected documents" + + return select_action( + modeladmin=modeladmin, + request=request, + queryset=queryset, + title="Set correspondent on multiple documents", + action="set_correspondent_on_selected", + modelclass=Correspondent, + success_message="Successfully set correspondent %(selected_object)s " + "on %(count)d %(items)s.", + queryset_action=lambda qs, corr: qs.update(correspondent=corr) + ) def remove_correspondent_from_selected(modeladmin, request, queryset): - return simple_action(modeladmin=modeladmin, request=request, queryset=queryset, - success_message="Successfully removed correspondent from %(count)d %(items)s.", - queryset_action=lambda qs: qs.update(correspondent=None)) -remove_correspondent_from_selected.short_description = "Remove correspondent from selected documents" + return simple_action( + modeladmin=modeladmin, + request=request, + queryset=queryset, + success_message="Successfully removed correspondent from %(count)d " + "%(items)s.", + queryset_action=lambda qs: qs.update(correspondent=None) + ) def set_document_type_on_selected(modeladmin, request, queryset): @@ -116,14 +145,12 @@ def set_document_type_on_selected(modeladmin, request, queryset): modelclass=DocumentType, success_message="Successfully set document type %(selected_object)s on %(count)d %(items)s.", queryset_action=lambda qs, document_type: qs.update(document_type=document_type)) -set_document_type_on_selected.short_description = "Set document type on selected documents" def remove_document_type_from_selected(modeladmin, request, queryset): return simple_action(modeladmin=modeladmin, request=request, queryset=queryset, success_message="Successfully removed document type from %(count)d %(items)s.", queryset_action=lambda qs: qs.update(document_type=None)) -remove_document_type_from_selected.short_description = "Remove document type from selected documents" def run_document_classifier_on_selected(modeladmin, request, queryset): @@ -135,4 +162,16 @@ def run_document_classifier_on_selected(modeladmin, request, queryset): except FileNotFoundError: modeladmin.message_user(request, "Classifier model file not found.", messages.ERROR) return None + + +add_tag_to_selected.short_description = "Add tag to selected documents" +remove_tag_from_selected.short_description = \ + "Remove tag from selected documents" +set_correspondent_on_selected.short_description = \ + "Set correspondent on selected documents" +remove_correspondent_from_selected.short_description = \ + "Remove correspondent from selected documents" +set_document_type_on_selected.short_description = "Set document type on selected documents" +remove_document_type_from_selected.short_description = "Remove document type from selected documents" run_document_classifier_on_selected.short_description = "Run document classifier on selected" + diff --git a/src/documents/admin.py b/src/documents/admin.py old mode 100644 new mode 100755 index 7386ebef1..d739011d4 --- a/src/documents/admin.py +++ b/src/documents/admin.py @@ -3,22 +3,26 @@ from django.conf import settings from django.contrib import admin, messages from django.contrib.admin.templatetags.admin_urls import add_preserved_filters -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import Group, User +from django.db import models from django.http import HttpResponseRedirect -try: - from django.core.urlresolvers import reverse -except ImportError: - from django.urls import reverse from django.templatetags.static import static +from django.urls import reverse from django.utils.html import format_html, format_html_join from django.utils.http import urlquote from django.utils.safestring import mark_safe -from django.db import models -from documents.actions import add_tag_to_selected, remove_tag_from_selected, set_correspondent_on_selected, \ - remove_correspondent_from_selected, set_document_type_on_selected, remove_document_type_from_selected, \ +from documents.actions import ( + add_tag_to_selected, + remove_correspondent_from_selected, + remove_tag_from_selected, + set_correspondent_on_selected, + set_document_type_on_selected, + remove_document_type_from_selected, run_document_classifier_on_selected -from .models import Correspondent, Tag, Document, Log, DocumentType +) + +from .models import Correspondent, Document, DocumentType, Log, Tag class FinancialYearFilter(admin.SimpleListFilter): @@ -93,11 +97,18 @@ def __init__(self, *args, **kwargs): self.title = "correspondent (recent)" def field_choices(self, field, request, model_admin): + + years = settings.PAPERLESS_RECENT_CORRESPONDENT_YEARS + days = 365 * years + lookups = [] - if settings.PAPERLESS_RECENT_CORRESPONDENT_YEARS and settings.PAPERLESS_RECENT_CORRESPONDENT_YEARS > 0: - date_limit = datetime.now() - timedelta(days=365*settings.PAPERLESS_RECENT_CORRESPONDENT_YEARS) - for c in Correspondent.objects.filter(documents__created__gte=date_limit).distinct(): + if years and years > 0: + correspondents = Correspondent.objects.filter( + documents__created__gte=datetime.now() - timedelta(days=days) + ).distinct() + for c in correspondents: lookups.append((c.id, c.name)) + return lookups @@ -107,12 +118,20 @@ class CommonAdmin(admin.ModelAdmin): class CorrespondentAdmin(CommonAdmin): - list_display = ("name", "automatic_classification", "document_count", "last_correspondence") - list_editable = ("automatic_classification",) + list_display = ( + "name", + "automatic_classification", + "document_count", + "last_correspondence" + ) + list_editable = ("automatic_classification") def get_queryset(self, request): qs = super(CorrespondentAdmin, self).get_queryset(request) - qs = qs.annotate(document_count=models.Count("documents"), last_correspondence=models.Max("documents__created")) + qs = qs.annotate( + document_count=models.Count("documents"), + last_correspondence=models.Max("documents__created") + ) return qs def document_count(self, obj): @@ -160,24 +179,39 @@ class DocumentAdmin(CommonAdmin): class Media: css = { "all": ("paperless.css",) - } search_fields = ("correspondent__name", "title", "content", "tags__name") readonly_fields = ("added",) list_display = ("title", "created", "added", "thumbnail", "correspondent", "tags_", "archive_serial_number", "document_type") - list_filter = ("document_type", "tags", ('correspondent', RecentCorrespondentFilter), "correspondent", FinancialYearFilter) + list_filter = ( + "document_type", + "tags", + ("correspondent", RecentCorrespondentFilter), + "correspondent", + FinancialYearFilter + ) filter_horizontal = ("tags",) ordering = ["-created", "correspondent"] - actions = [add_tag_to_selected, remove_tag_from_selected, set_correspondent_on_selected, remove_correspondent_from_selected, set_document_type_on_selected, remove_document_type_from_selected, run_document_classifier_on_selected] + actions = [ + add_tag_to_selected, + remove_tag_from_selected, + set_correspondent_on_selected, + remove_correspondent_from_selected, + set_document_type_on_selected, + remove_document_type_from_selected, + run_document_classifier_on_selected + ] - date_hierarchy = 'created' + date_hierarchy = "created" - document_queue = None + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.document_queue = [] def has_add_permission(self, request): return False @@ -187,27 +221,41 @@ def created_(self, obj): created_.short_description = "Created" def changelist_view(self, request, extra_context=None): - response = super().changelist_view(request, extra_context) - if request.method == 'GET': + response = super().changelist_view( + request, + extra_context=extra_context + ) + + if request.method == "GET": cl = self.get_changelist_instance(request) self.document_queue = [doc.id for doc in cl.queryset] return response - def change_view(self, request, object_id=None, form_url='', extra_context=None): + def change_view(self, request, object_id=None, form_url='', + extra_context=None): + extra_context = extra_context or {} doc = Document.objects.get(id=object_id) extra_context['download_url'] = doc.download_url extra_context['file_type'] = doc.file_type - if self.document_queue and object_id and int(object_id) in self.document_queue: - # There is a queue of documents - current_index = self.document_queue.index(int(object_id)) - if current_index < len(self.document_queue) - 1: - # ... and there are still documents in the queue - extra_context['next_object'] = self.document_queue[current_index + 1] + + if self.document_queue and object_id: + if int(object_id) in self.document_queue: + # There is a queue of documents + current_index = self.document_queue.index(int(object_id)) + if current_index < len(self.document_queue) - 1: + # ... and there are still documents in the queue + extra_context["next_object"] = self.document_queue[ + current_index + 1 + ] + return super(DocumentAdmin, self).change_view( - request, object_id, form_url, extra_context=extra_context, + request, + object_id, + form_url, + extra_context=extra_context, ) def response_change(self, request, obj): @@ -217,25 +265,35 @@ def response_change(self, request, obj): preserved_filters = self.get_preserved_filters(request) msg_dict = { - 'name': opts.verbose_name, - 'obj': format_html('{}', urlquote(request.path), obj), + "name": opts.verbose_name, + "obj": format_html( + '{}', + urlquote(request.path), + obj + ), } if "_saveandeditnext" in request.POST: msg = format_html( - 'The {name} "{obj}" was changed successfully. Editing next object.', + 'The {name} "{obj}" was changed successfully. ' + 'Editing next object.', **msg_dict ) self.message_user(request, msg, messages.SUCCESS) - redirect_url = reverse('admin:%s_%s_change' % - (opts.app_label, opts.model_name), - args=(request.POST['_next_object'],), - current_app=self.admin_site.name) - redirect_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, redirect_url) - response = HttpResponseRedirect(redirect_url) - else: - response = super().response_change(request, obj) + redirect_url = reverse( + "admin:{}_{}_change".format(opts.app_label, opts.model_name), + args=(request.POST["_next_object"],), + current_app=self.admin_site.name + ) + redirect_url = add_preserved_filters( + { + "preserved_filters": preserved_filters, + "opts": opts + }, + redirect_url + ) + return HttpResponseRedirect(redirect_url) - return response + return super().response_change(request, obj) @mark_safe def thumbnail(self, obj): diff --git a/src/documents/filters.py b/src/documents/filters.py old mode 100644 new mode 100755 index 780491a14..ae0e0d22e --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -1,8 +1,14 @@ -from django_filters.rest_framework import CharFilter, FilterSet, BooleanFilter +from django_filters.rest_framework import CharFilter, FilterSet, BooleanFilter, ModelChoiceFilter from .models import Correspondent, Document, Tag, DocumentType +CHAR_KWARGS = ( + "startswith", "endswith", "contains", + "istartswith", "iendswith", "icontains" +) + + class CorrespondentFilterSet(FilterSet): class Meta: @@ -44,38 +50,27 @@ class Meta(object): class DocumentFilterSet(FilterSet): - CHAR_KWARGS = { - "lookup_expr": ( - "startswith", - "endswith", - "contains", - "istartswith", - "iendswith", - "icontains" - ) - } - - correspondent__name = CharFilter( - field_name="correspondent__name", **CHAR_KWARGS) - correspondent__slug = CharFilter( - field_name="correspondent__slug", **CHAR_KWARGS) - tags__name = CharFilter( - field_name="tags__name", **CHAR_KWARGS) - tags__slug = CharFilter( - field_name="tags__slug", **CHAR_KWARGS) - tags__empty = BooleanFilter( - field_name="tags", lookup_expr="isnull", distinct=True) - document_type__name = CharFilter( - name="document_type__name", **CHAR_KWARGS) - document_type__slug = CharFilter( - name="document_type__slug", **CHAR_KWARGS) + tags_empty = BooleanFilter( + label="Is tagged", + field_name="tags", + lookup_expr="isnull", + exclude=True + ) class Meta: model = Document fields = { - "title": [ - "startswith", "endswith", "contains", - "istartswith", "iendswith", "icontains" - ], - "content": ["contains", "icontains"], + + "title": CHAR_KWARGS, + "content": ("contains", "icontains"), + + "correspondent__name": CHAR_KWARGS, + "correspondent__slug": CHAR_KWARGS, + + "tags__name": CHAR_KWARGS, + "tags__slug": CHAR_KWARGS, + + "document_type__name": CHAR_KWARGS, + "document_type__slug": CHAR_KWARGS, + } diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py index d1a9df380..502bec0c1 100644 --- a/src/documents/management/commands/document_exporter.py +++ b/src/documents/management/commands/document_exporter.py @@ -55,7 +55,12 @@ def dump(self): documents = Document.objects.all() document_map = {d.pk: d for d in documents} manifest = json.loads(serializers.serialize("json", documents)) - for document_dict in manifest: + + for index, document_dict in enumerate(manifest): + + # Force output to unencrypted as that will be the current state. + # The importer will make the decision to encrypt or not. + manifest[index]["fields"]["storage_type"] = Document.STORAGE_TYPE_UNENCRYPTED # NOQA: E501 document = document_map[document_dict["pk"]] diff --git a/src/documents/management/commands/document_importer.py b/src/documents/management/commands/document_importer.py index 15401722c..ae5c1853f 100644 --- a/src/documents/management/commands/document_importer.py +++ b/src/documents/management/commands/document_importer.py @@ -94,7 +94,7 @@ def _import_files_from_manifest(self): document_path = os.path.join(self.source, doc_file) thumbnail_path = os.path.join(self.source, thumb_file) - if document.storage_type == Document.STORAGE_TYPE_GPG: + if settings.PASSPHRASE: with open(document_path, "rb") as unencrypted: with open(document.source_path, "wb") as encrypted: @@ -112,3 +112,15 @@ def _import_files_from_manifest(self): shutil.copy(document_path, document.source_path) shutil.copy(thumbnail_path, document.thumbnail_path) + + # Reset the storage type to whatever we've used while importing + + storage_type = Document.STORAGE_TYPE_UNENCRYPTED + if settings.PASSPHRASE: + storage_type = Document.STORAGE_TYPE_GPG + + Document.objects.filter( + pk__in=[r["pk"] for r in self.manifest] + ).update( + storage_type=storage_type + ) diff --git a/src/documents/migrations/0014_document_checksum.py b/src/documents/migrations/0014_document_checksum.py index bc563cf86..a22348ba4 100644 --- a/src/documents/migrations/0014_document_checksum.py +++ b/src/documents/migrations/0014_document_checksum.py @@ -158,9 +158,4 @@ class Migration(migrations.Migration): name='modified', field=models.DateTimeField(auto_now=True, db_index=True), ), - migrations.AlterField( - model_name='document', - name='checksum', - field=models.CharField(editable=False, help_text='The checksum of the original document (before it was encrypted). We use this to prevent duplicate document imports.', max_length=32, unique=True), - ), ] diff --git a/src/documents/migrations/0015_add_insensitive_to_match.py b/src/documents/migrations/0015_add_insensitive_to_match.py index 34a570c6e..30666dea9 100644 --- a/src/documents/migrations/0015_add_insensitive_to_match.py +++ b/src/documents/migrations/0015_add_insensitive_to_match.py @@ -12,6 +12,11 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterField( + model_name='document', + name='checksum', + field=models.CharField(editable=False, help_text='The checksum of the original document (before it was encrypted). We use this to prevent duplicate document imports.', max_length=32, unique=True), + ), migrations.AddField( model_name='correspondent', name='is_insensitive', diff --git a/src/documents/templates/admin/documents/document/select_object.html b/src/documents/templates/admin/documents/document/select_object.html old mode 100644 new mode 100755 index 1439b5c21..775d57b12 --- a/src/documents/templates/admin/documents/document/select_object.html +++ b/src/documents/templates/admin/documents/document/select_object.html @@ -1,46 +1,50 @@ {% extends "admin/base_site.html" %} + + {% load i18n l10n admin_urls static %} {% load staticfiles %} -{% block extrahead %} -{{ block.super }} -{{ media }} - +{% block extrahead %} + {{ block.super }} + {{ media }} + {% endblock %} + {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation delete-selected-confirmation{% endblock %} + {% block breadcrumbs %} - + {% endblock %} {% block content %} -

Please select the {{itemname}}.

-
{% csrf_token %} -
- {% for obj in queryset %} - - {% endfor %} -

- -

- - - -

- - {% trans "Go back" %} -

-
-
+

Please select the {{itemname}}.

+
{% csrf_token %} +
+ {% for obj in queryset %} + + {% endfor %} +

+ +

+ + + +

+ + {% trans "Go back" %} +

+
+
{% endblock %} diff --git a/src/paperless/settings.py b/src/paperless/settings.py old mode 100644 new mode 100755 index faecc3569..b3725f4b6 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -149,8 +149,9 @@ def __get_boolean(key, default="NO"): "ENGINE": os.getenv("PAPERLESS_DBENGINE"), "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"), "USER": os.getenv("PAPERLESS_DBUSER"), - "PASSWORD": os.getenv("PAPERLESS_DBPASS") } + if os.getenv("PAPERLESS_DBPASS"): + DATABASES["default"]["PASSWORD"] = os.getenv("PAPERLESS_DBPASS") # Password validation diff --git a/src/paperless_tesseract/parsers.py b/src/paperless_tesseract/parsers.py index e3c2ed361..f54461161 100644 --- a/src/paperless_tesseract/parsers.py +++ b/src/paperless_tesseract/parsers.py @@ -172,8 +172,8 @@ def _get_ocr(self, imgs): raw_text = self._assemble_ocr_sections(imgs, middle, raw_text) return raw_text raise OCRError( - "The guessed language is not available in this instance of " - "Tesseract." + "The guessed language ({}) is not available in this instance " + "of Tesseract.".format(guessed_language) ) def _ocr(self, imgs, lang): diff --git a/src/tox.ini b/src/tox.ini index 98e44e063..ff47136be 100644 --- a/src/tox.ini +++ b/src/tox.ini @@ -5,7 +5,7 @@ [tox] skipsdist = True -envlist = py34, py35, py36, pycodestyle, doc +envlist = py34, py35, py36, py37, pycodestyle, doc [testenv] commands = pytest