]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: consumption templates (#4196)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Fri, 22 Sep 2023 23:53:13 +0000 (16:53 -0700)
committerGitHub <noreply@github.com>
Fri, 22 Sep 2023 23:53:13 +0000 (16:53 -0700)
* Initial implementation of consumption templates

* Frontend implementation of consumption templates

Testing

* Support consumption template source

* order templates, automatically add permissions

* Support title assignment in consumption templates

* Refactoring, filters to and, show sources on list

Show sources on template list, update some translation strings

Make filters and

minor testing

* Update strings

* Only update django-multiselectfield

* Basic docs, document some methods

* Improve testing coverage, template multi-assignment merges

51 files changed:
Pipfile
Pipfile.lock
docs/usage.md
src-ui/messages.xlf
src-ui/src/app/app-routing.module.ts
src-ui/src/app/app.module.ts
src-ui/src/app/components/app-frame/app-frame.component.html
src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts [new file with mode: 0644]
src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html
src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts
src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts
src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html
src-ui/src/app/components/common/input/tags/tags.component.html
src-ui/src/app/components/common/input/tags/tags.component.ts
src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html [new file with mode: 0644]
src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.scss [new file with mode: 0644]
src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts [new file with mode: 0644]
src-ui/src/app/data/paperless-consumption-template.ts [new file with mode: 0644]
src-ui/src/app/data/paperless-mail-rule.ts
src-ui/src/app/data/paperless-user.ts
src-ui/src/app/services/permissions.service.spec.ts
src-ui/src/app/services/permissions.service.ts
src-ui/src/app/services/rest/consumption-template.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/consumption-template.service.ts [new file with mode: 0644]
src-ui/src/app/services/rest/mail-rule.service.spec.ts
src-ui/src/styles.scss
src/documents/consumer.py
src/documents/data_models.py
src/documents/matching.py
src/documents/migrations/1039_consumptiontemplate.py [new file with mode: 0644]
src/documents/models.py
src/documents/serialisers.py
src/documents/tasks.py
src/documents/tests/test_api.py
src/documents/tests/test_consumer.py
src/documents/tests/test_consumption_templates.py [new file with mode: 0644]
src/documents/tests/test_management_exporter.py
src/documents/tests/test_migration_consumption_templates.py [new file with mode: 0644]
src/documents/views.py
src/locale/en_US/LC_MESSAGES/django.po
src/paperless/urls.py
src/paperless_mail/mail.py
src/paperless_mail/migrations/0022_mailrule_assign_owner_from_rule_and_more.py [new file with mode: 0644]
src/paperless_mail/models.py
src/paperless_mail/serialisers.py
src/paperless_mail/tests/test_api.py
src/paperless_mail/tests/test_mail.py

diff --git a/Pipfile b/Pipfile
index a9366fd504dac749a576ff6cf63aaac823dea280..1dfc980fb5ab4756a7132d08a26dbe482a55b6c9 100644 (file)
--- a/Pipfile
+++ b/Pipfile
@@ -3,7 +3,6 @@ url = "https://pypi.python.org/simple"
 verify_ssl = true
 name = "pypi"
 
-
 [packages]
 dateparser = "~=1.1"
 # WARNING: django does not use semver.
@@ -51,6 +50,7 @@ pdf2image = "*"
 flower = "*"
 bleach = "*"
 zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
+django-multiselectfield = "*"
 
 [dev-packages]
 # Linting
index 4d27f5c953e403a9f76be8c6b89007102feed331..da9a4af13bc8e458b2f18ed064d83200d6a61c00 100644 (file)
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "ac966c7a02e216e5198e13857f2701fd5e9b1c4dbb39ad151889f8a4d8cd8711"
+            "sha256": "973d5669b7774b4af56a55be874d496dc5d6fb751761bda5749410a4bce57e5c"
         },
         "pipfile-spec": 6,
         "requires": {},
                 "sha256:cac9df0ba87b4f439e1a311ef22f75c938fc874bebf1fbabaed58d0e6d559a25"
             ],
             "index": "pypi",
-            "markers": "python_version >= '3.8'",
             "version": "==4.1.11"
         },
         "django-celery-results": {
             "markers": "python_version >= '3.5'",
             "version": "==2.4.0"
         },
