mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: consumption templates (#4196)
* Initial implementation of consumption templates * Frontend implementation of consumption templates Testing * Support consumption template source * order templates, automatically add permissions * Support title assignment in consumption templates * Refactoring, filters to and, show sources on list Show sources on template list, update some translation strings Make filters and minor testing * Update strings * Only update django-multiselectfield * Basic docs, document some methods * Improve testing coverage, template multi-assignment merges
This commit is contained in:
@@ -20,6 +20,10 @@ from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.matching import document_matches_template
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.utils import copy_basic_file_stats
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
|
||||
@@ -27,10 +31,12 @@ from .classifier import load_classifier
|
||||
from .file_handling import create_source_path_directory
|
||||
from .file_handling import generate_unique_filename
|
||||
from .loggers import LoggingMixin
|
||||
from .models import ConsumptionTemplate
|
||||
from .models import Correspondent
|
||||
from .models import Document
|
||||
from .models import DocumentType
|
||||
from .models import FileInfo
|
||||
from .models import StoragePath
|
||||
from .models import Tag
|
||||
from .parsers import DocumentParser
|
||||
from .parsers import ParseError
|
||||
@@ -319,10 +325,15 @@ class Consumer(LoggingMixin):
|
||||
override_correspondent_id=None,
|
||||
override_document_type_id=None,
|
||||
override_tag_ids=None,
|
||||
override_storage_path_id=None,
|
||||
task_id=None,
|
||||
override_created=None,
|
||||
override_asn=None,
|
||||
override_owner_id=None,
|
||||
override_view_users=None,
|
||||
override_view_groups=None,
|
||||
override_change_users=None,
|
||||
override_change_groups=None,
|
||||
) -> Document:
|
||||
"""
|
||||
Return the document object if it was successfully created.
|
||||
@@ -334,10 +345,15 @@ class Consumer(LoggingMixin):
|
||||
self.override_correspondent_id = override_correspondent_id
|
||||
self.override_document_type_id = override_document_type_id
|
||||
self.override_tag_ids = override_tag_ids
|
||||
self.override_storage_path_id = override_storage_path_id
|
||||
self.task_id = task_id or str(uuid.uuid4())
|
||||
self.override_created = override_created
|
||||
self.override_asn = override_asn
|
||||
self.override_owner_id = override_owner_id
|
||||
self.override_view_users = override_view_users
|
||||
self.override_view_groups = override_view_groups
|
||||
self.override_change_users = override_change_users
|
||||
self.override_change_groups = override_change_groups
|
||||
|
||||
self._send_progress(
|
||||
0,
|
||||
@@ -578,6 +594,92 @@ class Consumer(LoggingMixin):
|
||||
|
||||
return document
|
||||
|
||||
def get_template_overrides(
|
||||
self,
|
||||
input_doc: ConsumableDocument,
|
||||
) -> DocumentMetadataOverrides:
|
||||
"""
|
||||
Match consumption templates to a document based on source and
|
||||
file name filters, path filters or mail rule filter if specified
|
||||
"""
|
||||
overrides = DocumentMetadataOverrides()
|
||||
for template in ConsumptionTemplate.objects.all().order_by("order"):
|
||||
template_overrides = DocumentMetadataOverrides()
|
||||
|
||||
if document_matches_template(input_doc, template):
|
||||
if template.assign_title is not None:
|
||||
template_overrides.title = template.assign_title
|
||||
if template.assign_tags is not None:
|
||||
template_overrides.tag_ids = [
|
||||
tag.pk for tag in template.assign_tags.all()
|
||||
]
|
||||
if template.assign_correspondent is not None:
|
||||
template_overrides.correspondent_id = (
|
||||
template.assign_correspondent.pk
|
||||
)
|
||||
if template.assign_document_type is not None:
|
||||
template_overrides.document_type_id = (
|
||||
template.assign_document_type.pk
|
||||
)
|
||||
if template.assign_storage_path is not None:
|
||||
template_overrides.storage_path_id = template.assign_storage_path.pk
|
||||
if template.assign_owner is not None:
|
||||
template_overrides.owner_id = template.assign_owner.pk
|
||||
if template.assign_view_users is not None:
|
||||
template_overrides.view_users = [
|
||||
user.pk for user in template.assign_view_users.all()
|
||||
]
|
||||
if template.assign_view_groups is not None:
|
||||
template_overrides.view_groups = [
|
||||
group.pk for group in template.assign_view_groups.all()
|
||||
]
|
||||
if template.assign_change_users is not None:
|
||||
template_overrides.change_users = [
|
||||
user.pk for user in template.assign_change_users.all()
|
||||
]
|
||||
if template.assign_change_groups is not None:
|
||||
template_overrides.change_groups = [
|
||||
group.pk for group in template.assign_change_groups.all()
|
||||
]
|
||||
overrides.update(template_overrides)
|
||||
return overrides
|
||||
|
||||
def _parse_title_placeholders(self, title: str) -> str:
|
||||
"""
|
||||
Consumption template title placeholders can only include items that are
|
||||
assigned as part of this template (since auto-matching hasnt happened yet)
|
||||
"""
|
||||
local_added = timezone.localtime(timezone.now())
|
||||
|
||||
correspondent_name = (
|
||||
Correspondent.objects.get(pk=self.override_correspondent_id).name
|
||||
if self.override_correspondent_id is not None
|
||||
else None
|
||||
)
|
||||
doc_type_name = (
|
||||
DocumentType.objects.get(pk=self.override_document_type_id).name
|
||||
if self.override_correspondent_id is not None
|
||||
else None
|
||||
)
|
||||
owner_username = (
|
||||
User.objects.get(pk=self.override_owner_id).username
|
||||
if self.override_owner_id is not None
|
||||
else None
|
||||
)
|
||||
|
||||
return title.format(
|
||||
correspondent=correspondent_name,
|
||||
document_type=doc_type_name,
|
||||
added=local_added.isoformat(),
|
||||
added_year=local_added.strftime("%Y"),
|
||||
added_year_short=local_added.strftime("%y"),
|
||||
added_month=local_added.strftime("%m"),
|
||||
added_month_name=local_added.strftime("%B"),
|
||||
added_month_name_short=local_added.strftime("%b"),
|
||||
added_day=local_added.strftime("%d"),
|
||||
owner_username=owner_username,
|
||||
).strip()
|
||||
|
||||
def _store(
|
||||
self,
|
||||
text: str,
|
||||
@@ -612,7 +714,11 @@ class Consumer(LoggingMixin):
|
||||
|
||||
with open(self.path, "rb") as f:
|
||||
document = Document.objects.create(
|
||||
title=(self.override_title or file_info.title)[:127],
|
||||
title=(
|
||||
self._parse_title_placeholders(self.override_title)
|
||||
if self.override_title is not None
|
||||
else file_info.title
|
||||
)[:127],
|
||||
content=text,
|
||||
mime_type=mime_type,
|
||||
checksum=hashlib.md5(f.read()).hexdigest(),
|
||||
@@ -643,6 +749,11 @@ class Consumer(LoggingMixin):
|
||||
for tag_id in self.override_tag_ids:
|
||||
document.tags.add(Tag.objects.get(pk=tag_id))
|
||||
|
||||
if self.override_storage_path_id:
|
||||
document.storage_path = StoragePath.objects.get(
|
||||
pk=self.override_storage_path_id,
|
||||
)
|
||||
|
||||
if self.override_asn:
|
||||
document.archive_serial_number = self.override_asn
|
||||
|
||||
@@ -651,6 +762,24 @@ class Consumer(LoggingMixin):
|
||||
pk=self.override_owner_id,
|
||||
)
|
||||
|
||||
if (
|
||||
self.override_view_users is not None
|
||||
or self.override_view_groups is not None
|
||||
or self.override_change_users is not None
|
||||
or self.override_change_users is not None
|
||||
):
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": self.override_view_users or [],
|
||||
"groups": self.override_view_groups or [],
|
||||
},
|
||||
"change": {
|
||||
"users": self.override_change_users or [],
|
||||
"groups": self.override_change_groups or [],
|
||||
},
|
||||
}
|
||||
set_permissions_for_object(permissions=permissions, object=document)
|
||||
|
||||
def _write(self, storage_type, source, target):
|
||||
with open(source, "rb") as read_file, open(target, "wb") as write_file:
|
||||
write_file.write(read_file.read())
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import dataclasses
|
||||
import datetime
|
||||
import enum
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -20,19 +20,70 @@ class DocumentMetadataOverrides:
|
||||
correspondent_id: Optional[int] = None
|
||||
document_type_id: Optional[int] = None
|
||||
tag_ids: Optional[list[int]] = None
|
||||
storage_path_id: Optional[int] = None
|
||||
created: Optional[datetime.datetime] = None
|
||||
asn: Optional[int] = None
|
||||
owner_id: Optional[int] = None
|
||||
view_users: Optional[list[int]] = None
|
||||
view_groups: Optional[list[int]] = None
|
||||
change_users: Optional[list[int]] = None
|
||||
change_groups: Optional[list[int]] = None
|
||||
|
||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||
"""
|
||||
Merges two DocumentMetadataOverrides objects such that object B's overrides
|
||||
are only applied if the property is empty in object A or merged if multiple
|
||||
are accepted.
|
||||
|
||||
The update is an in-place modification of self
|
||||
"""
|
||||
# only if empty
|
||||
if self.title is None:
|
||||
self.title = other.title
|
||||
if self.correspondent_id is None:
|
||||
self.correspondent_id = other.correspondent_id
|
||||
if self.document_type_id is None:
|
||||
self.document_type_id = other.document_type_id
|
||||
if self.storage_path_id is None:
|
||||
self.storage_path_id = other.storage_path_id
|
||||
if self.owner_id is None:
|
||||
self.owner_id = other.owner_id
|
||||
# merge
|
||||
# TODO: Handle the case where other is also None
|
||||
if self.tag_ids is None:
|
||||
self.tag_ids = other.tag_ids
|
||||
else:
|
||||
self.tag_ids.extend(other.tag_ids)
|
||||
if self.view_users is None:
|
||||
self.view_users = other.view_users
|
||||
else:
|
||||
self.view_users.extend(other.view_users)
|
||||
if self.view_groups is None:
|
||||
self.view_groups = other.view_groups
|
||||
else:
|
||||
self.view_groups.extend(other.view_groups)
|
||||
if self.change_users is None:
|
||||
self.change_users = other.change_users
|
||||
else:
|
||||
self.change_users.extend(other.change_users)
|
||||
if self.change_groups is None:
|
||||
self.change_groups = other.change_groups
|
||||
else:
|
||||
self.change_groups = [
|
||||
*self.change_groups,
|
||||
*other.change_groups,
|
||||
]
|
||||
return self
|
||||
|
||||
|
||||
class DocumentSource(enum.IntEnum):
|
||||
class DocumentSource(IntEnum):
|
||||
"""
|
||||
The source of an incoming document. May have other uses in the future
|
||||
"""
|
||||
|
||||
ConsumeFolder = enum.auto()
|
||||
ApiUpload = enum.auto()
|
||||
MailFetch = enum.auto()
|
||||
ConsumeFolder = 1
|
||||
ApiUpload = 2
|
||||
MailFetch = 3
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -44,6 +95,7 @@ class ConsumableDocument:
|
||||
|
||||
source: DocumentSource
|
||||
original_file: Path
|
||||
mailrule_id: Optional[int] = None
|
||||
mime_type: str = dataclasses.field(init=False, default=None)
|
||||
|
||||
def __post_init__(self):
|
||||
|
@@ -1,7 +1,11 @@
|
||||
import logging
|
||||
import re
|
||||
from fnmatch import fnmatch
|
||||
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
@@ -231,3 +235,67 @@ def _split_match(matching_model):
|
||||
re.escape(normspace(" ", (t[0] or t[1]).strip())).replace(r"\ ", r"\s+")
|
||||
for t in findterms(matching_model.match)
|
||||
]
|
||||
|
||||
|
||||
def document_matches_template(
|
||||
document: ConsumableDocument,
|
||||
template: ConsumptionTemplate,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if the incoming document matches all filters and
|
||||
settings from the template, False otherwise
|
||||
"""
|
||||
|
||||
def log_match_failure(reason: str):
|
||||
logger.info(f"Document did not match template {template.name}")
|
||||
logger.debug(reason)
|
||||
|
||||
# Document source vs template source
|
||||
if document.source not in [int(x) for x in list(template.sources)]:
|
||||
log_match_failure(
|
||||
f"Document source {document.source.name} not in"
|
||||
f" {[DocumentSource(int(x)).name for x in template.sources]}",
|
||||
)
|
||||
return False
|
||||
|
||||
# Document mail rule vs template mail rule
|
||||
if (
|
||||
document.mailrule_id is not None
|
||||
and template.filter_mailrule is not None
|
||||
and document.mailrule_id != template.filter_mailrule.pk
|
||||
):
|
||||
log_match_failure(
|
||||
f"Document mail rule {document.mailrule_id}"
|
||||
f" != {template.filter_mailrule.pk}",
|
||||
)
|
||||
return False
|
||||
|
||||
# Document filename vs template filename
|
||||
if (
|
||||
template.filter_filename is not None
|
||||
and len(template.filter_filename) > 0
|
||||
and not fnmatch(
|
||||
document.original_file.name.lower(),
|
||||
template.filter_filename.lower(),
|
||||
)
|
||||
):
|
||||
log_match_failure(
|
||||
f"Document filename {document.original_file.name} does not match"
|
||||
f" {template.filter_filename.lower()}",
|
||||
)
|
||||
return False
|
||||
|
||||
# Document path vs template path
|
||||
if (
|
||||
template.filter_path is not None
|
||||
and len(template.filter_path) > 0
|
||||
and not document.original_file.match(template.filter_path)
|
||||
):
|
||||
log_match_failure(
|
||||
f"Document path {document.original_file}"
|
||||
f" does not match {template.filter_path}",
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(f"Document matched template {template.name}")
|
||||
return True
|
||||
|
219
src/documents/migrations/1039_consumptiontemplate.py
Normal file
219
src/documents/migrations/1039_consumptiontemplate.py
Normal file
@@ -0,0 +1,219 @@
|
||||
# Generated by Django 4.1.11 on 2023-09-16 18:04
|
||||
|
||||
import django.db.models.deletion
|
||||
import multiselectfield.db.fields
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.management import create_permissions
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
def add_consumptiontemplate_permissions(apps, schema_editor):
|
||||
# create permissions without waiting for post_migrate signal
|
||||
for app_config in apps.get_app_configs():
|
||||
app_config.models_module = True
|
||||
create_permissions(app_config, apps=apps, verbosity=0)
|
||||
app_config.models_module = None
|
||||
|
||||
add_permission = Permission.objects.get(codename="add_document")
|
||||
consumptiontemplate_permissions = Permission.objects.filter(
|
||||
codename__contains="consumptiontemplate",
|
||||
)
|
||||
|
||||
for user in User.objects.filter(Q(user_permissions=add_permission)).distinct():
|
||||
user.user_permissions.add(*consumptiontemplate_permissions)
|
||||
|
||||
for group in Group.objects.filter(Q(permissions=add_permission)).distinct():
|
||||
group.permissions.add(*consumptiontemplate_permissions)
|
||||
|
||||
|
||||
def remove_consumptiontemplate_permissions(apps, schema_editor):
|
||||
consumptiontemplate_permissions = Permission.objects.filter(
|
||||
codename__contains="consumptiontemplate",
|
||||
)
|
||||
|
||||
for user in User.objects.all():
|
||||
user.user_permissions.remove(*consumptiontemplate_permissions)
|
||||
|
||||
for group in Group.objects.all():
|
||||
group.permissions.remove(*consumptiontemplate_permissions)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("documents", "1038_sharelink"),
|
||||
("paperless_mail", "0021_alter_mailaccount_password"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ConsumptionTemplate",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(max_length=256, unique=True, verbose_name="name"),
|
||||
),
|
||||
("order", models.IntegerField(default=0, verbose_name="order")),
|
||||
(
|
||||
"sources",
|
||||
multiselectfield.db.fields.MultiSelectField(
|
||||
choices=[
|
||||
(1, "Consume Folder"),
|
||||
(2, "Api Upload"),
|
||||
(3, "Mail Fetch"),
|
||||
],
|
||||
default="1,2,3",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_path",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="filter path",
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_filename",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="filter filename",
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_mailrule",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="paperless_mail.mailrule",
|
||||
verbose_name="filter documents from this mail rule",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_change_groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to="auth.group",
|
||||
verbose_name="grant change permissions to these groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_change_users",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="grant change permissions to these users",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_correspondent",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.correspondent",
|
||||
verbose_name="assign this correspondent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_document_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.documenttype",
|
||||
verbose_name="assign this document type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="assign this owner",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_storage_path",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.storagepath",
|
||||
verbose_name="assign this storage path",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_tags",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
to="documents.tag",
|
||||
verbose_name="assign this tag",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_title",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Assign a document title, can include some placeholders, see documentation.",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="assign title",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_view_groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to="auth.group",
|
||||
verbose_name="grant view permissions to these groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_view_users",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="grant view permissions to these users",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "consumption template",
|
||||
"verbose_name_plural": "consumption templates",
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
add_consumptiontemplate_permissions,
|
||||
remove_consumptiontemplate_permissions,
|
||||
),
|
||||
]
|
@@ -11,18 +11,18 @@ import dateutil.parser
|
||||
import pathvalidate
|
||||
from celery import states
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from multiselectfield import MultiSelectField
|
||||
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.parsers import get_default_file_extension
|
||||
|
||||
ALL_STATES = sorted(states.ALL_STATES)
|
||||
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
|
||||
|
||||
|
||||
class ModelWithOwner(models.Model):
|
||||
owner = models.ForeignKey(
|
||||
@@ -572,6 +572,9 @@ class UiSettings(models.Model):
|
||||
|
||||
|
||||
class PaperlessTask(models.Model):
|
||||
ALL_STATES = sorted(states.ALL_STATES)
|
||||
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
|
||||
|
||||
task_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
@@ -735,3 +738,137 @@ class ShareLink(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"Share Link for {self.document.title}"
|
||||
|
||||
|
||||
class ConsumptionTemplate(models.Model):
|
||||
class DocumentSourceChoices(models.IntegerChoices):
|
||||
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
|
||||
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
|
||||
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
|
||||
|
||||
name = models.CharField(_("name"), max_length=256, unique=True)
|
||||
|
||||
order = models.IntegerField(_("order"), default=0)
|
||||
|
||||
sources = MultiSelectField(
|
||||
max_length=3,
|
||||
choices=DocumentSourceChoices.choices,
|
||||
default=f"{DocumentSource.ConsumeFolder},{DocumentSource.ApiUpload},{DocumentSource.MailFetch}",
|
||||
)
|
||||
|
||||
filter_path = models.CharField(
|
||||
_("filter path"),
|
||||
max_length=256,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Only consume documents with a path that matches "
|
||||
"this if specified. Wildcards specified as * are "
|
||||
"allowed. Case insensitive.",
|
||||
),
|
||||
)
|
||||
|
||||
filter_filename = models.CharField(
|
||||
_("filter filename"),
|
||||
max_length=256,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Only consume documents which entirely match this "
|
||||
"filename if specified. Wildcards such as *.pdf or "
|
||||
"*invoice* are allowed. Case insensitive.",
|
||||
),
|
||||
)
|
||||
|
||||
filter_mailrule = models.ForeignKey(
|
||||
"paperless_mail.MailRule",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("filter documents from this mail rule"),
|
||||
)
|
||||
|
||||
assign_title = models.CharField(
|
||||
_("assign title"),
|
||||
max_length=256,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Assign a document title, can include some placeholders, "
|
||||
"see documentation.",
|
||||
),
|
||||
)
|
||||
|
||||
assign_tags = models.ManyToManyField(
|
||||
Tag,
|
||||
blank=True,
|
||||
verbose_name=_("assign this tag"),
|
||||
)
|
||||
|
||||
assign_document_type = models.ForeignKey(
|
||||
DocumentType,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("assign this document type"),
|
||||
)
|
||||
|
||||
assign_correspondent = models.ForeignKey(
|
||||
Correspondent,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("assign this correspondent"),
|
||||
)
|
||||
|
||||
assign_storage_path = models.ForeignKey(
|
||||
StoragePath,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("assign this storage path"),
|
||||
)
|
||||
|
||||
assign_owner = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
verbose_name=_("assign this owner"),
|
||||
)
|
||||
|
||||
assign_view_users = models.ManyToManyField(
|
||||
User,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
verbose_name=_("grant view permissions to these users"),
|
||||
)
|
||||
|
||||
assign_view_groups = models.ManyToManyField(
|
||||
Group,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
verbose_name=_("grant view permissions to these groups"),
|
||||
)
|
||||
|
||||
assign_change_users = models.ManyToManyField(
|
||||
User,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
verbose_name=_("grant change permissions to these users"),
|
||||
)
|
||||
|
||||
assign_change_groups = models.ManyToManyField(
|
||||
Group,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
verbose_name=_("grant change permissions to these groups"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("consumption template")
|
||||
verbose_name_plural = _("consumption templates")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
@@ -13,9 +13,12 @@ from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from rest_framework import fields
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import set_permissions_for_object
|
||||
|
||||
@@ -1035,3 +1038,56 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
|
||||
self._validate_permissions(permissions)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ConsumptionTemplateSerializer(serializers.ModelSerializer):
|
||||
order = serializers.IntegerField(required=False)
|
||||
sources = fields.MultipleChoiceField(
|
||||
choices=ConsumptionTemplate.DocumentSourceChoices.choices,
|
||||
allow_empty=False,
|
||||
default={
|
||||
DocumentSource.ConsumeFolder,
|
||||
DocumentSource.ApiUpload,
|
||||
DocumentSource.MailFetch,
|
||||
},
|
||||
)
|
||||
assign_correspondent = CorrespondentField(allow_null=True, required=False)
|
||||
assign_tags = TagsField(many=True, allow_null=True, required=False)
|
||||
assign_document_type = DocumentTypeField(allow_null=True, required=False)
|
||||
assign_storage_path = StoragePathField(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = ConsumptionTemplate
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"order",
|
||||
"sources",
|
||||
"filter_path",
|
||||
"filter_filename",
|
||||
"filter_mailrule",
|
||||
"assign_title",
|
||||
"assign_tags",
|
||||
"assign_correspondent",
|
||||
"assign_document_type",
|
||||
"assign_storage_path",
|
||||
"assign_owner",
|
||||
"assign_view_users",
|
||||
"assign_view_groups",
|
||||
"assign_change_users",
|
||||
"assign_change_groups",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
if ("filter_mailrule") in attrs and attrs["filter_mailrule"] is not None:
|
||||
attrs["sources"] = {DocumentSource.MailFetch.value}
|
||||
if (
|
||||
("filter_mailrule" not in attrs)
|
||||
and ("filter_filename" not in attrs or len(attrs["filter_filename"]) == 0)
|
||||
and ("filter_path" not in attrs or len(attrs["filter_path"]) == 0)
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"File name, path or mail rule filter are required",
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
@@ -153,6 +153,12 @@ def consume_file(
|
||||
overrides.asn = reader.asn
|
||||
logger.info(f"Found ASN in barcode: {overrides.asn}")
|
||||
|
||||
template_overrides = Consumer().get_template_overrides(
|
||||
input_doc=input_doc,
|
||||
)
|
||||
|
||||
overrides.update(template_overrides)
|
||||
|
||||
# continue with consumption if no barcode was found
|
||||
document = Consumer().try_consume_file(
|
||||
input_doc.original_file,
|
||||
@@ -161,9 +167,14 @@ def consume_file(
|
||||
override_correspondent_id=overrides.correspondent_id,
|
||||
override_document_type_id=overrides.document_type_id,
|
||||
override_tag_ids=overrides.tag_ids,
|
||||
override_storage_path_id=overrides.storage_path_id,
|
||||
override_created=overrides.created,
|
||||
override_asn=overrides.asn,
|
||||
override_owner_id=overrides.owner_id,
|
||||
override_view_users=overrides.view_users,
|
||||
override_view_groups=overrides.view_groups,
|
||||
override_change_users=overrides.change_users,
|
||||
override_change_groups=overrides.change_groups,
|
||||
task_id=self.request.id,
|
||||
)
|
||||
|
||||
|
@@ -32,6 +32,8 @@ from whoosh.writing import AsyncWriter
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents import index
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
@@ -45,6 +47,8 @@ from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import DocumentConsumeDelayMixin
|
||||
from paperless import version
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
@@ -5313,3 +5317,168 @@ class TestBulkEditObjectPermissions(APITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
|
||||
ENDPOINT = "/api/consumption_templates/"
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
self.client.force_authenticate(user=user)
|
||||
self.user2 = User.objects.create(username="user2")
|
||||
self.user3 = User.objects.create(username="user3")
|
||||
self.group1 = Group.objects.create(name="group1")
|
||||
|
||||
self.c = Correspondent.objects.create(name="Correspondent Name")
|
||||
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
|
||||
self.dt = DocumentType.objects.create(name="DocType Name")
|
||||
self.t1 = Tag.objects.create(name="t1")
|
||||
self.t2 = Tag.objects.create(name="t2")
|
||||
self.t3 = Tag.objects.create(name="t3")
|
||||
self.sp = StoragePath.objects.create(path="/test/")
|
||||
|
||||
self.ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}",
|
||||
filter_filename="*simple*",
|
||||
filter_path="*/samples/*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
self.ct.assign_tags.add(self.t1)
|
||||
self.ct.assign_tags.add(self.t2)
|
||||
self.ct.assign_tags.add(self.t3)
|
||||
self.ct.assign_view_users.add(self.user3.pk)
|
||||
self.ct.assign_view_groups.add(self.group1.pk)
|
||||
self.ct.assign_change_users.add(self.user3.pk)
|
||||
self.ct.assign_change_groups.add(self.group1.pk)
|
||||
self.ct.save()
|
||||
|
||||
def test_api_get_consumption_template(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to get all consumption template
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Existing consumption templates are returned
|
||||
"""
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
|
||||
resp_consumption_template = response.data["results"][0]
|
||||
self.assertEqual(resp_consumption_template["id"], self.ct.id)
|
||||
self.assertEqual(
|
||||
resp_consumption_template["assign_correspondent"],
|
||||
self.ct.assign_correspondent.pk,
|
||||
)
|
||||
|
||||
def test_api_create_consumption_template(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP response
|
||||
- New template is created
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_filename": "*test*",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ConsumptionTemplate.objects.count(), 2)
|
||||
|
||||
def test_api_create_invalid_consumption_template(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template
|
||||
- Neither file name nor path filter are specified
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP 400 response
|
||||
- No template is created
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(StoragePath.objects.count(), 1)
|
||||
|
||||
def test_api_create_consumption_template_with_mailrule(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template with a mail rule but no MailFetch source
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP response
|
||||
- New template is created with MailFetch as source
|
||||
"""
|
||||
account1 = MailAccount.objects.create(
|
||||
name="Email1",
|
||||
username="username1",
|
||||
password="password1",
|
||||
imap_server="server.example.com",
|
||||
imap_port=443,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
)
|
||||
rule1 = MailRule.objects.create(
|
||||
name="Rule1",
|
||||
account=account1,
|
||||
folder="INBOX",
|
||||
filter_from="from@example.com",
|
||||
filter_to="someone@somewhere.com",
|
||||
filter_subject="subject",
|
||||
filter_body="body",
|
||||
filter_attachment_filename="file.pdf",
|
||||
maximum_age=30,
|
||||
action=MailRule.MailAction.MARK_READ,
|
||||
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
|
||||
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
|
||||
order=0,
|
||||
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||
)
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_mailrule": rule1.pk,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ConsumptionTemplate.objects.count(), 2)
|
||||
ct = ConsumptionTemplate.objects.get(name="Template 2")
|
||||
self.assertEqual(ct.sources, [int(DocumentSource.MailFetch).__str__()])
|
||||
|
@@ -11,9 +11,12 @@ from unittest.mock import MagicMock
|
||||
|
||||
from dateutil import tz
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
|
||||
from documents.consumer import Consumer
|
||||
from documents.consumer import ConsumerError
|
||||
@@ -22,6 +25,7 @@ from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import FileInfo
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.parsers import DocumentParser
|
||||
from documents.parsers import ParseError
|
||||
@@ -431,6 +435,16 @@ class TestConsumer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(document.document_type.id, dt.id)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideStoragePath(self):
|
||||
sp = StoragePath.objects.create(name="test")
|
||||
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_storage_path_id=sp.pk,
|
||||
)
|
||||
self.assertEqual(document.storage_path.id, sp.id)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideTags(self):
|
||||
t1 = Tag.objects.create(name="t1")
|
||||
t2 = Tag.objects.create(name="t2")
|
||||
@@ -445,6 +459,51 @@ class TestConsumer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertIn(t3, document.tags.all())
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideAsn(self):
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_asn=123,
|
||||
)
|
||||
self.assertEqual(document.archive_serial_number, 123)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideTitlePlaceholders(self):
|
||||
c = Correspondent.objects.create(name="Correspondent Name")
|
||||
dt = DocumentType.objects.create(name="DocType Name")
|
||||
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_correspondent_id=c.pk,
|
||||
override_document_type_id=dt.pk,
|
||||
override_title="{correspondent}{document_type} {added_month}-{added_year_short}",
|
||||
)
|
||||
now = timezone.now()
|
||||
self.assertEqual(document.title, f"{c.name}{dt.name} {now.strftime('%m-%y')}")
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverrideOwner(self):
|
||||
testuser = User.objects.create(username="testuser")
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_owner_id=testuser.pk,
|
||||
)
|
||||
self.assertEqual(document.owner, testuser)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testOverridePermissions(self):
|
||||
testuser = User.objects.create(username="testuser")
|
||||
testgroup = Group.objects.create(name="testgroup")
|
||||
document = self.consumer.try_consume_file(
|
||||
self.get_test_file(),
|
||||
override_view_users=[testuser.pk],
|
||||
override_view_groups=[testgroup.pk],
|
||||
)
|
||||
user_checker = ObjectPermissionChecker(testuser)
|
||||
self.assertTrue(user_checker.has_perm("view_document", document))
|
||||
group_checker = ObjectPermissionChecker(testgroup)
|
||||
self.assertTrue(group_checker.has_perm("view_document", document))
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testNotAFile(self):
|
||||
self.assertRaisesMessage(
|
||||
ConsumerError,
|
||||
|
476
src/documents/tests/test_consumption_templates.py
Normal file
476
src/documents/tests/test_consumption_templates.py
Normal file
@@ -0,0 +1,476 @@
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents import tasks
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
SAMPLE_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.c = Correspondent.objects.create(name="Correspondent Name")
|
||||
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
|
||||
self.dt = DocumentType.objects.create(name="DocType Name")
|
||||
self.t1 = Tag.objects.create(name="t1")
|
||||
self.t2 = Tag.objects.create(name="t2")
|
||||
self.t3 = Tag.objects.create(name="t3")
|
||||
self.sp = StoragePath.objects.create(path="/test/")
|
||||
|
||||
self.user2 = User.objects.create(username="user2")
|
||||
self.user3 = User.objects.create(username="user3")
|
||||
self.group1 = Group.objects.create(name="group1")
|
||||
|
||||
account1 = MailAccount.objects.create(
|
||||
name="Email1",
|
||||
username="username1",
|
||||
password="password1",
|
||||
imap_server="server.example.com",
|
||||
imap_port=443,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
)
|
||||
self.rule1 = MailRule.objects.create(
|
||||
name="Rule1",
|
||||
account=account1,
|
||||
folder="INBOX",
|
||||
filter_from="from@example.com",
|
||||
filter_to="someone@somewhere.com",
|
||||
filter_subject="subject",
|
||||
filter_body="body",
|
||||
filter_attachment_filename="file.pdf",
|
||||
maximum_age=30,
|
||||
action=MailRule.MailAction.MARK_READ,
|
||||
assign_title_from=MailRule.TitleSource.NONE,
|
||||
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
|
||||
order=0,
|
||||
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||
assign_owner_from_rule=False,
|
||||
)
|
||||
|
||||
return super().setUp()
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_match(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that matches is consumed
|
||||
THEN:
|
||||
- Template overrides are applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*simple*",
|
||||
filter_path="*/samples/*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
ct.assign_tags.add(self.t1)
|
||||
ct.assign_tags.add(self.t2)
|
||||
ct.assign_tags.add(self.t3)
|
||||
ct.assign_view_users.add(self.user3.pk)
|
||||
ct.assign_view_groups.add(self.group1.pk)
|
||||
ct.assign_change_users.add(self.user3.pk)
|
||||
ct.assign_change_groups.add(self.group1.pk)
|
||||
ct.save()
|
||||
|
||||
self.assertEqual(ct.__str__(), "Template 1")
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
|
||||
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
|
||||
self.assertEqual(
|
||||
overrides["override_tag_ids"],
|
||||
[self.t1.pk, self.t2.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
|
||||
self.assertEqual(overrides["override_owner_id"], self.user2.pk)
|
||||
self.assertEqual(overrides["override_view_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
|
||||
self.assertEqual(overrides["override_change_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
|
||||
self.assertEqual(
|
||||
overrides["override_title"],
|
||||
"Doc from {correspondent}",
|
||||
)
|
||||
|
||||
info = cm.output[0]
|
||||
expected_str = f"Document matched template {ct}"
|
||||
self.assertIn(expected_str, info)
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_match_mailrule(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that matches is consumed via mail rule
|
||||
THEN:
|
||||
- Template overrides are applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_mailrule=self.rule1,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
ct.assign_tags.add(self.t1)
|
||||
ct.assign_tags.add(self.t2)
|
||||
ct.assign_tags.add(self.t3)
|
||||
ct.assign_view_users.add(self.user3.pk)
|
||||
ct.assign_view_groups.add(self.group1.pk)
|
||||
ct.assign_change_users.add(self.user3.pk)
|
||||
ct.assign_change_groups.add(self.group1.pk)
|
||||
ct.save()
|
||||
|
||||
self.assertEqual(ct.__str__(), "Template 1")
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
mailrule_id=self.rule1.pk,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
|
||||
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
|
||||
self.assertEqual(
|
||||
overrides["override_tag_ids"],
|
||||
[self.t1.pk, self.t2.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
|
||||
self.assertEqual(overrides["override_owner_id"], self.user2.pk)
|
||||
self.assertEqual(overrides["override_view_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
|
||||
self.assertEqual(overrides["override_change_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
|
||||
self.assertEqual(
|
||||
overrides["override_title"],
|
||||
"Doc from {correspondent}",
|
||||
)
|
||||
|
||||
info = cm.output[0]
|
||||
expected_str = f"Document matched template {ct}"
|
||||
self.assertIn(expected_str, info)
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_match_multiple(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Multiple existing consumption template
|
||||
WHEN:
|
||||
- File that matches is consumed
|
||||
THEN:
|
||||
- Template overrides are applied with subsequent templates only overwriting empty values
|
||||
or merging if multiple
|
||||
"""
|
||||
ct1 = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_path="*/samples/*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
)
|
||||
ct1.assign_tags.add(self.t1)
|
||||
ct1.assign_tags.add(self.t2)
|
||||
ct1.assign_view_users.add(self.user2)
|
||||
ct1.save()
|
||||
ct2 = ConsumptionTemplate.objects.create(
|
||||
name="Template 2",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*simple*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c2,
|
||||
assign_storage_path=self.sp,
|
||||
)
|
||||
ct2.assign_tags.add(self.t3)
|
||||
ct1.assign_view_users.add(self.user3)
|
||||
ct2.save()
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
# template 1
|
||||
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
|
||||
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
|
||||
# template 2
|
||||
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
|
||||
# template 1 & 2
|
||||
self.assertEqual(
|
||||
overrides["override_tag_ids"],
|
||||
[self.t1.pk, self.t2.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(
|
||||
overrides["override_view_users"],
|
||||
[self.user2.pk, self.user3.pk],
|
||||
)
|
||||
|
||||
expected_str = f"Document matched template {ct1}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document matched template {ct2}"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_filename(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on filename is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*foobar*",
|
||||
filter_path=None,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document filename {test_file.name} does not match"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_path(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on path is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_path="*foo/bar*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document path {test_file} does not match"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_mail_rule(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on source is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_mailrule=self.rule1,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
mailrule_id=99,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = "Document mail rule 99 !="
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_source(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on source is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_path="*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ApiUpload,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document source {DocumentSource.ApiUpload.name} not in ['{DocumentSource.ConsumeFolder.name}', '{DocumentSource.MailFetch.name}']"
|
||||
self.assertIn(expected_str, cm.output[1])
|
@@ -153,7 +153,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
manifest = self._do_export(use_filename_format=use_filename_format)
|
||||
|
||||
self.assertEqual(len(manifest), 154)
|
||||
self.assertEqual(len(manifest), 159)
|
||||
|
||||
# dont include consumer or AnonymousUser users
|
||||
self.assertEqual(
|
||||
@@ -247,7 +247,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
|
||||
self.assertEqual(GroupObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(UserObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(Permission.objects.count(), 112)
|
||||
self.assertEqual(Permission.objects.count(), 116)
|
||||
messages = check_sanity()
|
||||
# everything is alright after the test
|
||||
self.assertEqual(len(messages), 0)
|
||||
@@ -676,15 +676,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
os.path.join(self.dirs.media_dir, "documents"),
|
||||
)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 28)
|
||||
self.assertEqual(Permission.objects.count(), 112)
|
||||
self.assertEqual(ContentType.objects.count(), 29)
|
||||
self.assertEqual(Permission.objects.count(), 116)
|
||||
|
||||
manifest = self._do_export()
|
||||
|
||||
with paperless_environment():
|
||||
self.assertEqual(
|
||||
len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
|
||||
112,
|
||||
116,
|
||||
)
|
||||
# add 1 more to db to show objects are not re-created by import
|
||||
Permission.objects.create(
|
||||
@@ -692,7 +692,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
codename="test_perm",
|
||||
content_type_id=1,
|
||||
)
|
||||
self.assertEqual(Permission.objects.count(), 113)
|
||||
self.assertEqual(Permission.objects.count(), 117)
|
||||
|
||||
# will cause an import error
|
||||
self.user.delete()
|
||||
@@ -701,5 +701,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
with self.assertRaises(IntegrityError):
|
||||
call_command("document_importer", "--no-progress-bar", self.target)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 28)
|
||||
self.assertEqual(Permission.objects.count(), 113)
|
||||
self.assertEqual(ContentType.objects.count(), 29)
|
||||
self.assertEqual(Permission.objects.count(), 117)
|
||||
|
43
src/documents/tests/test_migration_consumption_templates.py
Normal file
43
src/documents/tests/test_migration_consumption_templates.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from documents.tests.utils import TestMigrations
|
||||
|
||||
|
||||
class TestMigrateConsumptionTemplate(TestMigrations):
|
||||
migrate_from = "1038_sharelink"
|
||||
migrate_to = "1039_consumptiontemplate"
|
||||
|
||||
def setUpBeforeMigration(self, apps):
|
||||
User = get_user_model()
|
||||
Group = apps.get_model("auth.Group")
|
||||
self.Permission = apps.get_model("auth", "Permission")
|
||||
self.user = User.objects.create(username="user1")
|
||||
self.group = Group.objects.create(name="group1")
|
||||
permission = self.Permission.objects.get(codename="add_document")
|
||||
self.user.user_permissions.add(permission.id)
|
||||
self.group.permissions.add(permission.id)
|
||||
|
||||
def test_users_with_add_documents_get_add_consumptiontemplate(self):
|
||||
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
|
||||
self.assertTrue(self.user.has_perm(f"documents.{permission.codename}"))
|
||||
self.assertTrue(permission in self.group.permissions.all())
|
||||
|
||||
|
||||
class TestReverseMigrateConsumptionTemplate(TestMigrations):
|
||||
migrate_from = "1039_consumptiontemplate"
|
||||
migrate_to = "1038_sharelink"
|
||||
|
||||
def setUpBeforeMigration(self, apps):
|
||||
User = get_user_model()
|
||||
Group = apps.get_model("auth.Group")
|
||||
self.Permission = apps.get_model("auth", "Permission")
|
||||
self.user = User.objects.create(username="user1")
|
||||
self.group = Group.objects.create(name="group1")
|
||||
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
|
||||
self.user.user_permissions.add(permission.id)
|
||||
self.group.permissions.add(permission.id)
|
||||
|
||||
def test_remove_consumptiontemplate_permissions(self):
|
||||
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
|
||||
self.assertFalse(self.user.has_perm(f"documents.{permission.codename}"))
|
||||
self.assertFalse(permission in self.group.permissions.all())
|
@@ -86,6 +86,7 @@ from .matching import match_correspondents
|
||||
from .matching import match_document_types
|
||||
from .matching import match_storage_paths
|
||||
from .matching import match_tags
|
||||
from .models import ConsumptionTemplate
|
||||
from .models import Correspondent
|
||||
from .models import Document
|
||||
from .models import DocumentType
|
||||
@@ -101,6 +102,7 @@ from .serialisers import AcknowledgeTasksViewSerializer
|
||||
from .serialisers import BulkDownloadSerializer
|
||||
from .serialisers import BulkEditObjectPermissionsSerializer
|
||||
from .serialisers import BulkEditSerializer
|
||||
from .serialisers import ConsumptionTemplateSerializer
|
||||
from .serialisers import CorrespondentSerializer
|
||||
from .serialisers import DocumentListSerializer
|
||||
from .serialisers import DocumentSerializer
|
||||
@@ -1248,3 +1250,14 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
|
||||
return HttpResponseBadRequest(
|
||||
"Error performing bulk permissions edit, check logs for more detail.",
|
||||
)
|
||||
|
||||
|
||||
class ConsumptionTemplateViewSet(ModelViewSet):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = ConsumptionTemplateSerializer
|
||||
pagination_class = StandardPagination
|
||||
|
||||
model = ConsumptionTemplate
|
||||
|
||||
queryset = ConsumptionTemplate.objects.all().order_by("order")
|
||||
|
@@ -21,555 +21,648 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:33 documents/models.py:728
|
||||
#: documents/models.py:36 documents/models.py:731
|
||||
msgid "owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:50
|
||||
#: documents/models.py:53
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:51
|
||||
#: documents/models.py:54
|
||||
msgid "Any word"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:52
|
||||
#: documents/models.py:55
|
||||
msgid "All words"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:53
|
||||
#: documents/models.py:56
|
||||
msgid "Exact match"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:54
|
||||
#: documents/models.py:57
|
||||
msgid "Regular expression"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:55
|
||||
#: documents/models.py:58
|
||||
msgid "Fuzzy word"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:56
|
||||
#: documents/models.py:59
|
||||
msgid "Automatic"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:59 documents/models.py:399 paperless_mail/models.py:18
|
||||
#: paperless_mail/models.py:92
|
||||
#: documents/models.py:62 documents/models.py:402 documents/models.py:755
|
||||
#: paperless_mail/models.py:18 paperless_mail/models.py:93
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:61
|
||||
#: documents/models.py:64
|
||||
msgid "match"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:64
|
||||
#: documents/models.py:67
|
||||
msgid "matching algorithm"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:69
|
||||
#: documents/models.py:72
|
||||
msgid "is insensitive"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:92 documents/models.py:144
|
||||
#: documents/models.py:95 documents/models.py:147
|
||||
msgid "correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:93
|
||||
#: documents/models.py:96
|
||||
msgid "correspondents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:97
|
||||
#: documents/models.py:100
|
||||
msgid "color"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:100
|
||||
#: documents/models.py:103
|
||||
msgid "is inbox tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:103
|
||||
#: documents/models.py:106
|
||||
msgid ""
|
||||
"Marks this tag as an inbox tag: All newly consumed documents will be tagged "
|
||||
"with inbox tags."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:109
|
||||
#: documents/models.py:112
|
||||
msgid "tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:110 documents/models.py:182
|
||||
#: documents/models.py:113 documents/models.py:185
|
||||
msgid "tags"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:115 documents/models.py:164
|
||||
#: documents/models.py:118 documents/models.py:167
|
||||
msgid "document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:116
|
||||
#: documents/models.py:119
|
||||
msgid "document types"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:121
|
||||
#: documents/models.py:124
|
||||
msgid "path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:126 documents/models.py:153
|
||||
#: documents/models.py:129 documents/models.py:156
|
||||
msgid "storage path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:127
|
||||
#: documents/models.py:130
|
||||
msgid "storage paths"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:134
|
||||
#: documents/models.py:137
|
||||
msgid "Unencrypted"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:135
|
||||
#: documents/models.py:138
|
||||
msgid "Encrypted with GNU Privacy Guard"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:156
|
||||
#: documents/models.py:159
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:168 documents/models.py:642
|
||||
#: documents/models.py:171 documents/models.py:645
|
||||
msgid "content"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:171
|
||||
#: documents/models.py:174
|
||||
msgid ""
|
||||
"The raw, text-only data of the document. This field is primarily used for "
|
||||
"searching."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:176
|
||||
#: documents/models.py:179
|
||||
msgid "mime type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:186
|
||||
#: documents/models.py:189
|
||||
msgid "checksum"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:190
|
||||
#: documents/models.py:193
|
||||
msgid "The checksum of the original document."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:194
|
||||
#: documents/models.py:197
|
||||
msgid "archive checksum"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:199
|
||||
#: documents/models.py:202
|
||||
msgid "The checksum of the archived document."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:202 documents/models.py:382 documents/models.py:648
|
||||
#: documents/models.py:686
|
||||
#: documents/models.py:205 documents/models.py:385 documents/models.py:651
|
||||
#: documents/models.py:689
|
||||
msgid "created"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:205
|
||||
#: documents/models.py:208
|
||||
msgid "modified"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:212
|
||||
#: documents/models.py:215
|
||||
msgid "storage type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:220
|
||||
#: documents/models.py:223
|
||||
msgid "added"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:227
|
||||
#: documents/models.py:230
|
||||
msgid "filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:233
|
||||
#: documents/models.py:236
|
||||
msgid "Current filename in storage"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:237
|
||||
#: documents/models.py:240
|
||||
msgid "archive filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:243
|
||||
#: documents/models.py:246
|
||||
msgid "Current archive filename in storage"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:247
|
||||
#: documents/models.py:250
|
||||
msgid "original filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:253
|
||||
#: documents/models.py:256
|
||||
msgid "The original name of the file when it was uploaded"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:260
|
||||
#: documents/models.py:263
|
||||
msgid "archive serial number"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:270
|
||||
#: documents/models.py:273
|
||||
msgid "The position of this document in your physical document archive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:276 documents/models.py:659 documents/models.py:713
|
||||
#: documents/models.py:279 documents/models.py:662 documents/models.py:716
|
||||
msgid "document"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:277
|
||||
#: documents/models.py:280
|
||||
msgid "documents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:365
|
||||
#: documents/models.py:368
|
||||
msgid "debug"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:366
|
||||
#: documents/models.py:369
|
||||
msgid "information"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:367
|
||||
#: documents/models.py:370
|
||||
msgid "warning"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:368 paperless_mail/models.py:287
|
||||
#: documents/models.py:371 paperless_mail/models.py:293
|
||||
msgid "error"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:369
|
||||
#: documents/models.py:372
|
||||
msgid "critical"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:372
|
||||
#: documents/models.py:375
|
||||
msgid "group"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:374
|
||||
#: documents/models.py:377
|
||||
msgid "message"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:377
|
||||
#: documents/models.py:380
|
||||
msgid "level"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:386
|
||||
#: documents/models.py:389
|
||||
msgid "log"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:387
|
||||
#: documents/models.py:390
|
||||
msgid "logs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:396 documents/models.py:461
|
||||
#: documents/models.py:399 documents/models.py:464
|
||||
msgid "saved view"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:397
|
||||
#: documents/models.py:400
|
||||
msgid "saved views"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:402
|
||||
#: documents/models.py:405
|
||||
msgid "show on dashboard"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:405
|
||||
#: documents/models.py:408
|
||||
msgid "show in sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:409
|
||||
#: documents/models.py:412
|
||||
msgid "sort field"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:414
|
||||
#: documents/models.py:417
|
||||
msgid "sort reverse"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:419
|
||||
#: documents/models.py:422
|
||||
msgid "title contains"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:420
|
||||
#: documents/models.py:423
|
||||
msgid "content contains"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:421
|
||||
#: documents/models.py:424
|
||||
msgid "ASN is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:422
|
||||
#: documents/models.py:425
|
||||
msgid "correspondent is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:423
|
||||
#: documents/models.py:426
|
||||
msgid "document type is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:424
|
||||
#: documents/models.py:427
|
||||
msgid "is in inbox"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:425
|
||||
#: documents/models.py:428
|
||||
msgid "has tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:426
|
||||
#: documents/models.py:429
|
||||
msgid "has any tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:427
|
||||
#: documents/models.py:430
|
||||
msgid "created before"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:428
|
||||
#: documents/models.py:431
|
||||
msgid "created after"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:429
|
||||
#: documents/models.py:432
|
||||
msgid "created year is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:430
|
||||
#: documents/models.py:433
|
||||
msgid "created month is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:431
|
||||
#: documents/models.py:434
|
||||
msgid "created day is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:432
|
||||
#: documents/models.py:435
|
||||
msgid "added before"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:433
|
||||
#: documents/models.py:436
|
||||
msgid "added after"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:434
|
||||
#: documents/models.py:437
|
||||
msgid "modified before"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:435
|
||||
#: documents/models.py:438
|
||||
msgid "modified after"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:436
|
||||
#: documents/models.py:439
|
||||
msgid "does not have tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:437
|
||||
#: documents/models.py:440
|
||||
msgid "does not have ASN"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:438
|
||||
#: documents/models.py:441
|
||||
msgid "title or content contains"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:439
|
||||
#: documents/models.py:442
|
||||
msgid "fulltext query"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:440
|
||||
#: documents/models.py:443
|
||||
msgid "more like this"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:441
|
||||
#: documents/models.py:444
|
||||
msgid "has tags in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:442
|
||||
#: documents/models.py:445
|
||||
msgid "ASN greater than"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:443
|
||||
#: documents/models.py:446
|
||||
msgid "ASN less than"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:444
|
||||
#: documents/models.py:447
|
||||
msgid "storage path is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:445
|
||||
#: documents/models.py:448
|
||||
msgid "has correspondent in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:446
|
||||
#: documents/models.py:449
|
||||
msgid "does not have correspondent in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:447
|
||||
#: documents/models.py:450
|
||||
msgid "has document type in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:448
|
||||
#: documents/models.py:451
|
||||
msgid "does not have document type in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:449
|
||||
#: documents/models.py:452
|
||||
msgid "has storage path in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:450
|
||||
#: documents/models.py:453
|
||||
msgid "does not have storage path in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:451
|
||||
#: documents/models.py:454
|
||||
msgid "owner is"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:452
|
||||
#: documents/models.py:455
|
||||
msgid "has owner in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:453
|
||||
#: documents/models.py:456
|
||||
msgid "does not have owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:454
|
||||
#: documents/models.py:457
|
||||
msgid "does not have owner in"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:464
|
||||
#: documents/models.py:467
|
||||
msgid "rule type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:466
|
||||
#: documents/models.py:469
|
||||
msgid "value"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:469
|
||||
#: documents/models.py:472
|
||||
msgid "filter rule"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:470
|
||||
#: documents/models.py:473
|
||||
msgid "filter rules"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:578
|
||||
#: documents/models.py:581
|
||||
msgid "Task ID"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:579
|
||||
#: documents/models.py:582
|
||||
msgid "Celery ID for the Task that was run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:584
|
||||
#: documents/models.py:587
|
||||
msgid "Acknowledged"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:585
|
||||
#: documents/models.py:588
|
||||
msgid "If the task is acknowledged via the frontend or API"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:591
|
||||
#: documents/models.py:594
|
||||
msgid "Task Filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:592
|
||||
#: documents/models.py:595
|
||||
msgid "Name of the file which the Task was run for"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:598
|
||||
#: documents/models.py:601
|
||||
msgid "Task Name"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:599
|
||||
#: documents/models.py:602
|
||||
msgid "Name of the Task which was run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:606
|
||||
#: documents/models.py:609
|
||||
msgid "Task State"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:607
|
||||
#: documents/models.py:610
|
||||
msgid "Current state of the task being run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:612
|
||||
#: documents/models.py:615
|
||||
msgid "Created DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:613
|
||||
#: documents/models.py:616
|
||||
msgid "Datetime field when the task result was created in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:618
|
||||
#: documents/models.py:621
|
||||
msgid "Started DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:619
|
||||
#: documents/models.py:622
|
||||
msgid "Datetime field when the task was started in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:624
|
||||
#: documents/models.py:627
|
||||
msgid "Completed DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:625
|
||||
#: documents/models.py:628
|
||||
msgid "Datetime field when the task was completed in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:630
|
||||
#: documents/models.py:633
|
||||
msgid "Result Data"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:632
|
||||
#: documents/models.py:635
|
||||
msgid "The data returned by the task"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:644
|
||||
#: documents/models.py:647
|
||||
msgid "Note for the document"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:668
|
||||
#: documents/models.py:671
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:673
|
||||
#: documents/models.py:676
|
||||
msgid "note"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:674
|
||||
#: documents/models.py:677
|
||||
msgid "notes"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:682
|
||||
#: documents/models.py:685
|
||||
msgid "Archive"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:683
|
||||
#: documents/models.py:686
|
||||
msgid "Original"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:694
|
||||
#: documents/models.py:697
|
||||
msgid "expiration"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:701
|
||||
#: documents/models.py:704
|
||||
msgid "slug"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:733
|
||||
#: documents/models.py:736
|
||||
msgid "share link"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:734
|
||||
#: documents/models.py:737
|
||||
msgid "share links"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:96
|
||||
#: documents/models.py:744
|
||||
msgid "Consume Folder"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:745
|
||||
msgid "Api Upload"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:746
|
||||
msgid "Mail Fetch"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:752
|
||||
msgid "consumption template"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:753
|
||||
msgid "consumption templates"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:757 paperless_mail/models.py:95
|
||||
msgid "order"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:766
|
||||
msgid "filter path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:771
|
||||
msgid ""
|
||||
"Only consume documents with a path that matches this if specified. Wildcards "
|
||||
"specified as * are allowed. Case insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:778
|
||||
msgid "filter filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:783 paperless_mail/models.py:148
|
||||
msgid ""
|
||||
"Only consume documents which entirely match this filename if specified. "
|
||||
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:794
|
||||
msgid "filter documents from this mail rule"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:798
|
||||
msgid "assign title"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:803
|
||||
msgid ""
|
||||
"Assign a document title, can include some placeholders, see documentation."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:811 paperless_mail/models.py:204
|
||||
msgid "assign this tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:819 paperless_mail/models.py:212
|
||||
msgid "assign this document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:827 paperless_mail/models.py:226
|
||||
msgid "assign this correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:835
|
||||
msgid "assign this storage path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:844
|
||||
msgid "assign this owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:851
|
||||
msgid "grant view permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:858
|
||||
msgid "grant view permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:865
|
||||
msgid "grant change permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:872
|
||||
msgid "grant change permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:100
|
||||
#, python-format
|
||||
msgid "Invalid regular expression: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:371
|
||||
#: documents/serialisers.py:375
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:747
|
||||
#: documents/serialisers.py:751
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:844
|
||||
#: documents/serialisers.py:848
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
@@ -749,7 +842,7 @@ msgstr ""
|
||||
msgid "Chinese Simplified"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/urls.py:182
|
||||
#: paperless/urls.py:184
|
||||
msgid "Paperless-ngx administration"
|
||||
msgstr ""
|
||||
|
||||
@@ -909,138 +1002,124 @@ msgstr ""
|
||||
msgid "Use attachment filename as title"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:87
|
||||
msgid "Do not assign a correspondent"
|
||||
#: paperless_mail/models.py:85
|
||||
msgid "Do not assign title from rule"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:88
|
||||
msgid "Use mail address"
|
||||
msgid "Do not assign a correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:89
|
||||
msgid "Use name (or mail address if not available)"
|
||||
msgid "Use mail address"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:90
|
||||
msgid "Use name (or mail address if not available)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:91
|
||||
msgid "Use correspondent selected below"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:94
|
||||
msgid "order"
|
||||
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 ""
|
||||
|
@@ -14,6 +14,7 @@ from documents.views import AcknowledgeTasksView
|
||||
from documents.views import BulkDownloadView
|
||||
from documents.views import BulkEditObjectPermissionsView
|
||||
from documents.views import BulkEditView
|
||||
from documents.views import ConsumptionTemplateViewSet
|
||||
from documents.views import CorrespondentViewSet
|
||||
from documents.views import DocumentTypeViewSet
|
||||
from documents.views import IndexView
|
||||
@@ -53,6 +54,7 @@ api_router.register(r"groups", GroupViewSet, basename="groups")
|
||||
api_router.register(r"mail_accounts", MailAccountViewSet)
|
||||
api_router.register(r"mail_rules", MailRuleViewSet)
|
||||
api_router.register(r"share_links", ShareLinkViewSet)
|
||||
api_router.register(r"consumption_templates", ConsumptionTemplateViewSet)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
@@ -436,6 +436,9 @@ class MailAccountHandler(LoggingMixin):
|
||||
elif rule.assign_title_from == MailRule.TitleSource.FROM_FILENAME:
|
||||
return os.path.splitext(os.path.basename(att.filename))[0]
|
||||
|
||||
elif rule.assign_title_from == MailRule.TitleSource.NONE:
|
||||
return None
|
||||
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"Unknown title selector.",
|
||||
@@ -690,6 +693,7 @@ class MailAccountHandler(LoggingMixin):
|
||||
input_doc = ConsumableDocument(
|
||||
source=DocumentSource.MailFetch,
|
||||
original_file=temp_filename,
|
||||
mailrule_id=rule.pk,
|
||||
)
|
||||
doc_overrides = DocumentMetadataOverrides(
|
||||
title=title,
|
||||
@@ -697,7 +701,9 @@ class MailAccountHandler(LoggingMixin):
|
||||
correspondent_id=correspondent.id if correspondent else None,
|
||||
document_type_id=doc_type.id if doc_type else None,
|
||||
tag_ids=tag_ids,
|
||||
owner_id=rule.owner.id if rule.owner else None,
|
||||
owner_id=rule.owner.id
|
||||
if (rule.assign_owner_from_rule and rule.owner)
|
||||
else None,
|
||||
)
|
||||
|
||||
consume_task = consume_file.s(
|
||||
|
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.1.11 on 2023-09-18 18:50
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless_mail", "0021_alter_mailaccount_password"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="mailrule",
|
||||
name="assign_owner_from_rule",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Assign the rule owner to documents",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="mailrule",
|
||||
name="assign_title_from",
|
||||
field=models.PositiveIntegerField(
|
||||
choices=[
|
||||
(1, "Use subject as title"),
|
||||
(2, "Use attachment filename as title"),
|
||||
(3, "Do not assign title from rule"),
|
||||
],
|
||||
default=1,
|
||||
verbose_name="assign title from",
|
||||
),
|
||||
),
|
||||
]
|
@@ -82,6 +82,7 @@ class MailRule(document_models.ModelWithOwner):
|
||||
class TitleSource(models.IntegerChoices):
|
||||
FROM_SUBJECT = 1, _("Use subject as title")
|
||||
FROM_FILENAME = 2, _("Use attachment filename as title")
|
||||
NONE = 3, _("Do not assign title from rule")
|
||||
|
||||
class CorrespondentSource(models.IntegerChoices):
|
||||
FROM_NOTHING = 1, _("Do not assign a correspondent")
|
||||
@@ -225,6 +226,11 @@ class MailRule(document_models.ModelWithOwner):
|
||||
verbose_name=_("assign this correspondent"),
|
||||
)
|
||||
|
||||
assign_owner_from_rule = models.BooleanField(
|
||||
_("Assign the rule owner to documents"),
|
||||
default=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.account.name}.{self.name}"
|
||||
|
||||
|
@@ -88,6 +88,7 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
||||
"assign_correspondent_from",
|
||||
"assign_correspondent",
|
||||
"assign_document_type",
|
||||
"assign_owner_from_rule",
|
||||
"order",
|
||||
"attachment_type",
|
||||
"consumption_scope",
|
||||
|
@@ -464,6 +464,7 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
|
||||
"assign_tags": [tag.pk],
|
||||
"assign_correspondent": correspondent.pk,
|
||||
"assign_document_type": document_type.pk,
|
||||
"assign_owner_from_rule": True,
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
@@ -512,6 +513,10 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
|
||||
rule1["assign_document_type"],
|
||||
)
|
||||
self.assertEqual(returned_rule1["assign_tags"], rule1["assign_tags"])
|
||||
self.assertEqual(
|
||||
returned_rule1["assign_owner_from_rule"],
|
||||
rule1["assign_owner_from_rule"],
|
||||
)
|
||||
|
||||
def test_delete_mail_rule(self):
|
||||
"""
|
||||
|
@@ -392,6 +392,11 @@ class TestMail(
|
||||
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
|
||||
)
|
||||
self.assertEqual(handler._get_title(message, att, rule), "the message title")
|
||||
rule = MailRule(
|
||||
name="b",
|
||||
assign_title_from=MailRule.TitleSource.NONE,
|
||||
)
|
||||
self.assertEqual(handler._get_title(message, att, rule), None)
|
||||
|
||||
def test_handle_message(self):
|
||||
message = self.create_message(
|
||||
|
Reference in New Issue
Block a user