verify_ssl = true
name = "pypi"
-
[packages]
dateparser = "~=1.1"
# WARNING: django does not use semver.
flower = "*"
bleach = "*"
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
+django-multiselectfield = "*"
[dev-packages]
# Linting
{
"_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",
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
</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="<span *ngIf="tasksService.failedFileTasks.length > 0">"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></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="<em>"/>all<x id="CLOSE_EMPHASISED_TEXT" ctype="x-em" equiv-text="</em>"/> 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.</a></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 <a target='_blank' href='https://docs.paperless-ngx.com/usage/#consumption-templates'>documentation</a>.</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 "<x id="PH" equiv-text="newTemplate.name"/>".</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 "<x id="PH" equiv-text="newMailRule.name"/>".</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">
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' },
},
},
},
- { path: 'tasks', component: TasksComponent },
+ {
+ path: 'templates',
+ component: ConsumptionTemplatesListComponent,
+ canActivate: [PermissionsGuard],
+ data: {
+ requiredPermission: {
+ action: PermissionAction.View,
+ type: PermissionType.ConsumptionTemplate,
+ },
+ },
+ },
],
},
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'
LogoComponent,
IsNumberPipe,
ShareLinksDropdownComponent,
+ ConsumptionTemplatesListComponent,
+ ConsumptionTemplateEditDialogComponent,
],
imports: [
BrowserModule,
</svg><span> <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> <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>
--- /dev/null
+<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>
--- /dev/null
+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()
+ })
+})
--- /dev/null
+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
+ }
+}
<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>
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'
let component: MailRuleEditDialogComponent
let settingsService: SettingsService
let fixture: ComponentFixture<MailRuleEditDialogComponent>
- let accountService: MailAccountService
- let correspondentService: CorrespondentService
- let documentTypeService: DocumentTypeService
beforeEach(async () => {
TestBed.configureTestingModule({
NumberComponent,
TagsComponent,
SafeHtmlPipe,
+ CheckComponent,
],
providers: [
NgbActiveModal,
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 = [
MailMetadataCorrespondentOption.FromNothing
),
assign_correspondent: new FormControl(null),
+ assign_owner_from_rule: new FormControl(true),
})
}
</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>
<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"
[multiple]="true"
[closeOnSelect]="false"
[clearSearchOnAdd]="true"
- [hideSelected]="true"
+ [hideSelected]="tags.length > 0"
[addTag]="allowCreate ? createTagRef : false"
addTagText="Add tag"
i18n-addTagText
})
}
+ @Input()
+ title = $localize`Tags`
+
@Input()
disabled = false
--- /dev/null
+<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>
--- /dev/null
+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()
+ })
+})
--- /dev/null
+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)
+ },
+ })
+ })
+ }
+}
--- /dev/null
+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]
+}
export enum MailMetadataTitleOption {
FromSubject = 1,
FromFilename = 2,
+ None = 3,
}
export enum MailMetadataCorrespondentOption {
assign_correspondent_from?: MailMetadataCorrespondentOption
assign_correspondent?: number // PaperlessCorrespondent.id
+
+ assign_owner_from_rule: boolean
}
-import { PaperlessGroup } from 'src/app/data/paperless-group'
import { ObjectWithId } from './object-with-id'
export interface PaperlessUser extends ObjectWithId {
'view_sharelink',
'change_sharelink',
'delete_sharelink',
+ 'add_consumptiontemplate',
+ 'view_consumptiontemplate',
+ 'change_consumptiontemplate',
+ 'delete_consumptiontemplate',
],
{
username: 'testuser',
Group = '%s_group',
Admin = '%s_logentry',
ShareLink = '%s_sharelink',
+ ConsumptionTemplate = '%s_consumptiontemplate',
}
@Injectable({
--- /dev/null
+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()
+ })
+})
--- /dev/null
+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()))
+ }
+}
attachment_type: MailFilterAttachmentType.Everything,
action: MailAction.MarkRead,
assign_title_from: MailMetadataTitleOption.FromSubject,
+ assign_owner_from_rule: true,
},
{
name: 'Mail Rule 2',
attachment_type: MailFilterAttachmentType.Everything,
action: MailAction.Delete,
assign_title_from: MailMetadataTitleOption.FromSubject,
+ assign_owner_from_rule: true,
},
{
name: 'Mail Rule 3',
attachment_type: MailFilterAttachmentType.Everything,
action: MailAction.Flag,
assign_title_from: MailMetadataTitleOption.FromSubject,
+ assign_owner_from_rule: false,
},
]
}
.btn-link:hover,
-.btn-link:active {
+.btn-link:active,
+.btn-link:focus-visible {
color: var(--pngx-primary-darken-15) !important;
}
}
table.table {
- color: var(--bs-body-color);
+ --bs-table-color: var(--bs-body-color);
--bs-table-bg: var(--bs-light-rgb);
.des,.asc {
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
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
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.
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,
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,
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(),
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
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())
import dataclasses
import datetime
-import enum
+from enum import IntEnum
from pathlib import Path
from typing import Optional
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
source: DocumentSource
original_file: Path
+ mailrule_id: Optional[int] = None
mime_type: str = dataclasses.field(init=False, default=None)
def __post_init__(self):
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
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
--- /dev/null
+# 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,
+ ),
+ ]
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(
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,
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}"
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
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
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,
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,
)
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
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):
)
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__()])
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
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
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")
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,
--- /dev/null
+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])
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(
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)
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(
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()
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)
--- /dev/null
+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())
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
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
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")
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 ""
msgid "Chinese Simplified"
msgstr ""
-#: paperless/urls.py:182
+#: paperless/urls.py:184
msgid "Paperless-ngx administration"
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 ""
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
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 = [
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.",
input_doc = ConsumableDocument(
source=DocumentSource.MailFetch,
original_file=temp_filename,
+ mailrule_id=rule.pk,
)
doc_overrides = DocumentMetadataOverrides(
title=title,
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(
--- /dev/null
+# 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",
+ ),
+ ),
+ ]
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")
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}"
"assign_correspondent_from",
"assign_correspondent",
"assign_document_type",
+ "assign_owner_from_rule",
"order",
"attachment_type",
"consumption_scope",
"assign_tags": [tag.pk],
"assign_correspondent": correspondent.pk,
"assign_document_type": document_type.pk,
+ "assign_owner_from_rule": True,
}
response = self.client.post(
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):
"""
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(