+        "django-multiselectfield": {
+            "hashes": [
+                "sha256:c270faa7f80588214c55f2d68cbddb2add525c2aa830c216b8a198de914eb470",
+                "sha256:d0a4c71568fb2332c71478ffac9f8708e01314a35cf923dfd7a191343452f9f9"
+            ],
+            "index": "pypi",
+            "version": "==0.1.12"
+        },
         "djangorestframework": {
             "hashes": [
                 "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8",
index d019848d2ae9d40a92c744e082039998960ce5bb..c80a5a2d2f65fbbb5f2d1b096dc78260fbd2afa8 100644 (file)
@@ -261,6 +261,62 @@ These can be found under Settings > Users & Groups, assuming the user has access
 as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
 permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
 
+## Consumption templates
+
+Consumption templates were introduced in v2.0 and allow for finer control over what metadata (tags, doc
+types) and permissions (owner, privileges) are assigned to documents during consumption. In general,
+templates are applied sequentially (by sort order) but subsequent templates will never override an
+assignment from a preceding template. The same is true for mail rules, e.g. if you set the correspondent
+in a mail rule any subsequent consumption templates that are applied _will not_ overwrite this. The
+exception to this is assignments that can be multiple e.g. tags and permissions, which will be merged.
+
+Consumption templates allow you to filter by:
+
+- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
+- File name, including wildcards e.g. \*.pdf will apply to all pdfs
+- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
+  example, automatically assigning documents to different owners based on the upload directory.
+- Mail rule. Choosing this option will force 'mail fetch' to be the template source.
+
+!!! note
+
+    You must include a file name filter, a path filter or a mail rule filter. Use * for either to apply
+    to all files.
+
+Consumption templates can assign:
+
+- Title, see [title placeholders](/usage#title_placeholders) below
+- Tags, correspondent, document types
+- Document owner
+- View and / or edit permissions to users or groups
+
+### Consumption template permissions
+
+All users who have application permissions for editing consumption templates can see the same set
+of templates. In other words, templates themselves intentionally do not have an owner or permissions.
+
+Given their potentially far-reaching capabilities, you may want to restrict access to templates.
+
+Upon migration, existing installs will grant access to consumption templates to users who can add
+documents (and superusers who can always access all parts of the app).
+
+### Title placeholders
+
+Consumption template titles can include placeholders, _only for items that are assigned within the template_.
+This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
+applied. You can use the following placeholders:
+
+- `{correspondent}`: assigned correspondent name
+- `{document_type}`: assigned document type name
+- `{owner_username}`: assigned owner username
+- `{added}`: added datetime
+- `{added_year}`: added year
+- `{added_year_short}`: added year
+- `{added_month}`: added month
+- `{added_month_name}`: added month name
+- `{added_month_name_short}`: added month short name
+- `{added_day}`: added day
+
 ## Best practices {#basic-searching}
 
 Paperless offers a couple tools that help you organize your document
index 198c2c9c14044779c9254bfe7741ec46e78b57da..98bd9af7d9bc4939a860ac441edc24b0b29c4530 100644 (file)
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">174</context>
+          <context context-type="linenumber">181</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">177</context>
+          <context context-type="linenumber">184</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
           <context context-type="linenumber">141</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
-          <context context-type="linenumber">2</context>
+          <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
+          <context context-type="linenumber">63</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
           <context context-type="linenumber">155</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="4462691404891390153" datatype="html">
+        <source>Consumption templates</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">159</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5433675495457939071" datatype="html">
+        <source>Templates</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
+          <context context-type="linenumber">162</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="2991443309752293110" datatype="html">
         <source>File Tasks</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">159</context>
+          <context context-type="linenumber">166</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
         <source>File Tasks<x id="START_TAG_SPAN_1" ctype="x-span_1" equiv-text="&lt;span *ngIf=&quot;tasksService.failedFileTasks.length &gt; 0&quot;&gt;"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge bg-danger ms-2&quot;&gt;"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">163</context>
+          <context context-type="linenumber">170</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4804785061014590286" datatype="html">
         <source>Logs</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">167</context>
+          <context context-type="linenumber">174</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">170</context>
+          <context context-type="linenumber">177</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/logs/logs.component.html</context>
         <source>Info</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">183</context>
+          <context context-type="linenumber">190</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
         <source>Documentation</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">187</context>
+          <context context-type="linenumber">194</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">190</context>
+          <context context-type="linenumber">197</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1534029177398918729" datatype="html">
         <source>GitHub</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">195</context>
+          <context context-type="linenumber">202</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">198</context>
+          <context context-type="linenumber">205</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5612903193691847840" datatype="html">
         <source>Suggest an idea</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">200</context>
+          <context context-type="linenumber">207</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">204</context>
+          <context context-type="linenumber">211</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4112664765954374539" datatype="html">
         <source>is available.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">213</context>
+          <context context-type="linenumber">220</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1175891574282637937" datatype="html">
         <source>Click to view.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">213</context>
+          <context context-type="linenumber">220</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9811291095862612" datatype="html">
         <source>Paperless-ngx can automatically check for updates</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">217</context>
+          <context context-type="linenumber">224</context>
         </context-group>
       </trans-unit>
       <trans-unit id="894819944961861800" datatype="html">
         <source> How does this work? </source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">224,226</context>
+          <context context-type="linenumber">231,233</context>
         </context-group>
       </trans-unit>
       <trans-unit id="509090351011426949" datatype="html">
         <source>Update available</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
-          <context context-type="linenumber">235</context>
+          <context context-type="linenumber">242</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3011185103048412841" datatype="html">
       </trans-unit>
       <trans-unit id="8953033926734869941" datatype="html">
         <source>Name</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">10</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
           <context context-type="linenumber">9</context>
           <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
           <context context-type="linenumber">8</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
           <context context-type="linenumber">19</context>
           <context context-type="linenumber">40</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="8743659855412792665" datatype="html">
-        <source>Matching algorithm</source>
+      <trans-unit id="8419515490539218007" datatype="html">
+        <source>Sort order</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
-          <context context-type="linenumber">10</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
-          <context context-type="linenumber">11</context>
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html</context>
+          <context context-type="linenumber">14</context>
         </context-group>
+      </trans-unit>
+      <trans-unit id="4163272119298020373" datatype="html">
+        <source>Filters</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">11</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">18</context>
         </context-group>
+      </trans-unit>
+      <trans-unit id="9090904435418488635" datatype="html">
+        <source>Process documents that match <x id="START_EMPHASISED_TEXT" ctype="x-em" equiv-text="&lt;em&gt;"/>all<x id="CLOSE_EMPHASISED_TEXT" ctype="x-em" equiv-text="&lt;/em&gt;"/> filters specified below.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
-          <context context-type="linenumber">13</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">19</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="2656329676292524585" datatype="html">
-        <source>Matching pattern</source>
+      <trans-unit id="1473412958770421458" datatype="html">
+        <source>Filter sources</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
-          <context context-type="linenumber">11</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">20</context>
         </context-group>
+      </trans-unit>
+      <trans-unit id="7467799586957602479" datatype="html">
+        <source>Filter filename</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
-          <context context-type="linenumber">12</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">21</context>
         </context-group>
+      </trans-unit>
+      <trans-unit id="3694878959415278689" datatype="html">
+        <source>Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">12</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">21</context>
         </context-group>
+      </trans-unit>
+      <trans-unit id="6540860478788535250" datatype="html">
+        <source>Filter path</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
-          <context context-type="linenumber">14</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">22</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="6063936469630366525" datatype="html">
-        <source>Case insensitive</source>
+      <trans-unit id="2900550647231873115" datatype="html">
+        <source>Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.&lt;/a&gt;</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
-          <context context-type="linenumber">12</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">22</context>
         </context-group>
+      </trans-unit>
+      <trans-unit id="7468453896129193641" datatype="html">
+        <source>Filter mail rule</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
-          <context context-type="linenumber">13</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">23</context>
         </context-group>
+      </trans-unit>
+      <trans-unit id="8663702115863339485" datatype="html">
+        <source>Apply to documents consumed via this mail rule.</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
-          <context context-type="linenumber">13</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">23</context>
         </context-group>
+      </trans-unit>
+      <trans-unit id="5070601857075675729" datatype="html">
+        <source>Assignments</source>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
-          <context context-type="linenumber">15</context>
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">28</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6019822389883736115" datatype="html">
+        <source>Assign title</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">33</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7071676931416298160" datatype="html">
+        <source>Can include some placeholders, see &lt;a target=&apos;_blank&apos; href=&apos;https://docs.paperless-ngx.com/usage/#consumption-templates&apos;&gt;documentation&lt;/a&gt;.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">33</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6528897010417701530" datatype="html">
+        <source>Assign tags</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">34</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6695990587380209737" datatype="html">
+        <source>Assign document type</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">35</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
+          <context context-type="linenumber">32</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4875491778188965469" datatype="html">
+        <source>Assign correspondent</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">36</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
+          <context context-type="linenumber">34</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7198346314713788799" datatype="html">
+        <source>Assign storage path</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">37</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5057200219587080996" datatype="html">
+        <source>Assign owner</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">40</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1749184201773078639" datatype="html">
+        <source>Assign view permissions</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">42</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2191775412581217688" datatype="html">
+        <source>Users:</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">46</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">65</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
+          <context context-type="linenumber">31</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
+          <context context-type="linenumber">50</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
+          <context context-type="linenumber">160</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
+          <context context-type="linenumber">187</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="239911470633002624" datatype="html">
+        <source>Groups:</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">54</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">73</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
+          <context context-type="linenumber">39</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
+          <context context-type="linenumber">58</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
+          <context context-type="linenumber">170</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
+          <context context-type="linenumber">197</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1744964187586405039" datatype="html">
+        <source>Assign edit permissions</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">61</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3728984448750213892" datatype="html">
+        <source>Edit permissions also grant viewing permissions</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">79</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
+          <context context-type="linenumber">64</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
+          <context context-type="linenumber">206</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1519954996184640001" datatype="html">
+        <source>Error</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">88</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
+          <context context-type="linenumber">40</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
+          <context context-type="linenumber">14</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/services/toast.service.ts</context>
+          <context context-type="linenumber">37</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2159130950882492111" datatype="html">
         <source>Cancel</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">89</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
           <context context-type="linenumber">20</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
-          <context context-type="linenumber">39</context>
+          <context context-type="linenumber">41</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
       </trans-unit>
       <trans-unit id="3768927257183755959" datatype="html">
         <source>Save</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html</context>
+          <context context-type="linenumber">90</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
           <context context-type="linenumber">21</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
-          <context context-type="linenumber">40</context>
+          <context context-type="linenumber">42</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
           <context context-type="linenumber">493</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="4626030417479279989" datatype="html">
+        <source>Consume Folder</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts</context>
+          <context context-type="linenumber">25</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="526966086395145275" datatype="html">
+        <source>API Upload</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts</context>
+          <context context-type="linenumber">29</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7502272564743467653" datatype="html">
+        <source>Mail Fetch</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts</context>
+          <context context-type="linenumber">33</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2057608151109082984" datatype="html">
+        <source>Create new consumption template</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts</context>
+          <context context-type="linenumber">83</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3745279073747685988" datatype="html">
+        <source>Edit consumption template</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts</context>
+          <context context-type="linenumber">87</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8743659855412792665" datatype="html">
+        <source>Matching algorithm</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
+          <context context-type="linenumber">10</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2656329676292524585" datatype="html">
+        <source>Matching pattern</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
+          <context context-type="linenumber">12</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">12</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
+          <context context-type="linenumber">14</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6063936469630366525" datatype="html">
+        <source>Case insensitive</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
+          <context context-type="linenumber">12</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
+          <context context-type="linenumber">13</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
+          <context context-type="linenumber">15</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6457471243969293847" datatype="html">
         <source>Create new correspondent</source>
         <context-group purpose="location">
           <context context-type="linenumber">28</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="6093797930511670257" datatype="html">
-        <source>Assign title from</source>
+      <trans-unit id="5512171567357420308" datatype="html">
+        <source>Assignments specified here will supersede any consumption templates.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
           <context context-type="linenumber">29</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="6695990587380209737" datatype="html">
-        <source>Assign document type</source>
+      <trans-unit id="6093797930511670257" datatype="html">
+        <source>Assign title from</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
-          <context context-type="linenumber">31</context>
+          <context context-type="linenumber">30</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4754802869258527587" datatype="html">
         <source>Assign correspondent from</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
-          <context context-type="linenumber">32</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="4875491778188965469" datatype="html">
-        <source>Assign correspondent</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
           <context context-type="linenumber">33</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="1519954996184640001" datatype="html">
-        <source>Error</source>
+      <trans-unit id="5232720756589450549" datatype="html">
+        <source>Assign owner from rule</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html</context>
-          <context context-type="linenumber">38</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
-          <context context-type="linenumber">14</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/services/toast.service.ts</context>
-          <context context-type="linenumber">37</context>
+          <context context-type="linenumber">35</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6886003843406464884" datatype="html">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
           <context context-type="linenumber">138</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html</context>
+          <context context-type="linenumber">27</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
           <context context-type="linenumber">74</context>
           <context context-type="linenumber">80</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="2881879110886196973" datatype="html">
+        <source>Do not assign title from this rule</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
+          <context context-type="linenumber">84</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="1568902914205618549" datatype="html">
         <source>Do not assign a correspondent</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
-          <context context-type="linenumber">87</context>
+          <context context-type="linenumber">91</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3567746385454588269" datatype="html">
         <source>Use mail address</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
-          <context context-type="linenumber">91</context>
+          <context context-type="linenumber">95</context>
         </context-group>
       </trans-unit>
       <trans-unit id="445154175758965852" datatype="html">
         <source>Use name (or mail address if not available)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
-          <context context-type="linenumber">95</context>
+          <context context-type="linenumber">99</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1258862217749148424" datatype="html">
         <source>Use correspondent selected below</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
-          <context context-type="linenumber">99</context>
+          <context context-type="linenumber">103</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3147349817770432927" datatype="html">
         <source>Create new mail rule</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
-          <context context-type="linenumber">141</context>
+          <context context-type="linenumber">145</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3374331029704382439" datatype="html">
         <source>Edit mail rule</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context>
-          <context context-type="linenumber">145</context>
+          <context context-type="linenumber">149</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8911059720204770105" datatype="html">
           <context context-type="linenumber">56</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="2191775412581217688" datatype="html">
-        <source>Users:</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
-          <context context-type="linenumber">31</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
-          <context context-type="linenumber">50</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
-          <context context-type="linenumber">160</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
-          <context context-type="linenumber">187</context>
-        </context-group>
-      </trans-unit>
-      <trans-unit id="239911470633002624" datatype="html">
-        <source>Groups:</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
-          <context context-type="linenumber">39</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
-          <context context-type="linenumber">58</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
-          <context context-type="linenumber">170</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
-          <context context-type="linenumber">197</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="7585826646011739428" datatype="html">
         <source>Edit</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
           <context context-type="linenumber">83</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html</context>
+          <context context-type="linenumber">26</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
           <context context-type="linenumber">73</context>
           <context context-type="linenumber">471</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="3728984448750213892" datatype="html">
-        <source>Edit permissions also grant viewing permissions</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
-          <context context-type="linenumber">64</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
-          <context context-type="linenumber">206</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="2722549756198502062" datatype="html">
         <source>Add item</source>
         <context-group purpose="location">
           <context context-type="linenumber">80</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="187187500641108332" datatype="html">
+        <source><x id="INTERPOLATION" equiv-text="{{title}}"/></source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
+          <context context-type="linenumber">2</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
+          <context context-type="linenumber">22</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6560126119609945418" datatype="html">
         <source>Add tag</source>
         <context-group purpose="location">
         <source>Set permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context>
-          <context context-type="linenumber">26</context>
+          <context context-type="linenumber">28</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1678966148862835666" datatype="html">
         <source>Edit permissions for </source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context>
-          <context context-type="linenumber">30</context>
+          <context context-type="linenumber">33</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8283439432608484491" datatype="html">
         <source>Note that permissions set here will override any existing permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context>
-          <context context-type="linenumber">55</context>
+          <context context-type="linenumber">61</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5947558132119506443" datatype="html">
           <context context-type="linenumber">8</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="187187500641108332" datatype="html">
-        <source><x id="INTERPOLATION" equiv-text="{{action.key}}"/></source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
-          <context context-type="linenumber">22</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
-          <context context-type="linenumber">11</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="6895273602775249942" datatype="html">
         <source>Inherited from group</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
           <context context-type="linenumber">86</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html</context>
+          <context context-type="linenumber">16</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
           <context context-type="linenumber">39</context>
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
           <context context-type="linenumber">500</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts</context>
+          <context context-type="linenumber">91</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
           <context context-type="linenumber">801</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">912</context>
+          <context context-type="linenumber">914</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">970</context>
+          <context context-type="linenumber">976</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1181910457994920507" datatype="html">
           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
           <context context-type="linenumber">502</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts</context>
+          <context context-type="linenumber">93</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
           <context context-type="linenumber">803</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">914</context>
+          <context context-type="linenumber">916</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">972</context>
+          <context context-type="linenumber">978</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5729001209753056399" datatype="html">
           <context context-type="linenumber">80</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="7308826808299076537" datatype="html">
+        <source>Add Template</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html</context>
+          <context context-type="linenumber">6</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6043932623370318890" datatype="html">
+        <source>Document Sources</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html</context>
+          <context context-type="linenumber">15</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3954560573326461437" datatype="html">
+        <source>No templates defined.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html</context>
+          <context context-type="linenumber">33</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5957685099370605085" datatype="html">
+        <source>Saved template &quot;<x id="PH" equiv-text="newTemplate.name"/>&quot;.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts</context>
+          <context context-type="linenumber">73</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5291506186551056893" datatype="html">
+        <source>Error saving template.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts</context>
+          <context context-type="linenumber">81</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="9152231933218802310" datatype="html">
+        <source>Confirm delete template</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts</context>
+          <context context-type="linenumber">89</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3359315394839441814" datatype="html">
+        <source>This operation will permanently delete this template.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts</context>
+          <context context-type="linenumber">90</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="9198120062446364156" datatype="html">
+        <source>Deleted template</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts</context>
+          <context context-type="linenumber">99</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8513868194237937637" datatype="html">
+        <source>Error deleting template.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts</context>
+          <context context-type="linenumber">104</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6316128875819022658" datatype="html">
         <source>correspondent</source>
         <context-group purpose="location">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">1010</context>
+          <context context-type="linenumber">1020</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1685061484835793745" datatype="html">
         <source>Error saving account.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">902</context>
+          <context context-type="linenumber">904</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5641934153807844674" datatype="html">
         <source>Confirm delete mail account</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">910</context>
+          <context context-type="linenumber">912</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7176985344323395435" datatype="html">
         <source>This operation will permanently delete this mail account.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">911</context>
+          <context context-type="linenumber">913</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4233826387148482123" datatype="html">
         <source>Deleted mail account</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">920</context>
+          <context context-type="linenumber">922</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6202503362522392111" datatype="html">
         <source>Error deleting mail account.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">929</context>
+          <context context-type="linenumber">933</context>
         </context-group>
       </trans-unit>
       <trans-unit id="123368655395433699" datatype="html">
         <source>Saved rule &quot;<x id="PH" equiv-text="newMailRule.name"/>&quot;.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">949</context>
+          <context context-type="linenumber">953</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8951124554918814321" datatype="html">
         <source>Error saving rule.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">960</context>
+          <context context-type="linenumber">966</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3896080636020672118" datatype="html">
         <source>Confirm delete mail rule</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">968</context>
+          <context context-type="linenumber">974</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2250372580580310337" datatype="html">
         <source>This operation will permanently delete this mail rule.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">969</context>
+          <context context-type="linenumber">975</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9077981247971516916" datatype="html">
         <source>Deleted mail rule</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">978</context>
+          <context context-type="linenumber">984</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2033194641751367552" datatype="html">
         <source>Error deleting mail rule.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">986</context>
+          <context context-type="linenumber">994</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3061362835271417984" datatype="html">
         <source>Permissions updated</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
-          <context context-type="linenumber">1006</context>
+          <context context-type="linenumber">1016</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5101757640976222639" datatype="html">
index d839274f80080c6ad9f389c51cd1ed6f1c1882fd..34bf77609fef23f5775a82307205a7be4b71ff31 100644 (file)
@@ -21,6 +21,7 @@ import {
   PermissionAction,
   PermissionType,
 } from './services/permissions.service'
+import { ConsumptionTemplatesListComponent } from './components/manage/consumption-templates-list/consumption-templates-list.component'
 
 export const routes: Routes = [
   { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
@@ -182,7 +183,17 @@ export const routes: Routes = [
           },
         },
       },
-      { path: 'tasks', component: TasksComponent },
+      {
+        path: 'templates',
+        component: ConsumptionTemplatesListComponent,
+        canActivate: [PermissionsGuard],
+        data: {
+          requiredPermission: {
+            action: PermissionAction.View,
+            type: PermissionType.ConsumptionTemplate,
+          },
+        },
+      },
     ],
   },
 
index f46c06cb989f5277fbb5afd99f223fcf2e7b7d35..bb6c8777a4b3df2f1f1df55e391345f252a17053 100644 (file)
@@ -95,6 +95,8 @@ import { UsernamePipe } from './pipes/username.pipe'
 import { LogoComponent } from './components/common/logo/logo.component'
 import { IsNumberPipe } from './pipes/is-number.pipe'
 import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
+import { ConsumptionTemplatesListComponent } from './components/manage/consumption-templates-list/consumption-templates-list.component'
+import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
 
 import localeAf from '@angular/common/locales/af'
 import localeAr from '@angular/common/locales/ar'
@@ -233,6 +235,8 @@ function initializeApp(settings: SettingsService) {
     LogoComponent,
     IsNumberPipe,
     ShareLinksDropdownComponent,
+    ConsumptionTemplatesListComponent,
+    ConsumptionTemplateEditDialogComponent,
   ],
   imports: [
     BrowserModule,
index 3d62c0932993faeeb84bb3b386bf2a320e926ece..61e71671495175c931f0849b751b6ba28a9b35ba 100644 (file)
               </svg><span>&nbsp;<ng-container i18n>Storage paths</ng-container></span>
             </a>
           </li>
+          <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }">
+            <a class="nav-link" routerLink="templates" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Consumption templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
+              <svg class="sidebaricon" fill="currentColor">
+                <use xlink:href="assets/bootstrap-icons.svg#file-earmark-ruled"/>
+              </svg><span>&nbsp;<ng-container i18n>Templates</ng-container></span>
+            </a>
+          </li>
           <li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" tourAnchor="tour.file-tasks">
             <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
               <span *ngIf="tasksService.failedFileTasks.length > 0 && slimSidebarEnabled" class="badge bg-danger position-absolute top-0 end-0">{{tasksService.failedFileTasks.length}}</span>
diff --git a/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html
new file mode 100644 (file)
index 0000000..92964ec
--- /dev/null
@@ -0,0 +1,92 @@
+<form [formGroup]="objectForm" (ngSubmit)="save()">
+    <div class="modal-header">
+      <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
+      <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
+      </button>
+    </div>
+    <div class="modal-body">
+      <div class="row">
+        <div class="col-md-8">
+          <pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
+        </div>
+        <div class="col">
+          <pngx-input-number i18n-title title="Sort order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-md-4">
+          <h5 class="border-bottom pb-2" i18n>Filters</h5>
+          <p class="small" i18n>Process documents that match <em>all</em> filters specified below.</p>
+          <pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.filter_filename"></pngx-input-select>
+          <pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
+          <pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.</a>" [error]="error?.filter_path"></pngx-input-text>
+          <pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
+        </div>
+        <div class="col">
+          <div class="row">
+            <div class="col">
+              <h5 class="border-bottom pb-2" i18n>Assignments</h5>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col">
+              <pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#consumption-templates'>documentation</a>." [error]="error?.assign_title"></pngx-input-text>
+              <pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
+              <pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
+              <pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
+              <pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
+          </div>
+          <div class="col">
+            <pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
+              <div>
+                <label class="form-label" i18n>Assign view permissions</label>
+                <div class="mb-2">
+                  <div class="row mb-1">
+                    <div class="col-lg-3">
+                      <label class="form-label d-block my-2" i18n>Users:</label>
+                    </div>
+                    <div class="col-lg-9">
+                      <pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
+                    </div>
+                  </div>
+                  <div class="row">
+                    <div class="col-lg-3">
+                      <label class="form-label d-block my-2" i18n>Groups:</label>
+                    </div>
+                    <div class="col-lg-9">
+                      <pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
+                    </div>
+                  </div>
+                </div>
+                <label class="form-label" i18n>Assign edit permissions</label>
+                <div>
+                  <div class="row mb-1">
+                    <div class="col-lg-3">
+                      <label class="form-label d-block my-2" i18n>Users:</label>
+                    </div>
+                    <div class="col-lg-9">
+                      <pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
+                    </div>
+                  </div>
+                  <div class="row">
+                    <div class="col-lg-3">
+                      <label class="form-label d-block my-2" i18n>Groups:</label>
+                    </div>
+                    <div class="col-lg-9">
+                      <pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
+                    </div>
+                  </div>
+                  <small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
+                </div>
+              </div>
+          </div>
+        </div>
+      </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <span class="text-danger" *ngIf="error?.non_field_errors"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
+      <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
+      <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
+    </div>
+  </form>
diff --git a/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.scss b/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..52789fb
--- /dev/null
@@ -0,0 +1,125 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { of } from 'rxjs'
+import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
+import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
+import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
+import { StoragePathService } from 'src/app/services/rest/storage-path.service'
+import { SettingsService } from 'src/app/services/settings.service'
+import { NumberComponent } from '../../input/number/number.component'
+import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
+import { PermissionsUserComponent } from '../../input/permissions/permissions-user/permissions-user.component'
+import { SelectComponent } from '../../input/select/select.component'
+import { TagsComponent } from '../../input/tags/tags.component'
+import { TextComponent } from '../../input/text/text.component'
+import { EditDialogMode } from '../edit-dialog.component'
+import { ConsumptionTemplateEditDialogComponent } from './consumption-template-edit-dialog.component'
+
+describe('ConsumptionTemplateEditDialogComponent', () => {
+  let component: ConsumptionTemplateEditDialogComponent
+  let settingsService: SettingsService
+  let fixture: ComponentFixture<ConsumptionTemplateEditDialogComponent>
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [
+        ConsumptionTemplateEditDialogComponent,
+        IfPermissionsDirective,
+        IfOwnerDirective,
+        SelectComponent,
+        TextComponent,
+        NumberComponent,
+        TagsComponent,
+        PermissionsUserComponent,
+        PermissionsGroupComponent,
+        SafeHtmlPipe,
+      ],
+      providers: [
+        NgbActiveModal,
+        {
+          provide: CorrespondentService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  {
+                    id: 1,
+                    username: 'c1',
+                  },
+                ],
+              }),
+          },
+        },
+        {
+          provide: DocumentTypeService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  {
+                    id: 1,
+                    username: 'dt1',
+                  },
+                ],
+              }),
+          },
+        },
+        {
+          provide: StoragePathService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [
+                  {
+                    id: 1,
+                    username: 'sp1',
+                  },
+                ],
+              }),
+          },
+        },
+        {
+          provide: MailRuleService,
+          useValue: {
+            listAll: () =>
+              of({
+                results: [],
+              }),
+          },
+        },
+      ],
+      imports: [
+        HttpClientTestingModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgSelectModule,
+        NgbModule,
+      ],
+    }).compileComponents()
+
+    fixture = TestBed.createComponent(ConsumptionTemplateEditDialogComponent)
+    settingsService = TestBed.inject(SettingsService)
+    settingsService.currentUser = { id: 99, username: 'user99' }
+    component = fixture.componentInstance
+
+    fixture.detectChanges()
+  })
+
+  it('should support create and edit modes', () => {
+    component.dialogMode = EditDialogMode.CREATE
+    const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
+    const editTitleSpy = jest.spyOn(component, 'getEditTitle')
+    fixture.detectChanges()
+    expect(createTitleSpy).toHaveBeenCalled()
+    expect(editTitleSpy).not.toHaveBeenCalled()
+    component.dialogMode = EditDialogMode.EDIT
+    fixture.detectChanges()
+    expect(editTitleSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts
new file mode 100644 (file)
index 0000000..3f89e5d
--- /dev/null
@@ -0,0 +1,115 @@
+import { Component } from '@angular/core'
+import { FormGroup, FormControl } from '@angular/forms'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { first } from 'rxjs'
+import {
+  DocumentSource,
+  PaperlessConsumptionTemplate,
+} from 'src/app/data/paperless-consumption-template'
+import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
+import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
+import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
+import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
+import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
+import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
+import { StoragePathService } from 'src/app/services/rest/storage-path.service'
+import { UserService } from 'src/app/services/rest/user.service'
+import { SettingsService } from 'src/app/services/settings.service'
+import { EditDialogComponent } from '../edit-dialog.component'
+import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
+import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule'
+
+export const DOCUMENT_SOURCE_OPTIONS = [
+  {
+    id: DocumentSource.ConsumeFolder,
+    name: $localize`Consume Folder`,
+  },
+  {
+    id: DocumentSource.ApiUpload,
+    name: $localize`API Upload`,
+  },
+  {
+    id: DocumentSource.MailFetch,
+    name: $localize`Mail Fetch`,
+  },
+]
+
+@Component({
+  selector: 'pngx-consumption-template-edit-dialog',
+  templateUrl: './consumption-template-edit-dialog.component.html',
+  styleUrls: ['./consumption-template-edit-dialog.component.scss'],
+})
+export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent<PaperlessConsumptionTemplate> {
+  templates: PaperlessConsumptionTemplate[]
+  correspondents: PaperlessCorrespondent[]
+  documentTypes: PaperlessDocumentType[]
+  storagePaths: PaperlessStoragePath[]
+  mailRules: PaperlessMailRule[]
+
+  constructor(
+    service: ConsumptionTemplateService,
+    activeModal: NgbActiveModal,
+    correspondentService: CorrespondentService,
+    documentTypeService: DocumentTypeService,
+    storagePathService: StoragePathService,
+    mailRuleService: MailRuleService,
+    userService: UserService,
+    settingsService: SettingsService
+  ) {
+    super(service, activeModal, userService, settingsService)
+
+    correspondentService
+      .listAll()
+      .pipe(first())
+      .subscribe((result) => (this.correspondents = result.results))
+
+    documentTypeService
+      .listAll()
+      .pipe(first())
+      .subscribe((result) => (this.documentTypes = result.results))
+
+    storagePathService
+      .listAll()
+      .pipe(first())
+      .subscribe((result) => (this.storagePaths = result.results))
+
+    mailRuleService
+      .listAll()
+      .pipe(first())
+      .subscribe((result) => (this.mailRules = result.results))
+  }
+
+  getCreateTitle() {
+    return $localize`Create new consumption template`
+  }
+
+  getEditTitle() {
+    return $localize`Edit consumption template`
+  }
+
+  getForm(): FormGroup {
+    return new FormGroup({
+      name: new FormControl(null),
+      account: new FormControl(null),
+      filter_filename: new FormControl(null),
+      filter_path: new FormControl(null),
+      filter_mailrule: new FormControl(null),
+      order: new FormControl(null),
+      sources: new FormControl([]),
+      assign_title: new FormControl(null),
+      assign_tags: new FormControl([]),
+      assign_owner: new FormControl(null),
+      assign_document_type: new FormControl(null),
+      assign_correspondent: new FormControl(null),
+      assign_storage_path: new FormControl(null),
+      assign_view_users: new FormControl([]),
+      assign_view_groups: new FormControl([]),
+      assign_change_users: new FormControl([]),
+      assign_change_groups: new FormControl([]),
+    })
+  }
+
+  get sourceOptions() {
+    return DOCUMENT_SOURCE_OPTIONS
+  }
+}
index a899ac1ad95f11ac5164f5df92838242ee492b1a..acf855f27e242ddc11643c9a49876ccf05d7543f 100644 (file)
       <div class="col-md-4">
         <pngx-input-select i18n-title title="Action" [items]="actionOptions" formControlName="action" i18n-hint hint="Action is only performed when documents are consumed from the mail. Mails without attachments remain entirely untouched."></pngx-input-select>
         <pngx-input-text i18n-title title="Action parameter" *ngIf="showActionParamField" formControlName="action_parameter" [error]="error?.action_parameter"></pngx-input-text>
+        <p class="small fst-italic mt-5" i18n>Assignments specified here will supersede any consumption templates.</p>
         <pngx-input-select i18n-title title="Assign title from" [items]="metadataTitleOptions" formControlName="assign_title_from"></pngx-input-select>
         <pngx-input-tags [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
         <pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
         <pngx-input-select i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
         <pngx-input-select *ngIf="showCorrespondentField" i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
+        <pngx-input-check i18n-title title="Assign owner from rule" formControlName="assign_owner_from_rule"></pngx-input-check>
       </div>
     </div>
   </div>
index 9f5470c8a355ceef64cb908f68673beaf7a39f09..6e48d95f07fc8ad9680a02aac69dc996e0cd5c2f 100644 (file)
@@ -15,6 +15,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
 import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
 import { MailAccountService } from 'src/app/services/rest/mail-account.service'
 import { SettingsService } from 'src/app/services/settings.service'
+import { CheckComponent } from '../../input/check/check.component'
 import { NumberComponent } from '../../input/number/number.component'
 import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
 import { SelectComponent } from '../../input/select/select.component'
@@ -27,9 +28,6 @@ describe('MailRuleEditDialogComponent', () => {
   let component: MailRuleEditDialogComponent
   let settingsService: SettingsService
   let fixture: ComponentFixture<MailRuleEditDialogComponent>
-  let accountService: MailAccountService
-  let correspondentService: CorrespondentService
-  let documentTypeService: DocumentTypeService
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
@@ -43,6 +41,7 @@ describe('MailRuleEditDialogComponent', () => {
         NumberComponent,
         TagsComponent,
         SafeHtmlPipe,
+        CheckComponent,
       ],
       providers: [
         NgbActiveModal,
index bddecf2040bd5a343566b90d299024bd1c92d269..1de03684786994eec95f41f8589699ce41f947aa 100644 (file)
@@ -79,6 +79,10 @@ const METADATA_TITLE_OPTIONS = [
     id: MailMetadataTitleOption.FromFilename,
     name: $localize`Use attachment filename as title`,
   },
+  {
+    id: MailMetadataTitleOption.None,
+    name: $localize`Do not assign title from this rule`,
+  },
 ]
 
 const METADATA_CORRESPONDENT_OPTIONS = [
@@ -168,6 +172,7 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<PaperlessMa
         MailMetadataCorrespondentOption.FromNothing
       ),
       assign_correspondent: new FormControl(null),
+      assign_owner_from_rule: new FormControl(true),
     })
   }
 
index 3c8794e4db5acb1877a8546de2f4f7e689865ce6..abb9d6c5120e6701c3b7148a869d05325741a57a 100644 (file)
@@ -6,7 +6,7 @@
     </div>
     <div class="modal-body">
       <div class="row">
-        <div class="col">
+        <div class="col-md-4">
           <pngx-input-text i18n-title title="Username" formControlName="username" [error]="error?.username"></pngx-input-text>
           <pngx-input-text i18n-title title="Email" formControlName="email" [error]="error?.email"></pngx-input-text>
           <pngx-input-password i18n-title title="Password" formControlName="password" [error]="error?.password"></pngx-input-password>
index a49a0de2d066a821704dd2c6d6ab86f0c1c65665..7d96a1026f29d357c393c94142cdc4fcb00f0ecc 100644 (file)
@@ -1,5 +1,5 @@
 <div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled">
-  <label class="form-label" for="tags" i18n>Tags</label>
+  <label class="form-label" for="tags" i18n>{{title}}</label>
 
   <div class="input-group flex-nowrap">
     <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
@@ -7,7 +7,7 @@
       [multiple]="true"
       [closeOnSelect]="false"
       [clearSearchOnAdd]="true"
-      [hideSelected]="true"
+      [hideSelected]="tags.length > 0"
       [addTag]="allowCreate ? createTagRef : false"
       addTagText="Add tag"
       i18n-addTagText
index ff5657a121fc26dbbb0e6546cb438f832b542b1c..64d6eddc337c73eae5224d2524a75ebc92ae9d80 100644 (file)
@@ -59,6 +59,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
     })
   }
 
+  @Input()
+  title = $localize`Tags`
+
   @Input()
   disabled = false
 
diff --git a/src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html b/src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.html
new file mode 100644 (file)
index 0000000..098c2af
--- /dev/null
@@ -0,0 +1,33 @@
+<pngx-page-header title="Consumption Templates">
+    <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ConsumptionTemplate }">
+      <svg class="sidebaricon me-1" fill="currentColor">
+        <use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
+      </svg>
+      <ng-container i18n>Add Template</ng-container>
+    </button>
+  </pngx-page-header>
+
+  <table class="table table-striped align-middle border shadow-sm">
+    <thead>
+        <tr>
+            <th scope="col" i18n>Name</th>
+            <th scope="col" i18n>Sort order</th>
+            <th scope="col" i18n>Document Sources</th>
+            <th scope="col" i18n>Actions</th>
+        </tr>
+    </thead>
+    <tbody>
+        <tr *ngFor="let template of templates">
+            <td scope="row"><button class="btn btn-link p-0" type="button" (click)="editTemplate(template)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.ConsumptionTemplate)">{{template.name}}</button></td>
+            <td scope="row"><code>{{template.order}}</code></td>
+            <td scope="row">{{getSourceList(template)}}</td>
+            <td scope="row">
+                <div class="btn-group">
+                    <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-primary" type="button" (click)="editTemplate(template)" i18n>Edit</button>
+                    <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)" i18n>Delete</button>
+                </div>
+            </td>
+        </tr>
+    </tbody>
+</table>
+<div *ngIf="templates.length === 0" i18n>No templates defined.</div>
diff --git a/src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.scss b/src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.spec.ts b/src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.spec.ts
new file mode 100644 (file)
index 0000000..fb971fa
--- /dev/null
@@ -0,0 +1,175 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { By } from '@angular/platform-browser'
+import {
+  NgbModal,
+  NgbPaginationModule,
+  NgbModalRef,
+  NgbModalModule,
+} from '@ng-bootstrap/ng-bootstrap'
+import { of, throwError } from 'rxjs'
+import {
+  DocumentSource,
+  PaperlessConsumptionTemplate,
+} from 'src/app/data/paperless-consumption-template'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { ConsumptionTemplatesListComponent } from './consumption-templates-list.component'
+import { ConsumptionTemplateEditDialogComponent } from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
+import { PermissionsService } from 'src/app/services/permissions.service'
+
+const templates: PaperlessConsumptionTemplate[] = [
+  {
+    id: 0,
+    name: 'Template 1',
+    order: 0,
+    sources: [
+      DocumentSource.ConsumeFolder,
+      DocumentSource.ApiUpload,
+      DocumentSource.MailFetch,
+    ],
+    filter_filename: 'foo',
+    filter_path: 'bar',
+    assign_tags: [1, 2, 3],
+  },
+  {
+    id: 1,
+    name: 'Template 2',
+    order: 1,
+    sources: [DocumentSource.MailFetch],
+    filter_filename: null,
+    filter_path: 'foo/bar',
+    assign_owner: 1,
+  },
+]
+
+describe('ConsumptionTemplatesComponent', () => {
+  let component: ConsumptionTemplatesListComponent
+  let fixture: ComponentFixture<ConsumptionTemplatesListComponent>
+  let consumptionTemplateService: ConsumptionTemplateService
+  let modalService: NgbModal
+  let toastService: ToastService
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      declarations: [
+        ConsumptionTemplatesListComponent,
+        IfPermissionsDirective,
+        PageHeaderComponent,
+        ConfirmDialogComponent,
+      ],
+      providers: [
+        {
+          provide: PermissionsService,
+          useValue: {
+            currentUserCan: () => true,
+            currentUserHasObjectPermissions: () => true,
+            currentUserOwnsObject: () => true,
+          },
+        },
+      ],
+      imports: [
+        HttpClientTestingModule,
+        NgbPaginationModule,
+        FormsModule,
+        ReactiveFormsModule,
+        NgbModalModule,
+      ],
+    })
+
+    consumptionTemplateService = TestBed.inject(ConsumptionTemplateService)
+    jest.spyOn(consumptionTemplateService, 'listAll').mockReturnValue(
+      of({
+        count: templates.length,
+        all: templates.map((o) => o.id),
+        results: templates,
+      })
+    )
+    modalService = TestBed.inject(NgbModal)
+    toastService = TestBed.inject(ToastService)
+
+    fixture = TestBed.createComponent(ConsumptionTemplatesListComponent)
+    component = fixture.componentInstance
+    fixture.detectChanges()
+  })
+
+  it('should support create, show notification on error / success', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const reloadSpy = jest.spyOn(component, 'reload')
+
+    const createButton = fixture.debugElement.queryAll(By.css('button'))[0]
+    createButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog =
+      modal.componentInstance as ConsumptionTemplateEditDialogComponent
+
+    // fail first
+    editDialog.failed.emit({ error: 'error creating item' })
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(reloadSpy).not.toHaveBeenCalled()
+
+    // succeed
+    editDialog.succeeded.emit(templates[0])
+    expect(toastInfoSpy).toHaveBeenCalled()
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should support edit, show notification on error / success', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const reloadSpy = jest.spyOn(component, 'reload')
+
+    const editButton = fixture.debugElement.queryAll(By.css('button'))[1]
+    editButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog =
+      modal.componentInstance as ConsumptionTemplateEditDialogComponent
+    expect(editDialog.object).toEqual(templates[0])
+
+    // fail first
+    editDialog.failed.emit({ error: 'error editing item' })
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(reloadSpy).not.toHaveBeenCalled()
+
+    // succeed
+    editDialog.succeeded.emit(templates[0])
+    expect(toastInfoSpy).toHaveBeenCalled()
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should support delete, show notification on error / success', () => {
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const deleteSpy = jest.spyOn(consumptionTemplateService, 'delete')
+    const reloadSpy = jest.spyOn(component, 'reload')
+
+    const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]
+    deleteButton.triggerEventHandler('click')
+
+    expect(modal).not.toBeUndefined()
+    const editDialog = modal.componentInstance as ConfirmDialogComponent
+
+    // fail first
+    deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
+    editDialog.confirmClicked.emit()
+    expect(toastErrorSpy).toHaveBeenCalled()
+    expect(reloadSpy).not.toHaveBeenCalled()
+
+    // succeed
+    deleteSpy.mockReturnValueOnce(of(true))
+    editDialog.confirmClicked.emit()
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+})
diff --git a/src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts b/src-ui/src/app/components/manage/consumption-templates-list/consumption-templates-list.component.ts
new file mode 100644 (file)
index 0000000..3682061
--- /dev/null
@@ -0,0 +1,109 @@
+import { Component, OnInit } from '@angular/core'
+import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
+import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
+import { Subject, takeUntil } from 'rxjs'
+import { PaperlessConsumptionTemplate } from 'src/app/data/paperless-consumption-template'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { ToastService } from 'src/app/services/toast.service'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import {
+  ConsumptionTemplateEditDialogComponent,
+  DOCUMENT_SOURCE_OPTIONS,
+} from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
+import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
+import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
+
+@Component({
+  selector: 'pngx-consumption-templates-list',
+  templateUrl: './consumption-templates-list.component.html',
+  styleUrls: ['./consumption-templates-list.component.scss'],
+})
+export class ConsumptionTemplatesListComponent
+  extends ComponentWithPermissions
+  implements OnInit
+{
+  public templates: PaperlessConsumptionTemplate[] = []
+
+  private unsubscribeNotifier: Subject<any> = new Subject()
+
+  constructor(
+    private consumptionTemplateService: ConsumptionTemplateService,
+    public permissionsService: PermissionsService,
+    private modalService: NgbModal,
+    private toastService: ToastService
+  ) {
+    super()
+  }
+
+  ngOnInit() {
+    this.reload()
+  }
+
+  reload() {
+    this.consumptionTemplateService
+      .listAll()
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((r) => {
+        this.templates = r.results
+      })
+  }
+
+  getSourceList(template: PaperlessConsumptionTemplate): string {
+    return template.sources
+      .map((id) => DOCUMENT_SOURCE_OPTIONS.find((s) => s.id === id).name)
+      .join(', ')
+  }
+
+  editTemplate(rule: PaperlessConsumptionTemplate) {
+    const modal = this.modalService.open(
+      ConsumptionTemplateEditDialogComponent,
+      {
+        backdrop: 'static',
+        size: 'xl',
+      }
+    )
+    modal.componentInstance.dialogMode = rule
+      ? EditDialogMode.EDIT
+      : EditDialogMode.CREATE
+    modal.componentInstance.object = rule
+    modal.componentInstance.succeeded
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((newTemplate) => {
+        this.toastService.showInfo(
+          $localize`Saved template "${newTemplate.name}".`
+        )
+        this.consumptionTemplateService.clearCache()
+        this.reload()
+      })
+    modal.componentInstance.failed
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe((e) => {
+        this.toastService.showError($localize`Error saving template.`, e)
+      })
+  }
+
+  deleteTemplate(rule: PaperlessConsumptionTemplate) {
+    const modal = this.modalService.open(ConfirmDialogComponent, {
+      backdrop: 'static',
+    })
+    modal.componentInstance.title = $localize`Confirm delete template`
+    modal.componentInstance.messageBold = $localize`This operation will permanently delete this template.`
+    modal.componentInstance.message = $localize`This operation cannot be undone.`
+    modal.componentInstance.btnClass = 'btn-danger'
+    modal.componentInstance.btnCaption = $localize`Proceed`
+    modal.componentInstance.confirmClicked.subscribe(() => {
+      modal.componentInstance.buttonsEnabled = false
+      this.consumptionTemplateService.delete(rule).subscribe({
+        next: () => {
+          modal.close()
+          this.toastService.showInfo($localize`Deleted template`)
+          this.consumptionTemplateService.clearCache()
+          this.reload()
+        },
+        error: (e) => {
+          this.toastService.showError($localize`Error deleting template.`, e)
+        },
+      })
+    })
+  }
+}
diff --git a/src-ui/src/app/data/paperless-consumption-template.ts b/src-ui/src/app/data/paperless-consumption-template.ts
new file mode 100644 (file)
index 0000000..c303fc8
--- /dev/null
@@ -0,0 +1,41 @@
+import { ObjectWithId } from './object-with-id'
+
+export enum DocumentSource {
+  ConsumeFolder = 1,
+  ApiUpload = 2,
+  MailFetch = 3,
+}
+
+export interface PaperlessConsumptionTemplate extends ObjectWithId {
+  name: string
+
+  order: number
+
+  sources: DocumentSource[]
+
+  filter_filename: string
+
+  filter_path?: string
+
+  filter_mailrule?: number // PaperlessMailRule.id
+
+  assign_title?: string
+
+  assign_tags?: number[] // PaperlessTag.id
+
+  assign_document_type?: number // PaperlessDocumentType.id
+
+  assign_correspondent?: number // PaperlessCorrespondent.id
+
+  assign_storage_path?: number // PaperlessStoragePath.id
+
+  assign_owner?: number // PaperlessUser.id
+
+  assign_view_users?: number[] // [PaperlessUser.id]
+
+  assign_view_groups?: number[] // [PaperlessGroup.id]
+
+  assign_change_users?: number[] // [PaperlessUser.id]
+
+  assign_change_groups?: number[] // [PaperlessGroup.id]
+}
index 63351fe3e5f744ba04d4ebf3470ef2b3a3c2d0ac..cc1bea0e7b959c129862b81c3ac912c3fc861f5d 100644 (file)
@@ -22,6 +22,7 @@ export enum MailAction {
 export enum MailMetadataTitleOption {
   FromSubject = 1,
   FromFilename = 2,
+  None = 3,
 }
 
 export enum MailMetadataCorrespondentOption {
@@ -67,4 +68,6 @@ export interface PaperlessMailRule extends ObjectWithPermissions {
   assign_correspondent_from?: MailMetadataCorrespondentOption
 
   assign_correspondent?: number // PaperlessCorrespondent.id
+
+  assign_owner_from_rule: boolean
 }
index 1cd64ebf990faed8792904046454824f08e0c159..72c3f75791311e2d13d397c39cc97bd96e70c9ae 100644 (file)
@@ -1,4 +1,3 @@
-import { PaperlessGroup } from 'src/app/data/paperless-group'
 import { ObjectWithId } from './object-with-id'
 
 export interface PaperlessUser extends ObjectWithId {
index 9e7c43df4e1af10858c2c7e5f17b0bf3aa7b034d..820b9ac1f119ce5fe4f08e6ee894925e9eb74707 100644 (file)
@@ -252,6 +252,10 @@ describe('PermissionsService', () => {
         'view_sharelink',
         'change_sharelink',
         'delete_sharelink',
+        'add_consumptiontemplate',
+        'view_consumptiontemplate',
+        'change_consumptiontemplate',
+        'delete_consumptiontemplate',
       ],
       {
         username: 'testuser',
index 1f4df374832c115954657930fda04321083f86e0..b22e5617725c0ba2befd1eb1326bc2467bda7aea 100644 (file)
@@ -25,6 +25,7 @@ export enum PermissionType {
   Group = '%s_group',
   Admin = '%s_logentry',
   ShareLink = '%s_sharelink',
+  ConsumptionTemplate = '%s_consumptiontemplate',
 }
 
 @Injectable({
diff --git a/src-ui/src/app/services/rest/consumption-template.service.spec.ts b/src-ui/src/app/services/rest/consumption-template.service.spec.ts
new file mode 100644 (file)
index 0000000..471007f
--- /dev/null
@@ -0,0 +1,64 @@
+import { HttpTestingController } from '@angular/common/http/testing'
+import { TestBed } from '@angular/core/testing'
+import { Subscription } from 'rxjs'
+import { environment } from 'src/environments/environment'
+import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
+import { ConsumptionTemplateService } from './consumption-template.service'
+import {
+  DocumentSource,
+  PaperlessConsumptionTemplate,
+} from 'src/app/data/paperless-consumption-template'
+
+let httpTestingController: HttpTestingController
+let service: ConsumptionTemplateService
+const endpoint = 'consumption_templates'
+const templates: PaperlessConsumptionTemplate[] = [
+  {
+    name: 'Template 1',
+    id: 1,
+    order: 1,
+    filter_filename: '*test*',
+    filter_path: null,
+    sources: [DocumentSource.ApiUpload],
+    assign_correspondent: 2,
+  },
+  {
+    name: 'Template 2',
+    id: 2,
+    order: 2,
+    filter_filename: null,
+    filter_path: '/test/',
+    sources: [DocumentSource.ConsumeFolder, DocumentSource.ApiUpload],
+    assign_document_type: 1,
+  },
+]
+
+// run common tests
+commonAbstractPaperlessServiceTests(
+  'consumption_templates',
+  ConsumptionTemplateService
+)
+
+describe(`Additional service tests for ConsumptionTemplateService`, () => {
+  it('should reload', () => {
+    service.reload()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+    )
+    req.flush({
+      results: templates,
+    })
+    expect(service.allTemplates).toEqual(templates)
+  })
+
+  beforeEach(() => {
+    // Dont need to setup again
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(ConsumptionTemplateService)
+  })
+
+  afterEach(() => {
+    httpTestingController.verify()
+  })
+})
diff --git a/src-ui/src/app/services/rest/consumption-template.service.ts b/src-ui/src/app/services/rest/consumption-template.service.ts
new file mode 100644 (file)
index 0000000..e0181a2
--- /dev/null
@@ -0,0 +1,42 @@
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { tap } from 'rxjs'
+import { PaperlessConsumptionTemplate } from 'src/app/data/paperless-consumption-template'
+import { AbstractPaperlessService } from './abstract-paperless-service'
+
+@Injectable({
+  providedIn: 'root',
+})
+export class ConsumptionTemplateService extends AbstractPaperlessService<PaperlessConsumptionTemplate> {
+  loading: boolean
+
+  constructor(http: HttpClient) {
+    super(http, 'consumption_templates')
+  }
+
+  public reload() {
+    this.loading = true
+    this.listAll().subscribe((r) => {
+      this.templates = r.results
+      this.loading = false
+    })
+  }
+
+  private templates: PaperlessConsumptionTemplate[] = []
+
+  public get allTemplates(): PaperlessConsumptionTemplate[] {
+    return this.templates
+  }
+
+  create(o: PaperlessConsumptionTemplate) {
+    return super.create(o).pipe(tap(() => this.reload()))
+  }
+
+  update(o: PaperlessConsumptionTemplate) {
+    return super.update(o).pipe(tap(() => this.reload()))
+  }
+
+  delete(o: PaperlessConsumptionTemplate) {
+    return super.delete(o).pipe(tap(() => this.reload()))
+  }
+}
index daff57341f051da3a923556545f83d28dc8ee697..a838271824c6488d04feec94f9cc0277b6bd33f0 100644 (file)
@@ -28,6 +28,7 @@ const mail_rules = [
     attachment_type: MailFilterAttachmentType.Everything,
     action: MailAction.MarkRead,
     assign_title_from: MailMetadataTitleOption.FromSubject,
+    assign_owner_from_rule: true,
   },
   {
     name: 'Mail Rule 2',
@@ -44,6 +45,7 @@ const mail_rules = [
     attachment_type: MailFilterAttachmentType.Everything,
     action: MailAction.Delete,
     assign_title_from: MailMetadataTitleOption.FromSubject,
+    assign_owner_from_rule: true,
   },
   {
     name: 'Mail Rule 3',
@@ -60,6 +62,7 @@ const mail_rules = [
     attachment_type: MailFilterAttachmentType.Everything,
     action: MailAction.Flag,
     assign_title_from: MailMetadataTitleOption.FromSubject,
+    assign_owner_from_rule: false,
   },
 ]
 
index ffecca62f8b614601cfe2c27f23ecdc3d442cc75..7aa71c21ddfa3701b05b30b6141c2e77cf713340 100644 (file)
@@ -170,7 +170,8 @@ a, a:hover,
 }
 
 .btn-link:hover,
-.btn-link:active {
+.btn-link:active,
+.btn-link:focus-visible {
   color: var(--pngx-primary-darken-15) !important;
 }
 
@@ -456,7 +457,7 @@ ul.pagination {
 }
 
 table.table {
-  color: var(--bs-body-color);
+  --bs-table-color: var(--bs-body-color);
   --bs-table-bg: var(--bs-light-rgb);
 
   .des,.asc {
index 3f83e0f5065c6036bb19c2f230fd5a680bcb8b7b..e618f0bb89de3843aef16ba38a3d7350be76b400 100644 (file)
@@ -20,6 +20,10 @@ from django.utils import timezone
 from filelock import FileLock
 from rest_framework.reverse import reverse
 
+from documents.data_models import ConsumableDocument
+from documents.data_models import DocumentMetadataOverrides
+from documents.matching import document_matches_template
+from documents.permissions import set_permissions_for_object
 from documents.utils import copy_basic_file_stats
 from documents.utils import copy_file_with_basic_stats
 
@@ -27,10 +31,12 @@ from .classifier import load_classifier
 from .file_handling import create_source_path_directory
 from .file_handling import generate_unique_filename
 from .loggers import LoggingMixin
+from .models import ConsumptionTemplate
 from .models import Correspondent
 from .models import Document
 from .models import DocumentType
 from .models import FileInfo
+from .models import StoragePath
 from .models import Tag
 from .parsers import DocumentParser
 from .parsers import ParseError
@@ -319,10 +325,15 @@ class Consumer(LoggingMixin):
         override_correspondent_id=None,
         override_document_type_id=None,
         override_tag_ids=None,
+        override_storage_path_id=None,
         task_id=None,
         override_created=None,
         override_asn=None,
         override_owner_id=None,
+        override_view_users=None,
+        override_view_groups=None,
+        override_change_users=None,
+        override_change_groups=None,
     ) -> Document:
         """
         Return the document object if it was successfully created.
@@ -334,10 +345,15 @@ class Consumer(LoggingMixin):
         self.override_correspondent_id = override_correspondent_id
         self.override_document_type_id = override_document_type_id
         self.override_tag_ids = override_tag_ids
+        self.override_storage_path_id = override_storage_path_id
         self.task_id = task_id or str(uuid.uuid4())
         self.override_created = override_created
         self.override_asn = override_asn
         self.override_owner_id = override_owner_id
+        self.override_view_users = override_view_users
+        self.override_view_groups = override_view_groups
+        self.override_change_users = override_change_users
+        self.override_change_groups = override_change_groups
 
         self._send_progress(
             0,
@@ -578,6 +594,92 @@ class Consumer(LoggingMixin):
 
         return document
 
+    def get_template_overrides(
+        self,
+        input_doc: ConsumableDocument,
+    ) -> DocumentMetadataOverrides:
+        """
+        Match consumption templates to a document based on source and
+        file name filters, path filters or mail rule filter if specified
+        """
+        overrides = DocumentMetadataOverrides()
+        for template in ConsumptionTemplate.objects.all().order_by("order"):
+            template_overrides = DocumentMetadataOverrides()
+
+            if document_matches_template(input_doc, template):
+                if template.assign_title is not None:
+                    template_overrides.title = template.assign_title
+                if template.assign_tags is not None:
+                    template_overrides.tag_ids = [
+                        tag.pk for tag in template.assign_tags.all()
+                    ]
+                if template.assign_correspondent is not None:
+                    template_overrides.correspondent_id = (
+                        template.assign_correspondent.pk
+                    )
+                if template.assign_document_type is not None:
+                    template_overrides.document_type_id = (
+                        template.assign_document_type.pk
+                    )
+                if template.assign_storage_path is not None:
+                    template_overrides.storage_path_id = template.assign_storage_path.pk
+                if template.assign_owner is not None:
+                    template_overrides.owner_id = template.assign_owner.pk
+                if template.assign_view_users is not None:
+                    template_overrides.view_users = [
+                        user.pk for user in template.assign_view_users.all()
+                    ]
+                if template.assign_view_groups is not None:
+                    template_overrides.view_groups = [
+                        group.pk for group in template.assign_view_groups.all()
+                    ]
+                if template.assign_change_users is not None:
+                    template_overrides.change_users = [
+                        user.pk for user in template.assign_change_users.all()
+                    ]
+                if template.assign_change_groups is not None:
+                    template_overrides.change_groups = [
+                        group.pk for group in template.assign_change_groups.all()
+                    ]
+                overrides.update(template_overrides)
+        return overrides
+
+    def _parse_title_placeholders(self, title: str) -> str:
+        """
+        Consumption template title placeholders can only include items that are
+        assigned as part of this template (since auto-matching hasnt happened yet)
+        """
+        local_added = timezone.localtime(timezone.now())
+
+        correspondent_name = (
+            Correspondent.objects.get(pk=self.override_correspondent_id).name
+            if self.override_correspondent_id is not None
+            else None
+        )
+        doc_type_name = (
+            DocumentType.objects.get(pk=self.override_document_type_id).name
+            if self.override_correspondent_id is not None
+            else None
+        )
+        owner_username = (
+            User.objects.get(pk=self.override_owner_id).username
+            if self.override_owner_id is not None
+            else None
+        )
+
+        return title.format(
+            correspondent=correspondent_name,
+            document_type=doc_type_name,
+            added=local_added.isoformat(),
+            added_year=local_added.strftime("%Y"),
+            added_year_short=local_added.strftime("%y"),
+            added_month=local_added.strftime("%m"),
+            added_month_name=local_added.strftime("%B"),
+            added_month_name_short=local_added.strftime("%b"),
+            added_day=local_added.strftime("%d"),
+            owner_username=owner_username,
+        ).strip()
+
     def _store(
         self,
         text: str,
@@ -612,7 +714,11 @@ class Consumer(LoggingMixin):
 
         with open(self.path, "rb") as f:
             document = Document.objects.create(
-                title=(self.override_title or file_info.title)[:127],
+                title=(
+                    self._parse_title_placeholders(self.override_title)
+                    if self.override_title is not None
+                    else file_info.title
+                )[:127],
                 content=text,
                 mime_type=mime_type,
                 checksum=hashlib.md5(f.read()).hexdigest(),
@@ -643,6 +749,11 @@ class Consumer(LoggingMixin):
             for tag_id in self.override_tag_ids:
                 document.tags.add(Tag.objects.get(pk=tag_id))
 
+        if self.override_storage_path_id:
+            document.storage_path = StoragePath.objects.get(
+                pk=self.override_storage_path_id,
+            )
+
         if self.override_asn:
             document.archive_serial_number = self.override_asn
 
@@ -651,6 +762,24 @@ class Consumer(LoggingMixin):
                 pk=self.override_owner_id,
             )
 
+        if (
+            self.override_view_users is not None
+            or self.override_view_groups is not None
+            or self.override_change_users is not None
+            or self.override_change_users is not None
+        ):
+            permissions = {
+                "view": {
+                    "users": self.override_view_users or [],
+                    "groups": self.override_view_groups or [],
+                },
+                "change": {
+                    "users": self.override_change_users or [],
+                    "groups": self.override_change_groups or [],
+                },
+            }
+            set_permissions_for_object(permissions=permissions, object=document)
+
     def _write(self, storage_type, source, target):
         with open(source, "rb") as read_file, open(target, "wb") as write_file:
             write_file.write(read_file.read())
index d8995287fd2fdf167dd76941e806930d75215354..7dcfe4401b73e140ee71df13f42a4cd2a46acf25 100644 (file)
@@ -1,6 +1,6 @@
 import dataclasses
 import datetime
-import enum
+from enum import IntEnum
 from pathlib import Path
 from typing import Optional
 
@@ -20,19 +20,70 @@ class DocumentMetadataOverrides:
     correspondent_id: Optional[int] = None
     document_type_id: Optional[int] = None
     tag_ids: Optional[list[int]] = None
+    storage_path_id: Optional[int] = None
     created: Optional[datetime.datetime] = None
     asn: Optional[int] = None
     owner_id: Optional[int] = None
+    view_users: Optional[list[int]] = None
+    view_groups: Optional[list[int]] = None
+    change_users: Optional[list[int]] = None
+    change_groups: Optional[list[int]] = None
+
+    def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
+        """
+        Merges two DocumentMetadataOverrides objects such that object B's overrides
+        are only applied if the property is empty in object A or merged if multiple
+        are accepted.
+
+        The update is an in-place modification of self
+        """
+        # only if empty
+        if self.title is None:
+            self.title = other.title
+        if self.correspondent_id is None:
+            self.correspondent_id = other.correspondent_id
+        if self.document_type_id is None:
+            self.document_type_id = other.document_type_id
+        if self.storage_path_id is None:
+            self.storage_path_id = other.storage_path_id
+        if self.owner_id is None:
+            self.owner_id = other.owner_id
+        # merge
+        # TODO: Handle the case where other is also None
+        if self.tag_ids is None:
+            self.tag_ids = other.tag_ids
+        else:
+            self.tag_ids.extend(other.tag_ids)
+        if self.view_users is None:
+            self.view_users = other.view_users
+        else:
+            self.view_users.extend(other.view_users)
+        if self.view_groups is None:
+            self.view_groups = other.view_groups
+        else:
+            self.view_groups.extend(other.view_groups)
+        if self.change_users is None:
+            self.change_users = other.change_users
+        else:
+            self.change_users.extend(other.change_users)
+        if self.change_groups is None:
+            self.change_groups = other.change_groups
+        else:
+            self.change_groups = [
+                *self.change_groups,
+                *other.change_groups,
+            ]
+        return self
 
 
-class DocumentSource(enum.IntEnum):
+class DocumentSource(IntEnum):
     """
     The source of an incoming document.  May have other uses in the future
     """
 
-    ConsumeFolder = enum.auto()
-    ApiUpload = enum.auto()
-    MailFetch = enum.auto()
+    ConsumeFolder = 1
+    ApiUpload = 2
+    MailFetch = 3
 
 
 @dataclasses.dataclass
@@ -44,6 +95,7 @@ class ConsumableDocument:
 
     source: DocumentSource
     original_file: Path
+    mailrule_id: Optional[int] = None
     mime_type: str = dataclasses.field(init=False, default=None)
 
     def __post_init__(self):
index eb0f4f8b527f944f8bf18a57398e5613521b1282..9c6e11ca7aab563bca70d5bb585bfdfc717c85ce 100644 (file)
@@ -1,7 +1,11 @@
 import logging
 import re
+from fnmatch import fnmatch
 
 from documents.classifier import DocumentClassifier
+from documents.data_models import ConsumableDocument
+from documents.data_models import DocumentSource
+from documents.models import ConsumptionTemplate
 from documents.models import Correspondent
 from documents.models import Document
 from documents.models import DocumentType
@@ -231,3 +235,67 @@ def _split_match(matching_model):
         re.escape(normspace(" ", (t[0] or t[1]).strip())).replace(r"\ ", r"\s+")
         for t in findterms(matching_model.match)
     ]
+
+
+def document_matches_template(
+    document: ConsumableDocument,
+    template: ConsumptionTemplate,
+) -> bool:
+    """
+    Returns True if the incoming document matches all filters and
+    settings from the template, False otherwise
+    """
+
+    def log_match_failure(reason: str):
+        logger.info(f"Document did not match template {template.name}")
+        logger.debug(reason)
+
+    # Document source vs template source
+    if document.source not in [int(x) for x in list(template.sources)]:
+        log_match_failure(
+            f"Document source {document.source.name} not in"
+            f" {[DocumentSource(int(x)).name for x in template.sources]}",
+        )
+        return False
+
+    # Document mail rule vs template mail rule
+    if (
+        document.mailrule_id is not None
+        and template.filter_mailrule is not None
+        and document.mailrule_id != template.filter_mailrule.pk
+    ):
+        log_match_failure(
+            f"Document mail rule {document.mailrule_id}"
+            f" != {template.filter_mailrule.pk}",
+        )
+        return False
+
+    # Document filename vs template filename
+    if (
+        template.filter_filename is not None
+        and len(template.filter_filename) > 0
+        and not fnmatch(
+            document.original_file.name.lower(),
+            template.filter_filename.lower(),
+        )
+    ):
+        log_match_failure(
+            f"Document filename {document.original_file.name} does not match"
+            f" {template.filter_filename.lower()}",
+        )
+        return False
+
+    # Document path vs template path
+    if (
+        template.filter_path is not None
+        and len(template.filter_path) > 0
+        and not document.original_file.match(template.filter_path)
+    ):
+        log_match_failure(
+            f"Document path {document.original_file}"
+            f" does not match {template.filter_path}",
+        )
+        return False
+
+    logger.info(f"Document matched template {template.name}")
+    return True
diff --git a/src/documents/migrations/1039_consumptiontemplate.py b/src/documents/migrations/1039_consumptiontemplate.py
new file mode 100644 (file)
index 0000000..cf8b9fd
--- /dev/null
@@ -0,0 +1,219 @@
+# Generated by Django 4.1.11 on 2023-09-16 18:04
+
+import django.db.models.deletion
+import multiselectfield.db.fields
+from django.conf import settings
+from django.contrib.auth.management import create_permissions
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import Permission
+from django.contrib.auth.models import User
+from django.db import migrations
+from django.db import models
+from django.db.models import Q
+
+
+def add_consumptiontemplate_permissions(apps, schema_editor):
+    # create permissions without waiting for post_migrate signal
+    for app_config in apps.get_app_configs():
+        app_config.models_module = True
+        create_permissions(app_config, apps=apps, verbosity=0)
+        app_config.models_module = None
+
+    add_permission = Permission.objects.get(codename="add_document")
+    consumptiontemplate_permissions = Permission.objects.filter(
+        codename__contains="consumptiontemplate",
+    )
+
+    for user in User.objects.filter(Q(user_permissions=add_permission)).distinct():
+        user.user_permissions.add(*consumptiontemplate_permissions)
+
+    for group in Group.objects.filter(Q(permissions=add_permission)).distinct():
+        group.permissions.add(*consumptiontemplate_permissions)
+
+
+def remove_consumptiontemplate_permissions(apps, schema_editor):
+    consumptiontemplate_permissions = Permission.objects.filter(
+        codename__contains="consumptiontemplate",
+    )
+
+    for user in User.objects.all():
+        user.user_permissions.remove(*consumptiontemplate_permissions)
+
+    for group in Group.objects.all():
+        group.permissions.remove(*consumptiontemplate_permissions)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ("auth", "0012_alter_user_first_name_max_length"),
+        ("documents", "1038_sharelink"),
+        ("paperless_mail", "0021_alter_mailaccount_password"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="ConsumptionTemplate",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "name",
+                    models.CharField(max_length=256, unique=True, verbose_name="name"),
+                ),
+                ("order", models.IntegerField(default=0, verbose_name="order")),
+                (
+                    "sources",
+                    multiselectfield.db.fields.MultiSelectField(
+                        choices=[
+                            (1, "Consume Folder"),
+                            (2, "Api Upload"),
+                            (3, "Mail Fetch"),
+                        ],
+                        default="1,2,3",
+                        max_length=3,
+                    ),
+                ),
+                (
+                    "filter_path",
+                    models.CharField(
+                        blank=True,
+                        help_text="Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.",
+                        max_length=256,
+                        null=True,
+                        verbose_name="filter path",
+                    ),
+                ),
+                (
+                    "filter_filename",
+                    models.CharField(
+                        blank=True,
+                        help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.",
+                        max_length=256,
+                        null=True,
+                        verbose_name="filter filename",
+                    ),
+                ),
+                (
+                    "filter_mailrule",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="paperless_mail.mailrule",
+                        verbose_name="filter documents from this mail rule",
+                    ),
+                ),
+                (
+                    "assign_change_groups",
+                    models.ManyToManyField(
+                        blank=True,
+                        related_name="+",
+                        to="auth.group",
+                        verbose_name="grant change permissions to these groups",
+                    ),
+                ),
+                (
+                    "assign_change_users",
+                    models.ManyToManyField(
+                        blank=True,
+                        related_name="+",
+                        to=settings.AUTH_USER_MODEL,
+                        verbose_name="grant change permissions to these users",
+                    ),
+                ),
+                (
+                    "assign_correspondent",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="documents.correspondent",
+                        verbose_name="assign this correspondent",
+                    ),
+                ),
+                (
+                    "assign_document_type",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="documents.documenttype",
+                        verbose_name="assign this document type",
+                    ),
+                ),
+                (
+                    "assign_owner",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to=settings.AUTH_USER_MODEL,
+                        verbose_name="assign this owner",
+                    ),
+                ),
+                (
+                    "assign_storage_path",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="documents.storagepath",
+                        verbose_name="assign this storage path",
+                    ),
+                ),
+                (
+                    "assign_tags",
+                    models.ManyToManyField(
+                        blank=True,
+                        to="documents.tag",
+                        verbose_name="assign this tag",
+                    ),
+                ),
+                (
+                    "assign_title",
+                    models.CharField(
+                        blank=True,
+                        help_text="Assign a document title, can include some placeholders, see documentation.",
+                        max_length=256,
+                        null=True,
+                        verbose_name="assign title",
+                    ),
+                ),
+                (
+                    "assign_view_groups",
+                    models.ManyToManyField(
+                        blank=True,
+                        related_name="+",
+                        to="auth.group",
+                        verbose_name="grant view permissions to these groups",
+                    ),
+                ),
+                (
+                    "assign_view_users",
+                    models.ManyToManyField(
+                        blank=True,
+                        related_name="+",
+                        to=settings.AUTH_USER_MODEL,
+                        verbose_name="grant view permissions to these users",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "consumption template",
+                "verbose_name_plural": "consumption templates",
+            },
+        ),
+        migrations.RunPython(
+            add_consumptiontemplate_permissions,
+            remove_consumptiontemplate_permissions,
+        ),
+    ]
index b7c188d348c6440f6f2c1442baca10d58e233a87..a1f7d7dd618f7253381afd308eed17db42047793 100644 (file)
@@ -11,18 +11,18 @@ import dateutil.parser
 import pathvalidate
 from celery import states
 from django.conf import settings
+from django.contrib.auth.models import Group
 from django.contrib.auth.models import User
 from django.core.validators import MaxValueValidator
 from django.core.validators import MinValueValidator
 from django.db import models
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
+from multiselectfield import MultiSelectField
 
+from documents.data_models import DocumentSource
 from documents.parsers import get_default_file_extension
 
-ALL_STATES = sorted(states.ALL_STATES)
-TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
-
 
 class ModelWithOwner(models.Model):
     owner = models.ForeignKey(
@@ -572,6 +572,9 @@ class UiSettings(models.Model):
 
 
 class PaperlessTask(models.Model):
+    ALL_STATES = sorted(states.ALL_STATES)
+    TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
+
     task_id = models.CharField(
         max_length=255,
         unique=True,
@@ -735,3 +738,137 @@ class ShareLink(models.Model):
 
     def __str__(self):
         return f"Share Link for {self.document.title}"
+
+
+class ConsumptionTemplate(models.Model):
+    class DocumentSourceChoices(models.IntegerChoices):
+        CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
+        API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
+        MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
+
+    name = models.CharField(_("name"), max_length=256, unique=True)
+
+    order = models.IntegerField(_("order"), default=0)
+
+    sources = MultiSelectField(
+        max_length=3,
+        choices=DocumentSourceChoices.choices,
+        default=f"{DocumentSource.ConsumeFolder},{DocumentSource.ApiUpload},{DocumentSource.MailFetch}",
+    )
+
+    filter_path = models.CharField(
+        _("filter path"),
+        max_length=256,
+        null=True,
+        blank=True,
+        help_text=_(
+            "Only consume documents with a path that matches "
+            "this if specified. Wildcards specified as * are "
+            "allowed. Case insensitive.",
+        ),
+    )
+
+    filter_filename = models.CharField(
+        _("filter filename"),
+        max_length=256,
+        null=True,
+        blank=True,
+        help_text=_(
+            "Only consume documents which entirely match this "
+            "filename if specified. Wildcards such as *.pdf or "
+            "*invoice* are allowed. Case insensitive.",
+        ),
+    )
+
+    filter_mailrule = models.ForeignKey(
+        "paperless_mail.MailRule",
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        verbose_name=_("filter documents from this mail rule"),
+    )
+
+    assign_title = models.CharField(
+        _("assign title"),
+        max_length=256,
+        null=True,
+        blank=True,
+        help_text=_(
+            "Assign a document title, can include some placeholders, "
+            "see documentation.",
+        ),
+    )
+
+    assign_tags = models.ManyToManyField(
+        Tag,
+        blank=True,
+        verbose_name=_("assign this tag"),
+    )
+
+    assign_document_type = models.ForeignKey(
+        DocumentType,
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        verbose_name=_("assign this document type"),
+    )
+
+    assign_correspondent = models.ForeignKey(
+        Correspondent,
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        verbose_name=_("assign this correspondent"),
+    )
+
+    assign_storage_path = models.ForeignKey(
+        StoragePath,
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        verbose_name=_("assign this storage path"),
+    )
+
+    assign_owner = models.ForeignKey(
+        User,
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        related_name="+",
+        verbose_name=_("assign this owner"),
+    )
+
+    assign_view_users = models.ManyToManyField(
+        User,
+        blank=True,
+        related_name="+",
+        verbose_name=_("grant view permissions to these users"),
+    )
+
+    assign_view_groups = models.ManyToManyField(
+        Group,
+        blank=True,
+        related_name="+",
+        verbose_name=_("grant view permissions to these groups"),
+    )
+
+    assign_change_users = models.ManyToManyField(
+        User,
+        blank=True,
+        related_name="+",
+        verbose_name=_("grant change permissions to these users"),
+    )
+
+    assign_change_groups = models.ManyToManyField(
+        Group,
+        blank=True,
+        related_name="+",
+        verbose_name=_("grant change permissions to these groups"),
+    )
+
+    class Meta:
+        verbose_name = _("consumption template")
+        verbose_name_plural = _("consumption templates")
+
+    def __str__(self):
+        return f"{self.name}"
index 0f99d5dcc4f1015f441810996f0fa220552ee176..00fc4b73b018d91bfefec8776e364b4154d4d62c 100644 (file)
@@ -13,9 +13,12 @@ from django.utils.text import slugify
 from django.utils.translation import gettext as _
 from guardian.core import ObjectPermissionChecker
 from guardian.shortcuts import get_users_with_perms
+from rest_framework import fields
 from rest_framework import serializers
 from rest_framework.fields import SerializerMethodField
 
+from documents.data_models import DocumentSource
+from documents.models import ConsumptionTemplate
 from documents.permissions import get_groups_with_only_permission
 from documents.permissions import set_permissions_for_object
 
@@ -1035,3 +1038,56 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
             self._validate_permissions(permissions)
 
         return attrs
+
+
+class ConsumptionTemplateSerializer(serializers.ModelSerializer):
+    order = serializers.IntegerField(required=False)
+    sources = fields.MultipleChoiceField(
+        choices=ConsumptionTemplate.DocumentSourceChoices.choices,
+        allow_empty=False,
+        default={
+            DocumentSource.ConsumeFolder,
+            DocumentSource.ApiUpload,
+            DocumentSource.MailFetch,
+        },
+    )
+    assign_correspondent = CorrespondentField(allow_null=True, required=False)
+    assign_tags = TagsField(many=True, allow_null=True, required=False)
+    assign_document_type = DocumentTypeField(allow_null=True, required=False)
+    assign_storage_path = StoragePathField(allow_null=True, required=False)
+
+    class Meta:
+        model = ConsumptionTemplate
+        fields = [
+            "id",
+            "name",
+            "order",
+            "sources",
+            "filter_path",
+            "filter_filename",
+            "filter_mailrule",
+            "assign_title",
+            "assign_tags",
+            "assign_correspondent",
+            "assign_document_type",
+            "assign_storage_path",
+            "assign_owner",
+            "assign_view_users",
+            "assign_view_groups",
+            "assign_change_users",
+            "assign_change_groups",
+        ]
+
+    def validate(self, attrs):
+        if ("filter_mailrule") in attrs and attrs["filter_mailrule"] is not None:
+            attrs["sources"] = {DocumentSource.MailFetch.value}
+        if (
+            ("filter_mailrule" not in attrs)
+            and ("filter_filename" not in attrs or len(attrs["filter_filename"]) == 0)
+            and ("filter_path" not in attrs or len(attrs["filter_path"]) == 0)
+        ):
+            raise serializers.ValidationError(
+                "File name, path or mail rule filter are required",
+            )
+
+        return attrs
index 6ecd30b42482651d8a6620f08fe8545875702c46..8aea56eaa032502241698cbf6bbf563925798804 100644 (file)
@@ -153,6 +153,12 @@ def consume_file(
                 overrides.asn = reader.asn
                 logger.info(f"Found ASN in barcode: {overrides.asn}")
 
+    template_overrides = Consumer().get_template_overrides(
+        input_doc=input_doc,
+    )
+
+    overrides.update(template_overrides)
+
     # continue with consumption if no barcode was found
     document = Consumer().try_consume_file(
         input_doc.original_file,
@@ -161,9 +167,14 @@ def consume_file(
         override_correspondent_id=overrides.correspondent_id,
         override_document_type_id=overrides.document_type_id,
         override_tag_ids=overrides.tag_ids,
+        override_storage_path_id=overrides.storage_path_id,
         override_created=overrides.created,
         override_asn=overrides.asn,
         override_owner_id=overrides.owner_id,
+        override_view_users=overrides.view_users,
+        override_view_groups=overrides.view_groups,
+        override_change_users=overrides.change_users,
+        override_change_groups=overrides.change_groups,
         task_id=self.request.id,
     )
 
index d4d6afe0416ab6e0028d49aade638c9a89e73d96..242bc5702ce211a02a81127ae89d59a7546f9995 100644 (file)
@@ -32,6 +32,8 @@ from whoosh.writing import AsyncWriter
 
 from documents import bulk_edit
 from documents import index
+from documents.data_models import DocumentSource
+from documents.models import ConsumptionTemplate
 from documents.models import Correspondent
 from documents.models import Document
 from documents.models import DocumentType
@@ -45,6 +47,8 @@ from documents.models import Tag
 from documents.tests.utils import DirectoriesMixin
 from documents.tests.utils import DocumentConsumeDelayMixin
 from paperless import version
+from paperless_mail.models import MailAccount
+from paperless_mail.models import MailRule
 
 
 class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
@@ -5313,3 +5317,168 @@ class TestBulkEditObjectPermissions(APITestCase):
         )
 
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+
+class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
+    ENDPOINT = "/api/consumption_templates/"
+
+    def setUp(self) -> None:
+        super().setUp()
+
+        user = User.objects.create_superuser(username="temp_admin")
+        self.client.force_authenticate(user=user)
+        self.user2 = User.objects.create(username="user2")
+        self.user3 = User.objects.create(username="user3")
+        self.group1 = Group.objects.create(name="group1")
+
+        self.c = Correspondent.objects.create(name="Correspondent Name")
+        self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
+        self.dt = DocumentType.objects.create(name="DocType Name")
+        self.t1 = Tag.objects.create(name="t1")
+        self.t2 = Tag.objects.create(name="t2")
+        self.t3 = Tag.objects.create(name="t3")
+        self.sp = StoragePath.objects.create(path="/test/")
+
+        self.ct = ConsumptionTemplate.objects.create(
+            name="Template 1",
+            order=0,
+            sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}",
+            filter_filename="*simple*",
+            filter_path="*/samples/*",
+            assign_title="Doc from {correspondent}",
+            assign_correspondent=self.c,
+            assign_document_type=self.dt,
+            assign_storage_path=self.sp,
+            assign_owner=self.user2,
+        )
+        self.ct.assign_tags.add(self.t1)
+        self.ct.assign_tags.add(self.t2)
+        self.ct.assign_tags.add(self.t3)
+        self.ct.assign_view_users.add(self.user3.pk)
+        self.ct.assign_view_groups.add(self.group1.pk)
+        self.ct.assign_change_users.add(self.user3.pk)
+        self.ct.assign_change_groups.add(self.group1.pk)
+        self.ct.save()
+
+    def test_api_get_consumption_template(self):
+        """
+        GIVEN:
+            - API request to get all consumption template
+        WHEN:
+            - API is called
+        THEN:
+            - Existing consumption templates are returned
+        """
+        response = self.client.get(self.ENDPOINT, format="json")
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data["count"], 1)
+
+        resp_consumption_template = response.data["results"][0]
+        self.assertEqual(resp_consumption_template["id"], self.ct.id)
+        self.assertEqual(
+            resp_consumption_template["assign_correspondent"],
+            self.ct.assign_correspondent.pk,
+        )
+
+    def test_api_create_consumption_template(self):
+        """
+        GIVEN:
+            - API request to create a consumption template
+        WHEN:
+            - API is called
+        THEN:
+            - Correct HTTP response
+            - New template is created
+        """
+        response = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "name": "Template 2",
+                    "order": 1,
+                    "sources": [DocumentSource.ApiUpload],
+                    "filter_filename": "*test*",
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(ConsumptionTemplate.objects.count(), 2)
+
+    def test_api_create_invalid_consumption_template(self):
+        """
+        GIVEN:
+            - API request to create a consumption template
+            - Neither file name nor path filter are specified
+        WHEN:
+            - API is called
+        THEN:
+            - Correct HTTP 400 response
+            - No template is created
+        """
+        response = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "name": "Template 2",
+                    "order": 1,
+                    "sources": [DocumentSource.ApiUpload],
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(StoragePath.objects.count(), 1)
+
+    def test_api_create_consumption_template_with_mailrule(self):
+        """
+        GIVEN:
+            - API request to create a consumption template with a mail rule but no MailFetch source
+        WHEN:
+            - API is called
+        THEN:
+            - Correct HTTP response
+            - New template is created with MailFetch as source
+        """
+        account1 = MailAccount.objects.create(
+            name="Email1",
+            username="username1",
+            password="password1",
+            imap_server="server.example.com",
+            imap_port=443,
+            imap_security=MailAccount.ImapSecurity.SSL,
+            character_set="UTF-8",
+        )
+        rule1 = MailRule.objects.create(
+            name="Rule1",
+            account=account1,
+            folder="INBOX",
+            filter_from="from@example.com",
+            filter_to="someone@somewhere.com",
+            filter_subject="subject",
+            filter_body="body",
+            filter_attachment_filename="file.pdf",
+            maximum_age=30,
+            action=MailRule.MailAction.MARK_READ,
+            assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
+            assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
+            order=0,
+            attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
+        )
+        response = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "name": "Template 2",
+                    "order": 1,
+                    "sources": [DocumentSource.ApiUpload],
+                    "filter_mailrule": rule1.pk,
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(ConsumptionTemplate.objects.count(), 2)
+        ct = ConsumptionTemplate.objects.get(name="Template 2")
+        self.assertEqual(ct.sources, [int(DocumentSource.MailFetch).__str__()])
index 70227b0db83e026f07c746b4ed35bd55ba05e922..7a4515018ab81235195606317db022559516fb4b 100644 (file)
@@ -11,9 +11,12 @@ from unittest.mock import MagicMock
 
 from dateutil import tz
 from django.conf import settings
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import User
 from django.test import TestCase
 from django.test import override_settings
 from django.utils import timezone
+from guardian.core import ObjectPermissionChecker
 
 from documents.consumer import Consumer
 from documents.consumer import ConsumerError
@@ -22,6 +25,7 @@ from documents.models import Correspondent
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import FileInfo
+from documents.models import StoragePath
 from documents.models import Tag
 from documents.parsers import DocumentParser
 from documents.parsers import ParseError
@@ -431,6 +435,16 @@ class TestConsumer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
         self.assertEqual(document.document_type.id, dt.id)
         self._assert_first_last_send_progress()
 
+    def testOverrideStoragePath(self):
+        sp = StoragePath.objects.create(name="test")
+
+        document = self.consumer.try_consume_file(
+            self.get_test_file(),
+            override_storage_path_id=sp.pk,
+        )
+        self.assertEqual(document.storage_path.id, sp.id)
+        self._assert_first_last_send_progress()
+
     def testOverrideTags(self):
         t1 = Tag.objects.create(name="t1")
         t2 = Tag.objects.create(name="t2")
@@ -445,6 +459,51 @@ class TestConsumer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
         self.assertIn(t3, document.tags.all())
         self._assert_first_last_send_progress()
 
+    def testOverrideAsn(self):
+        document = self.consumer.try_consume_file(
+            self.get_test_file(),
+            override_asn=123,
+        )
+        self.assertEqual(document.archive_serial_number, 123)
+        self._assert_first_last_send_progress()
+
+    def testOverrideTitlePlaceholders(self):
+        c = Correspondent.objects.create(name="Correspondent Name")
+        dt = DocumentType.objects.create(name="DocType Name")
+
+        document = self.consumer.try_consume_file(
+            self.get_test_file(),
+            override_correspondent_id=c.pk,
+            override_document_type_id=dt.pk,
+            override_title="{correspondent}{document_type} {added_month}-{added_year_short}",
+        )
+        now = timezone.now()
+        self.assertEqual(document.title, f"{c.name}{dt.name} {now.strftime('%m-%y')}")
+        self._assert_first_last_send_progress()
+
+    def testOverrideOwner(self):
+        testuser = User.objects.create(username="testuser")
+        document = self.consumer.try_consume_file(
+            self.get_test_file(),
+            override_owner_id=testuser.pk,
+        )
+        self.assertEqual(document.owner, testuser)
+        self._assert_first_last_send_progress()
+
+    def testOverridePermissions(self):
+        testuser = User.objects.create(username="testuser")
+        testgroup = Group.objects.create(name="testgroup")
+        document = self.consumer.try_consume_file(
+            self.get_test_file(),
+            override_view_users=[testuser.pk],
+            override_view_groups=[testgroup.pk],
+        )
+        user_checker = ObjectPermissionChecker(testuser)
+        self.assertTrue(user_checker.has_perm("view_document", document))
+        group_checker = ObjectPermissionChecker(testgroup)
+        self.assertTrue(group_checker.has_perm("view_document", document))
+        self._assert_first_last_send_progress()
+
     def testNotAFile(self):
         self.assertRaisesMessage(
             ConsumerError,
diff --git a/src/documents/tests/test_consumption_templates.py b/src/documents/tests/test_consumption_templates.py
new file mode 100644 (file)
index 0000000..23cda7c
--- /dev/null
@@ -0,0 +1,476 @@
+from pathlib import Path
+from unittest import TestCase
+from unittest import mock
+
+import pytest
+from django.contrib.auth.models import Group
+from django.contrib.auth.models import User
+
+from documents import tasks
+from documents.data_models import ConsumableDocument
+from documents.data_models import DocumentSource
+from documents.models import ConsumptionTemplate
+from documents.models import Correspondent
+from documents.models import DocumentType
+from documents.models import StoragePath
+from documents.models import Tag
+from documents.tests.utils import DirectoriesMixin
+from documents.tests.utils import FileSystemAssertsMixin
+from paperless_mail.models import MailAccount
+from paperless_mail.models import MailRule
+
+
+@pytest.mark.django_db
+class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
+    SAMPLE_DIR = Path(__file__).parent / "samples"
+
+    def setUp(self) -> None:
+        self.c = Correspondent.objects.create(name="Correspondent Name")
+        self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
+        self.dt = DocumentType.objects.create(name="DocType Name")
+        self.t1 = Tag.objects.create(name="t1")
+        self.t2 = Tag.objects.create(name="t2")
+        self.t3 = Tag.objects.create(name="t3")
+        self.sp = StoragePath.objects.create(path="/test/")
+
+        self.user2 = User.objects.create(username="user2")
+        self.user3 = User.objects.create(username="user3")
+        self.group1 = Group.objects.create(name="group1")
+
+        account1 = MailAccount.objects.create(
+            name="Email1",
+            username="username1",
+            password="password1",
+            imap_server="server.example.com",
+            imap_port=443,
+            imap_security=MailAccount.ImapSecurity.SSL,
+            character_set="UTF-8",
+        )
+        self.rule1 = MailRule.objects.create(
+            name="Rule1",
+            account=account1,
+            folder="INBOX",
+            filter_from="from@example.com",
+            filter_to="someone@somewhere.com",
+            filter_subject="subject",
+            filter_body="body",
+            filter_attachment_filename="file.pdf",
+            maximum_age=30,
+            action=MailRule.MailAction.MARK_READ,
+            assign_title_from=MailRule.TitleSource.NONE,
+            assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
+            order=0,
+            attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
+            assign_owner_from_rule=False,
+        )
+
+        return super().setUp()
+
+    @mock.patch("documents.consumer.Consumer.try_consume_file")
+    def test_consumption_template_match(self, m):
+        """
+        GIVEN:
+            - Existing consumption template
+        WHEN:
+            - File that matches is consumed
+        THEN:
+            - Template overrides are applied
+        """
+        ct = ConsumptionTemplate.objects.create(
+            name="Template 1",
+            order=0,
+            sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
+            filter_filename="*simple*",
+            filter_path="*/samples/*",
+            assign_title="Doc from {correspondent}",
+            assign_correspondent=self.c,
+            assign_document_type=self.dt,
+            assign_storage_path=self.sp,
+            assign_owner=self.user2,
+        )
+        ct.assign_tags.add(self.t1)
+        ct.assign_tags.add(self.t2)
+        ct.assign_tags.add(self.t3)
+        ct.assign_view_users.add(self.user3.pk)
+        ct.assign_view_groups.add(self.group1.pk)
+        ct.assign_change_users.add(self.user3.pk)
+        ct.assign_change_groups.add(self.group1.pk)
+        ct.save()
+
+        self.assertEqual(ct.__str__(), "Template 1")
+
+        test_file = self.SAMPLE_DIR / "simple.pdf"
+
+        with mock.patch("documents.tasks.async_to_sync"):
+            with self.assertLogs("paperless.matching", level="INFO") as cm:
+                tasks.consume_file(
+                    ConsumableDocument(
+                        source=DocumentSource.ConsumeFolder,
+                        original_file=test_file,
+                    ),
+                    None,
+                )
+                m.assert_called_once()
+                _, overrides = m.call_args
+                self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
+                self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
+                self.assertEqual(
+                    overrides["override_tag_ids"],
+                    [self.t1.pk, self.t2.pk, self.t3.pk],
+                )
+                self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
+                self.assertEqual(overrides["override_owner_id"], self.user2.pk)
+                self.assertEqual(overrides["override_view_users"], [self.user3.pk])
+                self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
+                self.assertEqual(overrides["override_change_users"], [self.user3.pk])
+                self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
+                self.assertEqual(
+                    overrides["override_title"],
+                    "Doc from {correspondent}",
+                )
+
+        info = cm.output[0]
+        expected_str = f"Document matched template {ct}"
+        self.assertIn(expected_str, info)
+
+    @mock.patch("documents.consumer.Consumer.try_consume_file")
+    def test_consumption_template_match_mailrule(self, m):
+        """
+        GIVEN:
+            - Existing consumption template
+        WHEN:
+            - File that matches is consumed via mail rule
+        THEN:
+            - Template overrides are applied
+        """
+        ct = ConsumptionTemplate.objects.create(
+            name="Template 1",
+            order=0,
+            sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
+            filter_mailrule=self.rule1,
+            assign_title="Doc from {correspondent}",
+            assign_correspondent=self.c,
+            assign_document_type=self.dt,
+            assign_storage_path=self.sp,
+            assign_owner=self.user2,
+        )
+        ct.assign_tags.add(self.t1)
+        ct.assign_tags.add(self.t2)
+        ct.assign_tags.add(self.t3)
+        ct.assign_view_users.add(self.user3.pk)
+        ct.assign_view_groups.add(self.group1.pk)
+        ct.assign_change_users.add(self.user3.pk)
+        ct.assign_change_groups.add(self.group1.pk)
+        ct.save()
+
+        self.assertEqual(ct.__str__(), "Template 1")
+
+        test_file = self.SAMPLE_DIR / "simple.pdf"
+        with mock.patch("documents.tasks.async_to_sync"):
+            with self.assertLogs("paperless.matching", level="INFO") as cm:
+                tasks.consume_file(
+                    ConsumableDocument(
+                        source=DocumentSource.ConsumeFolder,
+                        original_file=test_file,
+                        mailrule_id=self.rule1.pk,
+                    ),
+                    None,
+                )
+                m.assert_called_once()
+                _, overrides = m.call_args
+                self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
+                self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
+                self.assertEqual(
+                    overrides["override_tag_ids"],
+                    [self.t1.pk, self.t2.pk, self.t3.pk],
+                )
+                self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
+                self.assertEqual(overrides["override_owner_id"], self.user2.pk)
+                self.assertEqual(overrides["override_view_users"], [self.user3.pk])
+                self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
+                self.assertEqual(overrides["override_change_users"], [self.user3.pk])
+                self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
+                self.assertEqual(
+                    overrides["override_title"],
+                    "Doc from {correspondent}",
+                )
+
+        info = cm.output[0]
+        expected_str = f"Document matched template {ct}"
+        self.assertIn(expected_str, info)
+
+    @mock.patch("documents.consumer.Consumer.try_consume_file")
+    def test_consumption_template_match_multiple(self, m):
+        """
+        GIVEN:
+            - Multiple existing consumption template
+        WHEN:
+            - File that matches is consumed
+        THEN:
+            - Template overrides are applied with subsequent templates only overwriting empty values
+            or merging if multiple
+        """
+        ct1 = ConsumptionTemplate.objects.create(
+            name="Template 1",
+            order=0,
+            sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
+            filter_path="*/samples/*",
+            assign_title="Doc from {correspondent}",
+            assign_correspondent=self.c,
+            assign_document_type=self.dt,
+        )
+        ct1.assign_tags.add(self.t1)
+        ct1.assign_tags.add(self.t2)
+        ct1.assign_view_users.add(self.user2)
+        ct1.save()
+        ct2 = ConsumptionTemplate.objects.create(
+            name="Template 2",
+            order=0,
+            sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
+            filter_filename="*simple*",
+            assign_title="Doc from {correspondent}",
+            assign_correspondent=self.c2,
+            assign_storage_path=self.sp,
+        )
+        ct2.assign_tags.add(self.t3)
+        ct1.assign_view_users.add(self.user3)
+        ct2.save()
+
+        test_file = self.SAMPLE_DIR / "simple.pdf"
+
+        with mock.patch("documents.tasks.async_to_sync"):
+            with self.assertLogs("paperless.matching", level="INFO") as cm:
+                tasks.consume_file(
+                    ConsumableDocument(
+                        source=DocumentSource.ConsumeFolder,
+                        original_file=test_file,
+                    ),
+                    None,
+                )
+                m.assert_called_once()
+                _, overrides = m.call_args
+                # template 1
+                self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
+                self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
+                # template 2
+                self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
+                # template 1 & 2
+                self.assertEqual(
+                    overrides["override_tag_ids"],
+                    [self.t1.pk, self.t2.pk, self.t3.pk],
+                )
+                self.assertEqual(
+                    overrides["override_view_users"],
+                    [self.user2.pk, self.user3.pk],
+                )
+
+        expected_str = f"Document matched template {ct1}"
+        self.assertIn(expected_str, cm.output[0])
+        expected_str = f"Document matched template {ct2}"
+        self.assertIn(expected_str, cm.output[1])
+
+    @mock.patch("documents.consumer.Consumer.try_consume_file")
+    def test_consumption_template_no_match_filename(self, m):
+        """
+        GIVEN:
+            - Existing consumption template
+        WHEN:
+            - File that does not match on filename is consumed
+        THEN:
+            - Template overrides are not applied
+        """
+        ct = ConsumptionTemplate.objects.create(
+            name="Template 1",
+            order=0,
+            sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
+            filter_filename="*foobar*",
+            filter_path=None,
+            assign_title="Doc from {correspondent}",
+            assign_correspondent=self.c,
+            assign_document_type=self.dt,
+            assign_storage_path=self.sp,
+            assign_owner=self.user2,
+        )
+
+        test_file = self.SAMPLE_DIR / "simple.pdf"
+
+        with mock.patch("documents.tasks.async_to_sync"):
+            with self.assertLogs("paperless.matching", level="DEBUG") as cm:
+                tasks.consume_file(
+                    ConsumableDocument(
+                        source=DocumentSource.ConsumeFolder,
+                        original_file=test_file,
+                    ),
+                    None,
+                )
+                m.assert_called_once()
+                _, overrides = m.call_args
+                self.assertIsNone(overrides["override_correspondent_id"])
+                self.assertIsNone(overrides["override_document_type_id"])
+                self.assertIsNone(overrides["override_tag_ids"])
+                self.assertIsNone(overrides["override_storage_path_id"])
+                self.assertIsNone(overrides["override_owner_id"])
+                self.assertIsNone(overrides["override_view_users"])
+                self.assertIsNone(overrides["override_view_groups"])
+                self.assertIsNone(overrides["override_change_users"])
+                self.assertIsNone(overrides["override_change_groups"])
+                self.assertIsNone(overrides["override_title"])
+
+        expected_str = f"Document did not match template {ct}"
+        self.assertIn(expected_str, cm.output[0])
+        expected_str = f"Document filename {test_file.name} does not match"
+        self.assertIn(expected_str, cm.output[1])
+
+    @mock.patch("documents.consumer.Consumer.try_consume_file")
+    def test_consumption_template_no_match_path(self, m):
+        """
+        GIVEN:
+            - Existing consumption template
+        WHEN:
+            - File that does not match on path is consumed
+        THEN:
+            - Template overrides are not applied
+        """
+        ct = ConsumptionTemplate.objects.create(
+            name="Template 1",
+            order=0,
+            sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
+            filter_path="*foo/bar*",
+            assign_title="Doc from {correspondent}",
+            assign_correspondent=self.c,
+            assign_document_type=self.dt,
+            assign_storage_path=self.sp,
+            assign_owner=self.user2,
+        )
+
+        test_file = self.SAMPLE_DIR / "simple.pdf"
+
+        with mock.patch("documents.tasks.async_to_sync"):
+            with self.assertLogs("paperless.matching", level="DEBUG") as cm:
+                tasks.consume_file(
+                    ConsumableDocument(
+                        source=DocumentSource.ConsumeFolder,
+                        original_file=test_file,
+                    ),
+                    None,
+                )
+                m.assert_called_once()
+                _, overrides = m.call_args
+                self.assertIsNone(overrides["override_correspondent_id"])
+                self.assertIsNone(overrides["override_document_type_id"])
+                self.assertIsNone(overrides["override_tag_ids"])
+                self.assertIsNone(overrides["override_storage_path_id"])
+                self.assertIsNone(overrides["override_owner_id"])
+                self.assertIsNone(overrides["override_view_users"])
+                self.assertIsNone(overrides["override_view_groups"])
+                self.assertIsNone(overrides["override_change_users"])
+                self.assertIsNone(overrides["override_change_groups"])
+                self.assertIsNone(overrides["override_title"])
+
+        expected_str = f"Document did not match template {ct}"
+        self.assertIn(expected_str, cm.output[0])
+        expected_str = f"Document path {test_file} does not match"
+        self.assertIn(expected_str, cm.output[1])
+
+    @mock.patch("documents.consumer.Consumer.try_consume_file")
+    def test_consumption_template_no_match_mail_rule(self, m):
+        """
+        GIVEN:
+            - Existing consumption template
+        WHEN:
+            - File that does not match on source is consumed
+        THEN:
+            - Template overrides are not applied
+        """
+        ct = ConsumptionTemplate.objects.create(
+            name="Template 1",
+            order=0,
+            sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
+            filter_mailrule=self.rule1,
+            assign_title="Doc from {correspondent}",
+            assign_correspondent=self.c,
+            assign_document_type=self.dt,
+            assign_storage_path=self.sp,
+            assign_owner=self.user2,
+        )
+
+        test_file = self.SAMPLE_DIR / "simple.pdf"
+
+        with mock.patch("documents.tasks.async_to_sync"):
+            with self.assertLogs("paperless.matching", level="DEBUG") as cm:
+                tasks.consume_file(
+                    ConsumableDocument(
+                        source=DocumentSource.ConsumeFolder,
+                        original_file=test_file,
+                        mailrule_id=99,
+                    ),
+                    None,
+                )
+                m.assert_called_once()
+                _, overrides = m.call_args
+                self.assertIsNone(overrides["override_correspondent_id"])
+                self.assertIsNone(overrides["override_document_type_id"])
+                self.assertIsNone(overrides["override_tag_ids"])
+                self.assertIsNone(overrides["override_storage_path_id"])
+                self.assertIsNone(overrides["override_owner_id"])
+                self.assertIsNone(overrides["override_view_users"])
+                self.assertIsNone(overrides["override_view_groups"])
+                self.assertIsNone(overrides["override_change_users"])
+                self.assertIsNone(overrides["override_change_groups"])
+                self.assertIsNone(overrides["override_title"])
+
+        expected_str = f"Document did not match template {ct}"
+        self.assertIn(expected_str, cm.output[0])
+        expected_str = "Document mail rule 99 !="
+        self.assertIn(expected_str, cm.output[1])
+
+    @mock.patch("documents.consumer.Consumer.try_consume_file")
+    def test_consumption_template_no_match_source(self, m):
+        """
+        GIVEN:
+            - Existing consumption template
+        WHEN:
+            - File that does not match on source is consumed
+        THEN:
+            - Template overrides are not applied
+        """
+        ct = ConsumptionTemplate.objects.create(
+            name="Template 1",
+            order=0,
+            sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
+            filter_path="*",
+            assign_title="Doc from {correspondent}",
+            assign_correspondent=self.c,
+            assign_document_type=self.dt,
+            assign_storage_path=self.sp,
+            assign_owner=self.user2,
+        )
+
+        test_file = self.SAMPLE_DIR / "simple.pdf"
+
+        with mock.patch("documents.tasks.async_to_sync"):
+            with self.assertLogs("paperless.matching", level="DEBUG") as cm:
+                tasks.consume_file(
+                    ConsumableDocument(
+                        source=DocumentSource.ApiUpload,
+                        original_file=test_file,
+                    ),
+                    None,
+                )
+                m.assert_called_once()
+                _, overrides = m.call_args
+                self.assertIsNone(overrides["override_correspondent_id"])
+                self.assertIsNone(overrides["override_document_type_id"])
+                self.assertIsNone(overrides["override_tag_ids"])
+                self.assertIsNone(overrides["override_storage_path_id"])
+                self.assertIsNone(overrides["override_owner_id"])
+                self.assertIsNone(overrides["override_view_users"])
+                self.assertIsNone(overrides["override_view_groups"])
+                self.assertIsNone(overrides["override_change_users"])
+                self.assertIsNone(overrides["override_change_groups"])
+                self.assertIsNone(overrides["override_title"])
+
+        expected_str = f"Document did not match template {ct}"
+        self.assertIn(expected_str, cm.output[0])
+        expected_str = f"Document source {DocumentSource.ApiUpload.name} not in ['{DocumentSource.ConsumeFolder.name}', '{DocumentSource.MailFetch.name}']"
+        self.assertIn(expected_str, cm.output[1])
index d7bc1000aaed46910309d6f5b26a454f7222b9e7..b86fb2ef02ded272bb02d8f07da4a7dbd23d5dd6 100644 (file)
@@ -153,7 +153,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
 
         manifest = self._do_export(use_filename_format=use_filename_format)
 
-        self.assertEqual(len(manifest), 154)
+        self.assertEqual(len(manifest), 159)
 
         # dont include consumer or AnonymousUser users
         self.assertEqual(
@@ -247,7 +247,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
             self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
             self.assertEqual(GroupObjectPermission.objects.count(), 1)
             self.assertEqual(UserObjectPermission.objects.count(), 1)
-            self.assertEqual(Permission.objects.count(), 112)
+            self.assertEqual(Permission.objects.count(), 116)
             messages = check_sanity()
             # everything is alright after the test
             self.assertEqual(len(messages), 0)
@@ -676,15 +676,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
             os.path.join(self.dirs.media_dir, "documents"),
         )
 
-        self.assertEqual(ContentType.objects.count(), 28)
-        self.assertEqual(Permission.objects.count(), 112)
+        self.assertEqual(ContentType.objects.count(), 29)
+        self.assertEqual(Permission.objects.count(), 116)
 
         manifest = self._do_export()
 
         with paperless_environment():
             self.assertEqual(
                 len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
-                112,
+                116,
             )
             # add 1 more to db to show objects are not re-created by import
             Permission.objects.create(
@@ -692,7 +692,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
                 codename="test_perm",
                 content_type_id=1,
             )
-            self.assertEqual(Permission.objects.count(), 113)
+            self.assertEqual(Permission.objects.count(), 117)
 
             # will cause an import error
             self.user.delete()
@@ -701,5 +701,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
             with self.assertRaises(IntegrityError):
                 call_command("document_importer", "--no-progress-bar", self.target)
 
-            self.assertEqual(ContentType.objects.count(), 28)
-            self.assertEqual(Permission.objects.count(), 113)
+            self.assertEqual(ContentType.objects.count(), 29)
+            self.assertEqual(Permission.objects.count(), 117)
diff --git a/src/documents/tests/test_migration_consumption_templates.py b/src/documents/tests/test_migration_consumption_templates.py
new file mode 100644 (file)
index 0000000..3374530
--- /dev/null
@@ -0,0 +1,43 @@
+from django.contrib.auth import get_user_model
+
+from documents.tests.utils import TestMigrations
+
+
+class TestMigrateConsumptionTemplate(TestMigrations):
+    migrate_from = "1038_sharelink"
+    migrate_to = "1039_consumptiontemplate"
+
+    def setUpBeforeMigration(self, apps):
+        User = get_user_model()
+        Group = apps.get_model("auth.Group")
+        self.Permission = apps.get_model("auth", "Permission")
+        self.user = User.objects.create(username="user1")
+        self.group = Group.objects.create(name="group1")
+        permission = self.Permission.objects.get(codename="add_document")
+        self.user.user_permissions.add(permission.id)
+        self.group.permissions.add(permission.id)
+
+    def test_users_with_add_documents_get_add_consumptiontemplate(self):
+        permission = self.Permission.objects.get(codename="add_consumptiontemplate")
+        self.assertTrue(self.user.has_perm(f"documents.{permission.codename}"))
+        self.assertTrue(permission in self.group.permissions.all())
+
+
+class TestReverseMigrateConsumptionTemplate(TestMigrations):
+    migrate_from = "1039_consumptiontemplate"
+    migrate_to = "1038_sharelink"
+
+    def setUpBeforeMigration(self, apps):
+        User = get_user_model()
+        Group = apps.get_model("auth.Group")
+        self.Permission = apps.get_model("auth", "Permission")
+        self.user = User.objects.create(username="user1")
+        self.group = Group.objects.create(name="group1")
+        permission = self.Permission.objects.get(codename="add_consumptiontemplate")
+        self.user.user_permissions.add(permission.id)
+        self.group.permissions.add(permission.id)
+
+    def test_remove_consumptiontemplate_permissions(self):
+        permission = self.Permission.objects.get(codename="add_consumptiontemplate")
+        self.assertFalse(self.user.has_perm(f"documents.{permission.codename}"))
+        self.assertFalse(permission in self.group.permissions.all())
index be6ce1ff732974272af396e23e44a3d455858d3a..3353a327222071d09dc669e3ce31b2603cdd785d 100644 (file)
@@ -86,6 +86,7 @@ from .matching import match_correspondents
 from .matching import match_document_types
 from .matching import match_storage_paths
 from .matching import match_tags
+from .models import ConsumptionTemplate
 from .models import Correspondent
 from .models import Document
 from .models import DocumentType
@@ -101,6 +102,7 @@ from .serialisers import AcknowledgeTasksViewSerializer
 from .serialisers import BulkDownloadSerializer
 from .serialisers import BulkEditObjectPermissionsSerializer
 from .serialisers import BulkEditSerializer
+from .serialisers import ConsumptionTemplateSerializer
 from .serialisers import CorrespondentSerializer
 from .serialisers import DocumentListSerializer
 from .serialisers import DocumentSerializer
@@ -1248,3 +1250,14 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
             return HttpResponseBadRequest(
                 "Error performing bulk permissions edit, check logs for more detail.",
             )
+
+
+class ConsumptionTemplateViewSet(ModelViewSet):
+    permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
+
+    serializer_class = ConsumptionTemplateSerializer
+    pagination_class = StandardPagination
+
+    model = ConsumptionTemplate
+
+    queryset = ConsumptionTemplate.objects.all().order_by("order")
index a8eab13b5e0add7186e0d4b693d95bb969800b8d..bb1fe876ea27c14dd878529194fda691fad19027 100644 (file)
@@ -21,555 +21,648 @@ msgstr ""
 msgid "Documents"
 msgstr ""
 
-#: documents/models.py:33 documents/models.py:728
+#: documents/models.py:36 documents/models.py:731
 msgid "owner"
 msgstr ""
 
-#: documents/models.py:50
+#: documents/models.py:53
 msgid "None"
 msgstr ""
 
-#: documents/models.py:51
+#: documents/models.py:54
 msgid "Any word"
 msgstr ""
 
-#: documents/models.py:52
+#: documents/models.py:55
 msgid "All words"
 msgstr ""
 
-#: documents/models.py:53
+#: documents/models.py:56
 msgid "Exact match"
 msgstr ""
 
-#: documents/models.py:54
+#: documents/models.py:57
 msgid "Regular expression"
 msgstr ""
 
-#: documents/models.py:55
+#: documents/models.py:58
 msgid "Fuzzy word"
 msgstr ""
 
-#: documents/models.py:56
+#: documents/models.py:59
 msgid "Automatic"
 msgstr ""
 
-#: documents/models.py:59 documents/models.py:399 paperless_mail/models.py:18
-#: paperless_mail/models.py:92
+#: documents/models.py:62 documents/models.py:402 documents/models.py:755
+#: paperless_mail/models.py:18 paperless_mail/models.py:93
 msgid "name"
 msgstr ""
 
-#: documents/models.py:61
+#: documents/models.py:64
 msgid "match"
 msgstr ""
 
-#: documents/models.py:64
+#: documents/models.py:67
 msgid "matching algorithm"
 msgstr ""
 
-#: documents/models.py:69
+#: documents/models.py:72
 msgid "is insensitive"
 msgstr ""
 
-#: documents/models.py:92 documents/models.py:144
+#: documents/models.py:95 documents/models.py:147
 msgid "correspondent"
 msgstr ""
 
-#: documents/models.py:93
+#: documents/models.py:96
 msgid "correspondents"
 msgstr ""
 
-#: documents/models.py:97
+#: documents/models.py:100
 msgid "color"
 msgstr ""
 
-#: documents/models.py:100
+#: documents/models.py:103
 msgid "is inbox tag"
 msgstr ""
 
-#: documents/models.py:103
+#: documents/models.py:106
 msgid ""
 "Marks this tag as an inbox tag: All newly consumed documents will be tagged "
 "with inbox tags."
 msgstr ""
 
-#: documents/models.py:109
+#: documents/models.py:112
 msgid "tag"
 msgstr ""
 
-#: documents/models.py:110 documents/models.py:182
+#: documents/models.py:113 documents/models.py:185
 msgid "tags"
 msgstr ""
 
-#: documents/models.py:115 documents/models.py:164
+#: documents/models.py:118 documents/models.py:167
 msgid "document type"
 msgstr ""
 
-#: documents/models.py:116
+#: documents/models.py:119
 msgid "document types"
 msgstr ""
 
-#: documents/models.py:121
+#: documents/models.py:124
 msgid "path"
 msgstr ""
 
-#: documents/models.py:126 documents/models.py:153
+#: documents/models.py:129 documents/models.py:156
 msgid "storage path"
 msgstr ""
 
-#: documents/models.py:127
+#: documents/models.py:130
 msgid "storage paths"
 msgstr ""
 
-#: documents/models.py:134
+#: documents/models.py:137
 msgid "Unencrypted"
 msgstr ""
 
-#: documents/models.py:135
+#: documents/models.py:138
 msgid "Encrypted with GNU Privacy Guard"
 msgstr ""
 
-#: documents/models.py:156
+#: documents/models.py:159
 msgid "title"
 msgstr ""
 
-#: documents/models.py:168 documents/models.py:642
+#: documents/models.py:171 documents/models.py:645
 msgid "content"
 msgstr ""
 
-#: documents/models.py:171
+#: documents/models.py:174
 msgid ""
 "The raw, text-only data of the document. This field is primarily used for "
 "searching."
 msgstr ""
 
-#: documents/models.py:176
+#: documents/models.py:179
 msgid "mime type"
 msgstr ""
 
-#: documents/models.py:186
+#: documents/models.py:189
 msgid "checksum"
 msgstr ""
 
-#: documents/models.py:190
+#: documents/models.py:193
 msgid "The checksum of the original document."
 msgstr ""
 
-#: documents/models.py:194
+#: documents/models.py:197
 msgid "archive checksum"
 msgstr ""
 
-#: documents/models.py:199
+#: documents/models.py:202
 msgid "The checksum of the archived document."
 msgstr ""
 
-#: documents/models.py:202 documents/models.py:382 documents/models.py:648
-#: documents/models.py:686
+#: documents/models.py:205 documents/models.py:385 documents/models.py:651
+#: documents/models.py:689
 msgid "created"
 msgstr ""
 
-#: documents/models.py:205
+#: documents/models.py:208
 msgid "modified"
 msgstr ""
 
-#: documents/models.py:212
+#: documents/models.py:215
 msgid "storage type"
 msgstr ""
 
-#: documents/models.py:220
+#: documents/models.py:223
 msgid "added"
 msgstr ""
 
-#: documents/models.py:227
+#: documents/models.py:230
 msgid "filename"
 msgstr ""
 
-#: documents/models.py:233
+#: documents/models.py:236
 msgid "Current filename in storage"
 msgstr ""
 
-#: documents/models.py:237
+#: documents/models.py:240
 msgid "archive filename"
 msgstr ""
 
-#: documents/models.py:243
+#: documents/models.py:246
 msgid "Current archive filename in storage"
 msgstr ""
 
-#: documents/models.py:247
+#: documents/models.py:250
 msgid "original filename"
 msgstr ""
 
-#: documents/models.py:253
+#: documents/models.py:256
 msgid "The original name of the file when it was uploaded"
 msgstr ""
 
-#: documents/models.py:260
+#: documents/models.py:263
 msgid "archive serial number"
 msgstr ""
 
-#: documents/models.py:270
+#: documents/models.py:273
 msgid "The position of this document in your physical document archive."
 msgstr ""
 
-#: documents/models.py:276 documents/models.py:659 documents/models.py:713
+#: documents/models.py:279 documents/models.py:662 documents/models.py:716
 msgid "document"
 msgstr ""
 
-#: documents/models.py:277
+#: documents/models.py:280
 msgid "documents"
 msgstr ""
 
-#: documents/models.py:365
+#: documents/models.py:368
 msgid "debug"
 msgstr ""
 
-#: documents/models.py:366
+#: documents/models.py:369
 msgid "information"
 msgstr ""
 
-#: documents/models.py:367
+#: documents/models.py:370
 msgid "warning"
 msgstr ""
 
-#: documents/models.py:368 paperless_mail/models.py:287
+#: documents/models.py:371 paperless_mail/models.py:293
 msgid "error"
 msgstr ""
 
-#: documents/models.py:369
+#: documents/models.py:372
 msgid "critical"
 msgstr ""
 
-#: documents/models.py:372
+#: documents/models.py:375
 msgid "group"
 msgstr ""
 
-#: documents/models.py:374
+#: documents/models.py:377
 msgid "message"
 msgstr ""
 
-#: documents/models.py:377
+#: documents/models.py:380
 msgid "level"
 msgstr ""
 
-#: documents/models.py:386
+#: documents/models.py:389
 msgid "log"
 msgstr ""
 
-#: documents/models.py:387
+#: documents/models.py:390
 msgid "logs"
 msgstr ""
 
-#: documents/models.py:396 documents/models.py:461
+#: documents/models.py:399 documents/models.py:464
 msgid "saved view"
 msgstr ""
 
-#: documents/models.py:397
+#: documents/models.py:400
 msgid "saved views"
 msgstr ""
 
-#: documents/models.py:402
+#: documents/models.py:405
 msgid "show on dashboard"
 msgstr ""
 
-#: documents/models.py:405
+#: documents/models.py:408
 msgid "show in sidebar"
 msgstr ""
 
-#: documents/models.py:409
+#: documents/models.py:412
 msgid "sort field"
 msgstr ""
 
-#: documents/models.py:414
+#: documents/models.py:417
 msgid "sort reverse"
 msgstr ""
 
-#: documents/models.py:419
+#: documents/models.py:422
 msgid "title contains"
 msgstr ""
 
-#: documents/models.py:420
+#: documents/models.py:423
 msgid "content contains"
 msgstr ""
 
-#: documents/models.py:421
+#: documents/models.py:424
 msgid "ASN is"
 msgstr ""
 
-#: documents/models.py:422
+#: documents/models.py:425
 msgid "correspondent is"
 msgstr ""
 
-#: documents/models.py:423
+#: documents/models.py:426
 msgid "document type is"
 msgstr ""
 
-#: documents/models.py:424
+#: documents/models.py:427
 msgid "is in inbox"
 msgstr ""
 
-#: documents/models.py:425
+#: documents/models.py:428
 msgid "has tag"
 msgstr ""
 
-#: documents/models.py:426
+#: documents/models.py:429
 msgid "has any tag"
 msgstr ""
 
-#: documents/models.py:427
+#: documents/models.py:430
 msgid "created before"
 msgstr ""
 
-#: documents/models.py:428
+#: documents/models.py:431
 msgid "created after"
 msgstr ""
 
-#: documents/models.py:429
+#: documents/models.py:432
 msgid "created year is"
 msgstr ""
 
-#: documents/models.py:430
+#: documents/models.py:433
 msgid "created month is"
 msgstr ""
 
-#: documents/models.py:431
+#: documents/models.py:434
 msgid "created day is"
 msgstr ""
 
-#: documents/models.py:432
+#: documents/models.py:435
 msgid "added before"
 msgstr ""
 
-#: documents/models.py:433
+#: documents/models.py:436
 msgid "added after"
 msgstr ""
 
-#: documents/models.py:434
+#: documents/models.py:437
 msgid "modified before"
 msgstr ""
 
-#: documents/models.py:435
+#: documents/models.py:438
 msgid "modified after"
 msgstr ""
 
-#: documents/models.py:436
+#: documents/models.py:439
 msgid "does not have tag"
 msgstr ""
 
-#: documents/models.py:437
+#: documents/models.py:440
 msgid "does not have ASN"
 msgstr ""
 
-#: documents/models.py:438
+#: documents/models.py:441
 msgid "title or content contains"
 msgstr ""
 
-#: documents/models.py:439
+#: documents/models.py:442
 msgid "fulltext query"
 msgstr ""
 
-#: documents/models.py:440
+#: documents/models.py:443
 msgid "more like this"
 msgstr ""
 
-#: documents/models.py:441
+#: documents/models.py:444
 msgid "has tags in"
 msgstr ""
 
-#: documents/models.py:442
+#: documents/models.py:445
 msgid "ASN greater than"
 msgstr ""
 
-#: documents/models.py:443
+#: documents/models.py:446
 msgid "ASN less than"
 msgstr ""
 
-#: documents/models.py:444
+#: documents/models.py:447
 msgid "storage path is"
 msgstr ""
 
-#: documents/models.py:445
+#: documents/models.py:448
 msgid "has correspondent in"
 msgstr ""
 
-#: documents/models.py:446
+#: documents/models.py:449
 msgid "does not have correspondent in"
 msgstr ""
 
-#: documents/models.py:447
+#: documents/models.py:450
 msgid "has document type in"
 msgstr ""
 
-#: documents/models.py:448
+#: documents/models.py:451
 msgid "does not have document type in"
 msgstr ""
 
-#: documents/models.py:449
+#: documents/models.py:452
 msgid "has storage path in"
 msgstr ""
 
-#: documents/models.py:450
+#: documents/models.py:453
 msgid "does not have storage path in"
 msgstr ""
 
-#: documents/models.py:451
+#: documents/models.py:454
 msgid "owner is"
 msgstr ""
 
-#: documents/models.py:452
+#: documents/models.py:455
 msgid "has owner in"
 msgstr ""
 
-#: documents/models.py:453
+#: documents/models.py:456
 msgid "does not have owner"
 msgstr ""
 
-#: documents/models.py:454
+#: documents/models.py:457
 msgid "does not have owner in"
 msgstr ""
 
-#: documents/models.py:464
+#: documents/models.py:467
 msgid "rule type"
 msgstr ""
 
-#: documents/models.py:466
+#: documents/models.py:469
 msgid "value"
 msgstr ""
 
-#: documents/models.py:469
+#: documents/models.py:472
 msgid "filter rule"
 msgstr ""
 
-#: documents/models.py:470
+#: documents/models.py:473
 msgid "filter rules"
 msgstr ""
 
-#: documents/models.py:578
+#: documents/models.py:581
 msgid "Task ID"
 msgstr ""
 
-#: documents/models.py:579
+#: documents/models.py:582
 msgid "Celery ID for the Task that was run"
 msgstr ""
 
-#: documents/models.py:584
+#: documents/models.py:587
 msgid "Acknowledged"
 msgstr ""
 
-#: documents/models.py:585
+#: documents/models.py:588
 msgid "If the task is acknowledged via the frontend or API"
 msgstr ""
 
-#: documents/models.py:591
+#: documents/models.py:594
 msgid "Task Filename"
 msgstr ""
 
-#: documents/models.py:592
+#: documents/models.py:595
 msgid "Name of the file which the Task was run for"
 msgstr ""
 
-#: documents/models.py:598
+#: documents/models.py:601
 msgid "Task Name"
 msgstr ""
 
-#: documents/models.py:599
+#: documents/models.py:602
 msgid "Name of the Task which was run"
 msgstr ""
 
-#: documents/models.py:606
+#: documents/models.py:609
 msgid "Task State"
 msgstr ""
 
-#: documents/models.py:607
+#: documents/models.py:610
 msgid "Current state of the task being run"
 msgstr ""
 
-#: documents/models.py:612
+#: documents/models.py:615
 msgid "Created DateTime"
 msgstr ""
 
-#: documents/models.py:613
+#: documents/models.py:616
 msgid "Datetime field when the task result was created in UTC"
 msgstr ""
 
-#: documents/models.py:618
+#: documents/models.py:621
 msgid "Started DateTime"
 msgstr ""
 
-#: documents/models.py:619
+#: documents/models.py:622
 msgid "Datetime field when the task was started in UTC"
 msgstr ""
 
-#: documents/models.py:624
+#: documents/models.py:627
 msgid "Completed DateTime"
 msgstr ""
 
-#: documents/models.py:625
+#: documents/models.py:628
 msgid "Datetime field when the task was completed in UTC"
 msgstr ""
 
-#: documents/models.py:630
+#: documents/models.py:633
 msgid "Result Data"
 msgstr ""
 
-#: documents/models.py:632
+#: documents/models.py:635
 msgid "The data returned by the task"
 msgstr ""
 
-#: documents/models.py:644
+#: documents/models.py:647
 msgid "Note for the document"
 msgstr ""
 
-#: documents/models.py:668
+#: documents/models.py:671
 msgid "user"
 msgstr ""
 
-#: documents/models.py:673
+#: documents/models.py:676
 msgid "note"
 msgstr ""
 
-#: documents/models.py:674
+#: documents/models.py:677
 msgid "notes"
 msgstr ""
 
-#: documents/models.py:682
+#: documents/models.py:685
 msgid "Archive"
 msgstr ""
 
-#: documents/models.py:683
+#: documents/models.py:686
 msgid "Original"
 msgstr ""
 
-#: documents/models.py:694
+#: documents/models.py:697
 msgid "expiration"
 msgstr ""
 
-#: documents/models.py:701
+#: documents/models.py:704
 msgid "slug"
 msgstr ""
 
-#: documents/models.py:733
+#: documents/models.py:736
 msgid "share link"
 msgstr ""
 
-#: documents/models.py:734
+#: documents/models.py:737
 msgid "share links"
 msgstr ""
 
-#: documents/serialisers.py:96
+#: documents/models.py:744
+msgid "Consume Folder"
+msgstr ""
+
+#: documents/models.py:745
+msgid "Api Upload"
+msgstr ""
+
+#: documents/models.py:746
+msgid "Mail Fetch"
+msgstr ""
+
+#: documents/models.py:752
+msgid "consumption template"
+msgstr ""
+
+#: documents/models.py:753
+msgid "consumption templates"
+msgstr ""
+
+#: documents/models.py:757 paperless_mail/models.py:95
+msgid "order"
+msgstr ""
+
+#: documents/models.py:766
+msgid "filter path"
+msgstr ""
+
+#: documents/models.py:771
+msgid ""
+"Only consume documents with a path that matches this if specified. Wildcards "
+"specified as * are allowed. Case insensitive."
+msgstr ""
+
+#: documents/models.py:778
+msgid "filter filename"
+msgstr ""
+
+#: documents/models.py:783 paperless_mail/models.py:148
+msgid ""
+"Only consume documents which entirely match this filename if specified. "
+"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
+msgstr ""
+
+#: documents/models.py:794
+msgid "filter documents from this mail rule"
+msgstr ""
+
+#: documents/models.py:798
+msgid "assign title"
+msgstr ""
+
+#: documents/models.py:803
+msgid ""
+"Assign a document title, can include some placeholders, see documentation."
+msgstr ""
+
+#: documents/models.py:811 paperless_mail/models.py:204
+msgid "assign this tag"
+msgstr ""
+
+#: documents/models.py:819 paperless_mail/models.py:212
+msgid "assign this document type"
+msgstr ""
+
+#: documents/models.py:827 paperless_mail/models.py:226
+msgid "assign this correspondent"
+msgstr ""
+
+#: documents/models.py:835
+msgid "assign this storage path"
+msgstr ""
+
+#: documents/models.py:844
+msgid "assign this owner"
+msgstr ""
+
+#: documents/models.py:851
+msgid "grant view permissions to these users"
+msgstr ""
+
+#: documents/models.py:858
+msgid "grant view permissions to these groups"
+msgstr ""
+
+#: documents/models.py:865
+msgid "grant change permissions to these users"
+msgstr ""
+
+#: documents/models.py:872
+msgid "grant change permissions to these groups"
+msgstr ""
+
+#: documents/serialisers.py:100
 #, python-format
 msgid "Invalid regular expression: %(error)s"
 msgstr ""
 
-#: documents/serialisers.py:371
+#: documents/serialisers.py:375
 msgid "Invalid color."
 msgstr ""
 
-#: documents/serialisers.py:747
+#: documents/serialisers.py:751
 #, python-format
 msgid "File type %(type)s not supported"
 msgstr ""
 
-#: documents/serialisers.py:844
+#: documents/serialisers.py:848
 msgid "Invalid variable detected."
 msgstr ""
 
@@ -749,7 +842,7 @@ msgstr ""
 msgid "Chinese Simplified"
 msgstr ""
 
-#: paperless/urls.py:182
+#: paperless/urls.py:184
 msgid "Paperless-ngx administration"
 msgstr ""
 
@@ -909,138 +1002,124 @@ msgstr ""
 msgid "Use attachment filename as title"
 msgstr ""
 
-#: paperless_mail/models.py:87
-msgid "Do not assign a correspondent"
+#: paperless_mail/models.py:85
+msgid "Do not assign title from rule"
 msgstr ""
 
 #: paperless_mail/models.py:88
-msgid "Use mail address"
+msgid "Do not assign a correspondent"
 msgstr ""
 
 #: paperless_mail/models.py:89
-msgid "Use name (or mail address if not available)"
+msgid "Use mail address"
 msgstr ""
 
 #: paperless_mail/models.py:90
-msgid "Use correspondent selected below"
+msgid "Use name (or mail address if not available)"
 msgstr ""
 
-#: paperless_mail/models.py:94
-msgid "order"
+#: paperless_mail/models.py:91
+msgid "Use correspondent selected below"
 msgstr ""
 
-#: paperless_mail/models.py:100
+#: paperless_mail/models.py:101
 msgid "account"
 msgstr ""
 
-#: paperless_mail/models.py:104 paperless_mail/models.py:242
+#: paperless_mail/models.py:105 paperless_mail/models.py:248
 msgid "folder"
 msgstr ""
 
-#: paperless_mail/models.py:108
+#: paperless_mail/models.py:109
 msgid ""
 "Subfolders must be separated by a delimiter, often a dot ('.') or slash "
 "('/'), but it varies by mail server."
 msgstr ""
 
-#: paperless_mail/models.py:114
+#: paperless_mail/models.py:115
 msgid "filter from"
 msgstr ""
 
-#: paperless_mail/models.py:121
+#: paperless_mail/models.py:122
 msgid "filter to"
 msgstr ""
 
-#: paperless_mail/models.py:128
+#: paperless_mail/models.py:129
 msgid "filter subject"
 msgstr ""
 
-#: paperless_mail/models.py:135
+#: paperless_mail/models.py:136
 msgid "filter body"
 msgstr ""
 
-#: paperless_mail/models.py:142
+#: paperless_mail/models.py:143
 msgid "filter attachment filename"
 msgstr ""
 
-#: paperless_mail/models.py:147
-msgid ""
-"Only consume documents which entirely match this filename if specified. "
-"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
-msgstr ""
-
-#: paperless_mail/models.py:154
+#: paperless_mail/models.py:155
 msgid "maximum age"
 msgstr ""
 
-#: paperless_mail/models.py:156
+#: paperless_mail/models.py:157
 msgid "Specified in days."
 msgstr ""
 
-#: paperless_mail/models.py:160
+#: paperless_mail/models.py:161
 msgid "attachment type"
 msgstr ""
 
-#: paperless_mail/models.py:164
+#: paperless_mail/models.py:165
 msgid ""
 "Inline attachments include embedded images, so it's best to combine this "
 "option with a filename filter."
 msgstr ""
 
-#: paperless_mail/models.py:170
+#: paperless_mail/models.py:171
 msgid "consumption scope"
 msgstr ""
 
-#: paperless_mail/models.py:176
+#: paperless_mail/models.py:177
 msgid "action"
 msgstr ""
 
-#: paperless_mail/models.py:182
+#: paperless_mail/models.py:183
 msgid "action parameter"
 msgstr ""
 
-#: paperless_mail/models.py:187
+#: paperless_mail/models.py:188
 msgid ""
 "Additional parameter for the action selected above, i.e., the target folder "
 "of the move to folder action. Subfolders must be separated by dots."
 msgstr ""
 
-#: paperless_mail/models.py:195
+#: paperless_mail/models.py:196
 msgid "assign title from"
 msgstr ""
 
-#: paperless_mail/models.py:203
-msgid "assign this tag"
-msgstr ""
-
-#: paperless_mail/models.py:211
-msgid "assign this document type"
-msgstr ""
-
-#: paperless_mail/models.py:215
+#: paperless_mail/models.py:216
 msgid "assign correspondent from"
 msgstr ""
 
-#: paperless_mail/models.py:225
-msgid "assign this correspondent"
+#: paperless_mail/models.py:230
+msgid "Assign the rule owner to documents"
 msgstr ""
 
-#: paperless_mail/models.py:250
+#: paperless_mail/models.py:256
 msgid "uid"
 msgstr ""
 
-#: paperless_mail/models.py:258
+#: paperless_mail/models.py:264
 msgid "subject"
 msgstr ""
 
-#: paperless_mail/models.py:266
+#: paperless_mail/models.py:272
 msgid "received"
 msgstr ""
 
-#: paperless_mail/models.py:273
+#: paperless_mail/models.py:279
 msgid "processed"
 msgstr ""
 
-#: paperless_mail/models.py:279
+#: paperless_mail/models.py:285
 msgid "status"
 msgstr ""
index 05e772ee0c6472210c679505ccdbbb59e3ad0f0b..415efc4de7bac3d1b52067e541bd948a0be2c7f5 100644 (file)
@@ -14,6 +14,7 @@ from documents.views import AcknowledgeTasksView
 from documents.views import BulkDownloadView
 from documents.views import BulkEditObjectPermissionsView
 from documents.views import BulkEditView
+from documents.views import ConsumptionTemplateViewSet
 from documents.views import CorrespondentViewSet
 from documents.views import DocumentTypeViewSet
 from documents.views import IndexView
@@ -53,6 +54,7 @@ api_router.register(r"groups", GroupViewSet, basename="groups")
 api_router.register(r"mail_accounts", MailAccountViewSet)
 api_router.register(r"mail_rules", MailRuleViewSet)
 api_router.register(r"share_links", ShareLinkViewSet)
+api_router.register(r"consumption_templates", ConsumptionTemplateViewSet)
 
 
 urlpatterns = [
index e0b584a8cb6aa4d7e18b43715995eba55b6b48f8..0157ea7739005b8db7a82ae8bd104cd952c7f88f 100644 (file)
@@ -436,6 +436,9 @@ class MailAccountHandler(LoggingMixin):
         elif rule.assign_title_from == MailRule.TitleSource.FROM_FILENAME:
             return os.path.splitext(os.path.basename(att.filename))[0]
 
+        elif rule.assign_title_from == MailRule.TitleSource.NONE:
+            return None
+
         else:
             raise NotImplementedError(
                 "Unknown title selector.",
@@ -690,6 +693,7 @@ class MailAccountHandler(LoggingMixin):
                 input_doc = ConsumableDocument(
                     source=DocumentSource.MailFetch,
                     original_file=temp_filename,
+                    mailrule_id=rule.pk,
                 )
                 doc_overrides = DocumentMetadataOverrides(
                     title=title,
@@ -697,7 +701,9 @@ class MailAccountHandler(LoggingMixin):
                     correspondent_id=correspondent.id if correspondent else None,
                     document_type_id=doc_type.id if doc_type else None,
                     tag_ids=tag_ids,
-                    owner_id=rule.owner.id if rule.owner else None,
+                    owner_id=rule.owner.id
+                    if (rule.assign_owner_from_rule and rule.owner)
+                    else None,
                 )
 
                 consume_task = consume_file.s(
diff --git a/src/paperless_mail/migrations/0022_mailrule_assign_owner_from_rule_and_more.py b/src/paperless_mail/migrations/0022_mailrule_assign_owner_from_rule_and_more.py
new file mode 100644 (file)
index 0000000..f2c59a5
--- /dev/null
@@ -0,0 +1,34 @@
+# Generated by Django 4.1.11 on 2023-09-18 18:50
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("paperless_mail", "0021_alter_mailaccount_password"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="mailrule",
+            name="assign_owner_from_rule",
+            field=models.BooleanField(
+                default=True,
+                verbose_name="Assign the rule owner to documents",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="mailrule",
+            name="assign_title_from",
+            field=models.PositiveIntegerField(
+                choices=[
+                    (1, "Use subject as title"),
+                    (2, "Use attachment filename as title"),
+                    (3, "Do not assign title from rule"),
+                ],
+                default=1,
+                verbose_name="assign title from",
+            ),
+        ),
+    ]
index 3ea0619d99937f4543b4c532a3e5ff91998377d3..6f1896f1f359b950bb8b53e79a1c433a84012cda 100644 (file)
@@ -82,6 +82,7 @@ class MailRule(document_models.ModelWithOwner):
     class TitleSource(models.IntegerChoices):
         FROM_SUBJECT = 1, _("Use subject as title")
         FROM_FILENAME = 2, _("Use attachment filename as title")
+        NONE = 3, _("Do not assign title from rule")
 
     class CorrespondentSource(models.IntegerChoices):
         FROM_NOTHING = 1, _("Do not assign a correspondent")
@@ -225,6 +226,11 @@ class MailRule(document_models.ModelWithOwner):
         verbose_name=_("assign this correspondent"),
     )
 
+    assign_owner_from_rule = models.BooleanField(
+        _("Assign the rule owner to documents"),
+        default=True,
+    )
+
     def __str__(self):
         return f"{self.account.name}.{self.name}"
 
index bdecff11e617ce04a07bfb0ad878ed0e6a4c0f02..e5da4a7daac1c1dad3cf55712e792a9ed1a8aefe 100644 (file)
@@ -88,6 +88,7 @@ class MailRuleSerializer(OwnedObjectSerializer):
             "assign_correspondent_from",
             "assign_correspondent",
             "assign_document_type",
+            "assign_owner_from_rule",
             "order",
             "attachment_type",
             "consumption_scope",
index a9e88e4ada1d783eb7637481f17b1c55fabed593..a020571142627cbbb68d6c93274bdf50c3f95b4f 100644 (file)
@@ -464,6 +464,7 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
             "assign_tags": [tag.pk],
             "assign_correspondent": correspondent.pk,
             "assign_document_type": document_type.pk,
+            "assign_owner_from_rule": True,
         }
 
         response = self.client.post(
@@ -512,6 +513,10 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
             rule1["assign_document_type"],
         )
         self.assertEqual(returned_rule1["assign_tags"], rule1["assign_tags"])
+        self.assertEqual(
+            returned_rule1["assign_owner_from_rule"],
+            rule1["assign_owner_from_rule"],
+        )
 
     def test_delete_mail_rule(self):
         """
index 38585d9f2627683f984ce5cc724dc093175e3ef9..48b1e1a7abfbed08a821391e1b1be9c803db08cf 100644 (file)
@@ -392,6 +392,11 @@ class TestMail(
             assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
         )
         self.assertEqual(handler._get_title(message, att, rule), "the message title")
+        rule = MailRule(
+            name="b",
+            assign_title_from=MailRule.TitleSource.NONE,
+        )
+        self.assertEqual(handler._get_title(message, att, rule), None)
 
     def test_handle_message(self):
         message = self.create_message(