mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-11-23 23:49:08 -06:00
Merge branch 'dev' into feature-ai
This commit is contained in:
@@ -1,20 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from email import message_from_bytes
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
from filelock import FileLock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.models import Document
|
||||
|
||||
|
||||
def send_email(
|
||||
subject: str,
|
||||
body: str,
|
||||
to: list[str],
|
||||
attachment: Path | None = None,
|
||||
attachment_mime_type: str | None = None,
|
||||
attachments: list[Document],
|
||||
*,
|
||||
use_archive: bool,
|
||||
) -> int:
|
||||
"""
|
||||
Send an email with an optional attachment.
|
||||
Send an email with attachments.
|
||||
|
||||
Args:
|
||||
subject: Email subject
|
||||
body: Email body text
|
||||
to: List of recipient email addresses
|
||||
attachments: List of documents to attach (the list may be empty)
|
||||
use_archive: Whether to attach archive versions when available
|
||||
|
||||
Returns:
|
||||
Number of emails sent
|
||||
|
||||
TODO: re-evaluate this pending https://code.djangoproject.com/ticket/35581 / https://github.com/django/django/pull/18966
|
||||
"""
|
||||
email = EmailMessage(
|
||||
@@ -22,17 +39,49 @@ def send_email(
|
||||
body=body,
|
||||
to=to,
|
||||
)
|
||||
if attachment:
|
||||
# Something could be renaming the file concurrently so it can't be attached
|
||||
with FileLock(settings.MEDIA_LOCK), attachment.open("rb") as f:
|
||||
content = f.read()
|
||||
if attachment_mime_type == "message/rfc822":
|
||||
# See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981
|
||||
content = message_from_bytes(f.read())
|
||||
|
||||
email.attach(
|
||||
filename=attachment.name,
|
||||
content=content,
|
||||
mimetype=attachment_mime_type,
|
||||
used_filenames: set[str] = set()
|
||||
|
||||
# Something could be renaming the file concurrently so it can't be attached
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
for document in attachments:
|
||||
attachment_path = (
|
||||
document.archive_path
|
||||
if use_archive and document.has_archive_version
|
||||
else document.source_path
|
||||
)
|
||||
|
||||
friendly_filename = _get_unique_filename(
|
||||
document,
|
||||
used_filenames,
|
||||
archive=use_archive and document.has_archive_version,
|
||||
)
|
||||
used_filenames.add(friendly_filename)
|
||||
|
||||
with attachment_path.open("rb") as f:
|
||||
content = f.read()
|
||||
if document.mime_type == "message/rfc822":
|
||||
# See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981
|
||||
content = message_from_bytes(content)
|
||||
|
||||
email.attach(
|
||||
filename=friendly_filename,
|
||||
content=content,
|
||||
mimetype=document.mime_type,
|
||||
)
|
||||
|
||||
return email.send()
|
||||
|
||||
|
||||
def _get_unique_filename(doc: Document, used_names: set[str], *, archive: bool) -> str:
|
||||
"""
|
||||
Constructs a unique friendly filename for the given document.
|
||||
|
||||
The filename might not be unique enough, so a counter is appended if needed.
|
||||
"""
|
||||
counter = 0
|
||||
while True:
|
||||
filename = doc.get_public_filename(archive=archive, counter=counter)
|
||||
if filename not in used_names:
|
||||
return filename
|
||||
counter += 1
|
||||
|
||||
@@ -6,8 +6,11 @@ from fnmatch import fnmatch
|
||||
from fnmatch import translate as fnmatch_translate
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.filters import CustomFieldQueryParser
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
@@ -342,67 +345,147 @@ def consumable_document_matches_workflow(
|
||||
def existing_document_matches_workflow(
|
||||
document: Document,
|
||||
trigger: WorkflowTrigger,
|
||||
) -> tuple[bool, str]:
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Returns True if the Document matches all filters from the workflow trigger,
|
||||
False otherwise. Includes a reason if doesn't match
|
||||
"""
|
||||
|
||||
trigger_matched = True
|
||||
reason = ""
|
||||
|
||||
# Check content matching algorithm
|
||||
if trigger.matching_algorithm > MatchingModel.MATCH_NONE and not matches(
|
||||
trigger,
|
||||
document,
|
||||
):
|
||||
reason = (
|
||||
return (
|
||||
False,
|
||||
f"Document content matching settings for algorithm '{trigger.matching_algorithm}' did not match",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
# Document tags vs trigger has_tags
|
||||
if (
|
||||
trigger.filter_has_tags.all().count() > 0
|
||||
and document.tags.filter(
|
||||
id__in=trigger.filter_has_tags.all().values_list("id"),
|
||||
).count()
|
||||
== 0
|
||||
):
|
||||
reason = (
|
||||
f"Document tags {document.tags.all()} do not include"
|
||||
f" {trigger.filter_has_tags.all()}",
|
||||
)
|
||||
trigger_matched = False
|
||||
# Check if any tag filters exist to determine if we need to load document tags
|
||||
trigger_has_tags_qs = trigger.filter_has_tags.all()
|
||||
trigger_has_all_tags_qs = trigger.filter_has_all_tags.all()
|
||||
trigger_has_not_tags_qs = trigger.filter_has_not_tags.all()
|
||||
|
||||
has_tags_filter = trigger_has_tags_qs.exists()
|
||||
has_all_tags_filter = trigger_has_all_tags_qs.exists()
|
||||
has_not_tags_filter = trigger_has_not_tags_qs.exists()
|
||||
|
||||
# Load document tags once if any tag filters exist
|
||||
document_tag_ids = None
|
||||
if has_tags_filter or has_all_tags_filter or has_not_tags_filter:
|
||||
document_tag_ids = set(document.tags.values_list("id", flat=True))
|
||||
|
||||
# Document tags vs trigger has_tags (any of)
|
||||
if has_tags_filter:
|
||||
trigger_has_tag_ids = set(trigger_has_tags_qs.values_list("id", flat=True))
|
||||
if not (document_tag_ids & trigger_has_tag_ids):
|
||||
# For error message, load the actual tag objects
|
||||
return (
|
||||
False,
|
||||
f"Document tags {list(document.tags.all())} do not include {list(trigger_has_tags_qs)}",
|
||||
)
|
||||
|
||||
# Document tags vs trigger has_all_tags (all of)
|
||||
if has_all_tags_filter:
|
||||
required_tag_ids = set(trigger_has_all_tags_qs.values_list("id", flat=True))
|
||||
if not required_tag_ids.issubset(document_tag_ids):
|
||||
return (
|
||||
False,
|
||||
f"Document tags {list(document.tags.all())} do not contain all of {list(trigger_has_all_tags_qs)}",
|
||||
)
|
||||
|
||||
# Document tags vs trigger has_not_tags (none of)
|
||||
if has_not_tags_filter:
|
||||
excluded_tag_ids = set(trigger_has_not_tags_qs.values_list("id", flat=True))
|
||||
if document_tag_ids & excluded_tag_ids:
|
||||
return (
|
||||
False,
|
||||
f"Document tags {list(document.tags.all())} include excluded tags {list(trigger_has_not_tags_qs)}",
|
||||
)
|
||||
|
||||
# Document correspondent vs trigger has_correspondent
|
||||
if (
|
||||
trigger.filter_has_correspondent is not None
|
||||
and document.correspondent != trigger.filter_has_correspondent
|
||||
trigger.filter_has_correspondent_id is not None
|
||||
and document.correspondent_id != trigger.filter_has_correspondent_id
|
||||
):
|
||||
reason = (
|
||||
return (
|
||||
False,
|
||||
f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
if (
|
||||
document.correspondent_id
|
||||
and trigger.filter_has_not_correspondents.filter(
|
||||
id=document.correspondent_id,
|
||||
).exists()
|
||||
):
|
||||
return (
|
||||
False,
|
||||
f"Document correspondent {document.correspondent} is excluded by {list(trigger.filter_has_not_correspondents.all())}",
|
||||
)
|
||||
|
||||
# Document document_type vs trigger has_document_type
|
||||
if (
|
||||
trigger.filter_has_document_type is not None
|
||||
and document.document_type != trigger.filter_has_document_type
|
||||
trigger.filter_has_document_type_id is not None
|
||||
and document.document_type_id != trigger.filter_has_document_type_id
|
||||
):
|
||||
reason = (
|
||||
return (
|
||||
False,
|
||||
f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
if (
|
||||
document.document_type_id
|
||||
and trigger.filter_has_not_document_types.filter(
|
||||
id=document.document_type_id,
|
||||
).exists()
|
||||
):
|
||||
return (
|
||||
False,
|
||||
f"Document doc type {document.document_type} is excluded by {list(trigger.filter_has_not_document_types.all())}",
|
||||
)
|
||||
|
||||
# Document storage_path vs trigger has_storage_path
|
||||
if (
|
||||
trigger.filter_has_storage_path is not None
|
||||
and document.storage_path != trigger.filter_has_storage_path
|
||||
trigger.filter_has_storage_path_id is not None
|
||||
and document.storage_path_id != trigger.filter_has_storage_path_id
|
||||
):
|
||||
reason = (
|
||||
return (
|
||||
False,
|
||||
f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
if (
|
||||
document.storage_path_id
|
||||
and trigger.filter_has_not_storage_paths.filter(
|
||||
id=document.storage_path_id,
|
||||
).exists()
|
||||
):
|
||||
return (
|
||||
False,
|
||||
f"Document storage path {document.storage_path} is excluded by {list(trigger.filter_has_not_storage_paths.all())}",
|
||||
)
|
||||
|
||||
# Custom field query check
|
||||
if trigger.filter_custom_field_query:
|
||||
parser = CustomFieldQueryParser("filter_custom_field_query")
|
||||
try:
|
||||
custom_field_q, annotations = parser.parse(
|
||||
trigger.filter_custom_field_query,
|
||||
)
|
||||
except serializers.ValidationError:
|
||||
return (False, "Invalid custom field query configuration")
|
||||
|
||||
qs = (
|
||||
Document.objects.filter(id=document.id)
|
||||
.annotate(**annotations)
|
||||
.filter(custom_field_q)
|
||||
)
|
||||
if not qs.exists():
|
||||
return (
|
||||
False,
|
||||
"Document custom fields do not match the configured custom field query",
|
||||
)
|
||||
|
||||
# Document original_filename vs trigger filename
|
||||
if (
|
||||
@@ -414,13 +497,12 @@ def existing_document_matches_workflow(
|
||||
trigger.filter_filename.lower(),
|
||||
)
|
||||
):
|
||||
reason = (
|
||||
f"Document filename {document.original_filename} does not match"
|
||||
f" {trigger.filter_filename.lower()}",
|
||||
return (
|
||||
False,
|
||||
f"Document filename {document.original_filename} does not match {trigger.filter_filename.lower()}",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
return (trigger_matched, reason)
|
||||
return (True, None)
|
||||
|
||||
|
||||
def prefilter_documents_by_workflowtrigger(
|
||||
@@ -433,31 +515,66 @@ def prefilter_documents_by_workflowtrigger(
|
||||
document_matches_workflow in run_workflows
|
||||
"""
|
||||
|
||||
if trigger.filter_has_tags.all().count() > 0:
|
||||
documents = documents.filter(
|
||||
tags__in=trigger.filter_has_tags.all(),
|
||||
).distinct()
|
||||
# Filter for documents that have AT LEAST ONE of the specified tags.
|
||||
if trigger.filter_has_tags.exists():
|
||||
documents = documents.filter(tags__in=trigger.filter_has_tags.all()).distinct()
|
||||
|
||||
# Filter for documents that have ALL of the specified tags.
|
||||
if trigger.filter_has_all_tags.exists():
|
||||
for tag in trigger.filter_has_all_tags.all():
|
||||
documents = documents.filter(tags=tag)
|
||||
# Multiple JOINs can create duplicate results.
|
||||
documents = documents.distinct()
|
||||
|
||||
# Exclude documents that have ANY of the specified tags.
|
||||
if trigger.filter_has_not_tags.exists():
|
||||
documents = documents.exclude(tags__in=trigger.filter_has_not_tags.all())
|
||||
|
||||
# Correspondent, DocumentType, etc. filtering
|
||||
|
||||
if trigger.filter_has_correspondent is not None:
|
||||
documents = documents.filter(
|
||||
correspondent=trigger.filter_has_correspondent,
|
||||
)
|
||||
if trigger.filter_has_not_correspondents.exists():
|
||||
documents = documents.exclude(
|
||||
correspondent__in=trigger.filter_has_not_correspondents.all(),
|
||||
)
|
||||
|
||||
if trigger.filter_has_document_type is not None:
|
||||
documents = documents.filter(
|
||||
document_type=trigger.filter_has_document_type,
|
||||
)
|
||||
if trigger.filter_has_not_document_types.exists():
|
||||
documents = documents.exclude(
|
||||
document_type__in=trigger.filter_has_not_document_types.all(),
|
||||
)
|
||||
|
||||
if trigger.filter_has_storage_path is not None:
|
||||
documents = documents.filter(
|
||||
storage_path=trigger.filter_has_storage_path,
|
||||
)
|
||||
if trigger.filter_has_not_storage_paths.exists():
|
||||
documents = documents.exclude(
|
||||
storage_path__in=trigger.filter_has_not_storage_paths.all(),
|
||||
)
|
||||
|
||||
if trigger.filter_filename is not None and len(trigger.filter_filename) > 0:
|
||||
# the true fnmatch will actually run later so we just want a loose filter here
|
||||
# Custom Field & Filename Filtering
|
||||
|
||||
if trigger.filter_custom_field_query:
|
||||
parser = CustomFieldQueryParser("filter_custom_field_query")
|
||||
try:
|
||||
custom_field_q, annotations = parser.parse(
|
||||
trigger.filter_custom_field_query,
|
||||
)
|
||||
except serializers.ValidationError:
|
||||
return documents.none()
|
||||
|
||||
documents = documents.annotate(**annotations).filter(custom_field_q)
|
||||
|
||||
if trigger.filter_filename:
|
||||
regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$")
|
||||
regex = f"(?i){regex}"
|
||||
documents = documents.filter(original_filename__regex=regex)
|
||||
documents = documents.filter(original_filename__iregex=regex)
|
||||
|
||||
return documents
|
||||
|
||||
@@ -472,13 +589,34 @@ def document_matches_workflow(
|
||||
settings from the workflow trigger, False otherwise
|
||||
"""
|
||||
|
||||
triggers_queryset = (
|
||||
workflow.triggers.filter(
|
||||
type=trigger_type,
|
||||
)
|
||||
.select_related(
|
||||
"filter_mailrule",
|
||||
"filter_has_document_type",
|
||||
"filter_has_correspondent",
|
||||
"filter_has_storage_path",
|
||||
"schedule_date_custom_field",
|
||||
)
|
||||
.prefetch_related(
|
||||
"filter_has_tags",
|
||||
"filter_has_all_tags",
|
||||
"filter_has_not_tags",
|
||||
"filter_has_not_document_types",
|
||||
"filter_has_not_correspondents",
|
||||
"filter_has_not_storage_paths",
|
||||
)
|
||||
)
|
||||
|
||||
trigger_matched = True
|
||||
if workflow.triggers.filter(type=trigger_type).count() == 0:
|
||||
if not triggers_queryset.exists():
|
||||
trigger_matched = False
|
||||
logger.info(f"Document did not match {workflow}")
|
||||
logger.debug(f"No matching triggers with type {trigger_type} found")
|
||||
else:
|
||||
for trigger in workflow.triggers.filter(type=trigger_type):
|
||||
for trigger in triggers_queryset:
|
||||
if trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION:
|
||||
trigger_matched, reason = consumable_document_matches_workflow(
|
||||
document,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-07 18:52
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_custom_field_query",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="JSON-encoded custom field query expression.",
|
||||
null=True,
|
||||
verbose_name="filter custom field query",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_has_all_tags",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_all",
|
||||
to="documents.tag",
|
||||
verbose_name="has all of these tag(s)",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_has_not_correspondents",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_not_correspondent",
|
||||
to="documents.correspondent",
|
||||
verbose_name="does not have these correspondent(s)",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_has_not_document_types",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_not_document_type",
|
||||
to="documents.documenttype",
|
||||
verbose_name="does not have these document type(s)",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_has_not_storage_paths",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_not_storage_path",
|
||||
to="documents.storagepath",
|
||||
verbose_name="does not have these storage path(s)",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflowtrigger",
|
||||
name="filter_has_not_tags",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_not",
|
||||
to="documents.tag",
|
||||
verbose_name="does not have these tag(s)",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1066,6 +1066,20 @@ class WorkflowTrigger(models.Model):
|
||||
verbose_name=_("has these tag(s)"),
|
||||
)
|
||||
|
||||
filter_has_all_tags = models.ManyToManyField(
|
||||
Tag,
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_all",
|
||||
verbose_name=_("has all of these tag(s)"),
|
||||
)
|
||||
|
||||
filter_has_not_tags = models.ManyToManyField(
|
||||
Tag,
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_not",
|
||||
verbose_name=_("does not have these tag(s)"),
|
||||
)
|
||||
|
||||
filter_has_document_type = models.ForeignKey(
|
||||
DocumentType,
|
||||
null=True,
|
||||
@@ -1074,6 +1088,13 @@ class WorkflowTrigger(models.Model):
|
||||
verbose_name=_("has this document type"),
|
||||
)
|
||||
|
||||
filter_has_not_document_types = models.ManyToManyField(
|
||||
DocumentType,
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_not_document_type",
|
||||
verbose_name=_("does not have these document type(s)"),
|
||||
)
|
||||
|
||||
filter_has_correspondent = models.ForeignKey(
|
||||
Correspondent,
|
||||
null=True,
|
||||
@@ -1082,6 +1103,13 @@ class WorkflowTrigger(models.Model):
|
||||
verbose_name=_("has this correspondent"),
|
||||
)
|
||||
|
||||
filter_has_not_correspondents = models.ManyToManyField(
|
||||
Correspondent,
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_not_correspondent",
|
||||
verbose_name=_("does not have these correspondent(s)"),
|
||||
)
|
||||
|
||||
filter_has_storage_path = models.ForeignKey(
|
||||
StoragePath,
|
||||
null=True,
|
||||
@@ -1090,6 +1118,20 @@ class WorkflowTrigger(models.Model):
|
||||
verbose_name=_("has this storage path"),
|
||||
)
|
||||
|
||||
filter_has_not_storage_paths = models.ManyToManyField(
|
||||
StoragePath,
|
||||
blank=True,
|
||||
related_name="workflowtriggers_has_not_storage_path",
|
||||
verbose_name=_("does not have these storage path(s)"),
|
||||
)
|
||||
|
||||
filter_custom_field_query = models.TextField(
|
||||
_("filter custom field query"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("JSON-encoded custom field query expression."),
|
||||
)
|
||||
|
||||
schedule_offset_days = models.IntegerField(
|
||||
_("schedule offset days"),
|
||||
default=0,
|
||||
|
||||
@@ -16,6 +16,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import DecimalValidator
|
||||
from django.core.validators import EmailValidator
|
||||
from django.core.validators import MaxLengthValidator
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import integer_validator
|
||||
@@ -43,6 +44,7 @@ if settings.AUDIT_LOG_ENABLED:
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.filters import CustomFieldQueryParser
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -1906,6 +1908,51 @@ class BulkDownloadSerializer(DocumentListSerializer):
|
||||
}[compression]
|
||||
|
||||
|
||||
class EmailSerializer(DocumentListSerializer):
|
||||
addresses = serializers.CharField(
|
||||
required=True,
|
||||
label="Email addresses",
|
||||
help_text="Comma-separated email addresses",
|
||||
)
|
||||
|
||||
subject = serializers.CharField(
|
||||
required=True,
|
||||
label="Email subject",
|
||||
)
|
||||
|
||||
message = serializers.CharField(
|
||||
required=True,
|
||||
label="Email message",
|
||||
)
|
||||
|
||||
use_archive_version = serializers.BooleanField(
|
||||
default=True,
|
||||
label="Use archive version",
|
||||
help_text="Use archive version of documents if available",
|
||||
)
|
||||
|
||||
def validate_addresses(self, addresses):
|
||||
address_list = [addr.strip() for addr in addresses.split(",")]
|
||||
if not address_list:
|
||||
raise serializers.ValidationError("At least one email address is required")
|
||||
|
||||
email_validator = EmailValidator()
|
||||
try:
|
||||
for address in address_list:
|
||||
email_validator(address)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError(f"Invalid email address: {address}")
|
||||
|
||||
return ",".join(address_list)
|
||||
|
||||
def validate_documents(self, documents):
|
||||
super().validate_documents(documents)
|
||||
if not documents:
|
||||
raise serializers.ValidationError("At least one document is required")
|
||||
|
||||
return documents
|
||||
|
||||
|
||||
class StoragePathSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
class Meta:
|
||||
model = StoragePath
|
||||
@@ -2194,6 +2241,12 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
||||
"match",
|
||||
"is_insensitive",
|
||||
"filter_has_tags",
|
||||
"filter_has_all_tags",
|
||||
"filter_has_not_tags",
|
||||
"filter_custom_field_query",
|
||||
"filter_has_not_correspondents",
|
||||
"filter_has_not_document_types",
|
||||
"filter_has_not_storage_paths",
|
||||
"filter_has_correspondent",
|
||||
"filter_has_document_type",
|
||||
"filter_has_storage_path",
|
||||
@@ -2219,6 +2272,20 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
||||
):
|
||||
attrs["filter_path"] = None
|
||||
|
||||
if (
|
||||
"filter_custom_field_query" in attrs
|
||||
and attrs["filter_custom_field_query"] is not None
|
||||
and len(attrs["filter_custom_field_query"]) == 0
|
||||
):
|
||||
attrs["filter_custom_field_query"] = None
|
||||
|
||||
if (
|
||||
"filter_custom_field_query" in attrs
|
||||
and attrs["filter_custom_field_query"] is not None
|
||||
):
|
||||
parser = CustomFieldQueryParser("filter_custom_field_query")
|
||||
parser.parse(attrs["filter_custom_field_query"])
|
||||
|
||||
trigger_type = attrs.get("type", getattr(self.instance, "type", None))
|
||||
if (
|
||||
trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||
@@ -2414,6 +2481,20 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
||||
if triggers is not None and triggers is not serializers.empty:
|
||||
for trigger in triggers:
|
||||
filter_has_tags = trigger.pop("filter_has_tags", None)
|
||||
filter_has_all_tags = trigger.pop("filter_has_all_tags", None)
|
||||
filter_has_not_tags = trigger.pop("filter_has_not_tags", None)
|
||||
filter_has_not_correspondents = trigger.pop(
|
||||
"filter_has_not_correspondents",
|
||||
None,
|
||||
)
|
||||
filter_has_not_document_types = trigger.pop(
|
||||
"filter_has_not_document_types",
|
||||
None,
|
||||
)
|
||||
filter_has_not_storage_paths = trigger.pop(
|
||||
"filter_has_not_storage_paths",
|
||||
None,
|
||||
)
|
||||
# Convert sources to strings to handle django-multiselectfield v1.0 changes
|
||||
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger)
|
||||
trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
|
||||
@@ -2422,6 +2503,22 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
if filter_has_tags is not None:
|
||||
trigger_instance.filter_has_tags.set(filter_has_tags)
|
||||
if filter_has_all_tags is not None:
|
||||
trigger_instance.filter_has_all_tags.set(filter_has_all_tags)
|
||||
if filter_has_not_tags is not None:
|
||||
trigger_instance.filter_has_not_tags.set(filter_has_not_tags)
|
||||
if filter_has_not_correspondents is not None:
|
||||
trigger_instance.filter_has_not_correspondents.set(
|
||||
filter_has_not_correspondents,
|
||||
)
|
||||
if filter_has_not_document_types is not None:
|
||||
trigger_instance.filter_has_not_document_types.set(
|
||||
filter_has_not_document_types,
|
||||
)
|
||||
if filter_has_not_storage_paths is not None:
|
||||
trigger_instance.filter_has_not_storage_paths.set(
|
||||
filter_has_not_storage_paths,
|
||||
)
|
||||
set_triggers.append(trigger_instance)
|
||||
|
||||
if actions is not None and actions is not serializers.empty:
|
||||
|
||||
@@ -1173,12 +1173,15 @@ def run_workflows(
|
||||
else ""
|
||||
)
|
||||
try:
|
||||
attachments = []
|
||||
if action.email.include_document and original_file:
|
||||
attachments = [document]
|
||||
n_messages = send_email(
|
||||
subject=subject,
|
||||
body=body,
|
||||
to=action.email.to.split(","),
|
||||
attachment=original_file if action.email.include_document else None,
|
||||
attachment_mime_type=document.mime_type,
|
||||
attachments=attachments,
|
||||
use_archive=False,
|
||||
)
|
||||
logger.debug(
|
||||
f"Sent {n_messages} notification email(s) to {action.email.to}",
|
||||
|
||||
@@ -3022,7 +3022,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].attachments[0][0], "archive.pdf")
|
||||
expected_filename = f"{doc.created} test.pdf"
|
||||
self.assertEqual(mail.outbox[0].attachments[0][0], expected_filename)
|
||||
|
||||
self.client.post(
|
||||
f"/api/documents/{doc2.pk}/email/",
|
||||
@@ -3035,7 +3036,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 2)
|
||||
self.assertEqual(mail.outbox[1].attachments[0][0], "test2.pdf")
|
||||
expected_filename2 = f"{doc2.created} test2.pdf"
|
||||
self.assertEqual(mail.outbox[1].attachments[0][0], expected_filename2)
|
||||
|
||||
@mock.patch("django.core.mail.message.EmailMessage.send", side_effect=Exception)
|
||||
def test_email_document_errors(self, mocked_send):
|
||||
@@ -3093,7 +3095,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
"message": "hello",
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
resp = self.client.post(
|
||||
f"/api/documents/{doc.pk}/email/",
|
||||
|
||||
411
src/documents/tests/test_api_email.py
Normal file
411
src/documents/tests/test_api_email.py
Normal file
@@ -0,0 +1,411 @@
|
||||
import json
|
||||
import shutil
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.test import override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
|
||||
|
||||
class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
ENDPOINT = "/api/documents/email/"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.user = User.objects.create_superuser(username="temp_admin")
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.doc1 = Document.objects.create(
|
||||
title="test1",
|
||||
mime_type="application/pdf",
|
||||
content="this is document 1",
|
||||
checksum="1",
|
||||
filename="test1.pdf",
|
||||
archive_checksum="A1",
|
||||
archive_filename="archive1.pdf",
|
||||
)
|
||||
self.doc2 = Document.objects.create(
|
||||
title="test2",
|
||||
mime_type="application/pdf",
|
||||
content="this is document 2",
|
||||
checksum="2",
|
||||
filename="test2.pdf",
|
||||
)
|
||||
|
||||
# Copy sample files to document paths (using different files to distinguish versions)
|
||||
shutil.copy(
|
||||
self.SAMPLE_DIR / "documents" / "originals" / "0000001.pdf",
|
||||
self.doc1.archive_path,
|
||||
)
|
||||
shutil.copy(
|
||||
self.SAMPLE_DIR / "documents" / "originals" / "0000002.pdf",
|
||||
self.doc1.source_path,
|
||||
)
|
||||
shutil.copy(
|
||||
self.SAMPLE_DIR / "documents" / "originals" / "0000003.pdf",
|
||||
self.doc2.source_path,
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
EMAIL_ENABLED=True,
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
)
|
||||
def test_email_success(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Multiple existing documents (doc1 with archive, doc2 without)
|
||||
WHEN:
|
||||
- API request is made to bulk email documents
|
||||
THEN:
|
||||
- Email is sent with all documents attached
|
||||
- Archive version used by default for doc1
|
||||
- Original version used for doc2 (no archive available)
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk, self.doc2.pk],
|
||||
"addresses": "hello@paperless-ngx.com,test@example.com",
|
||||
"subject": "Bulk email test",
|
||||
"message": "Here are your documents",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["message"], "Email sent")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
email = mail.outbox[0]
|
||||
self.assertEqual(email.to, ["hello@paperless-ngx.com", "test@example.com"])
|
||||
self.assertEqual(email.subject, "Bulk email test")
|
||||
self.assertEqual(email.body, "Here are your documents")
|
||||
self.assertEqual(len(email.attachments), 2)
|
||||
|
||||
attachment_names = [att[0] for att in email.attachments]
|
||||
self.assertEqual(len(attachment_names), 2)
|
||||
self.assertIn(f"{self.doc1!s}.pdf", attachment_names)
|
||||
self.assertIn(f"{self.doc2!s}.pdf", attachment_names)
|
||||
|
||||
doc1_attachment = next(
|
||||
att for att in email.attachments if att[0] == f"{self.doc1!s}.pdf"
|
||||
)
|
||||
archive_size = self.doc1.archive_path.stat().st_size
|
||||
self.assertEqual(len(doc1_attachment[1]), archive_size)
|
||||
|
||||
doc2_attachment = next(
|
||||
att for att in email.attachments if att[0] == f"{self.doc2!s}.pdf"
|
||||
)
|
||||
original_size = self.doc2.source_path.stat().st_size
|
||||
self.assertEqual(len(doc2_attachment[1]), original_size)
|
||||
|
||||
@override_settings(
|
||||
EMAIL_ENABLED=True,
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
)
|
||||
def test_email_use_original_version(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Documents with archive versions
|
||||
WHEN:
|
||||
- API request is made to bulk email with use_archive_version=False
|
||||
THEN:
|
||||
- Original files are attached instead of archive versions
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
"use_archive_version": False,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
attachment = mail.outbox[0].attachments[0]
|
||||
self.assertEqual(attachment[0], f"{self.doc1!s}.pdf")
|
||||
|
||||
original_size = self.doc1.source_path.stat().st_size
|
||||
self.assertEqual(len(attachment[1]), original_size)
|
||||
|
||||
def test_email_missing_required_fields(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Request with missing required fields
|
||||
WHEN:
|
||||
- API request is made to bulk email endpoint
|
||||
THEN:
|
||||
- Bad request response is returned
|
||||
"""
|
||||
# Missing addresses
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Missing subject
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "test@example.com",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Missing message
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Missing documents
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_empty_document_list(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Request with empty document list
|
||||
WHEN:
|
||||
- API request is made to bulk email endpoint
|
||||
THEN:
|
||||
- Bad request response is returned
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_invalid_document_id(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Request with non-existent document ID
|
||||
WHEN:
|
||||
- API request is made to bulk email endpoint
|
||||
THEN:
|
||||
- Bad request response is returned
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [999],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_invalid_email_address(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Request with invalid email address
|
||||
WHEN:
|
||||
- API request is made to bulk email endpoint
|
||||
THEN:
|
||||
- Bad request response is returned
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "invalid-email",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Test multiple addresses with one invalid
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "valid@example.com,invalid-email",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_email_insufficient_permissions(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- User without permissions to view document
|
||||
WHEN:
|
||||
- API request is made to bulk email documents
|
||||
THEN:
|
||||
- Forbidden response is returned
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
|
||||
|
||||
doc_owned = Document.objects.create(
|
||||
title="owned_doc",
|
||||
mime_type="application/pdf",
|
||||
checksum="owned",
|
||||
owner=self.user,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk, doc_owned.pk],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@override_settings(
|
||||
EMAIL_ENABLED=True,
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
)
|
||||
def test_email_duplicate_filenames(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Multiple documents with the same title
|
||||
WHEN:
|
||||
- API request is made to bulk email documents
|
||||
THEN:
|
||||
- Filenames are made unique with counters
|
||||
"""
|
||||
doc3 = Document.objects.create(
|
||||
title="test1",
|
||||
mime_type="application/pdf",
|
||||
content="this is document 3",
|
||||
checksum="3",
|
||||
filename="test3.pdf",
|
||||
)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.pdf", doc3.source_path)
|
||||
|
||||
doc4 = Document.objects.create(
|
||||
title="test1",
|
||||
mime_type="application/pdf",
|
||||
content="this is document 4",
|
||||
checksum="4",
|
||||
filename="test4.pdf",
|
||||
)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.pdf", doc4.source_path)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk, doc3.pk, doc4.pk],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
attachment_names = [att[0] for att in mail.outbox[0].attachments]
|
||||
self.assertEqual(len(attachment_names), 3)
|
||||
self.assertIn(f"{self.doc1!s}.pdf", attachment_names)
|
||||
self.assertIn(f"{doc3!s}_01.pdf", attachment_names)
|
||||
self.assertIn(f"{doc3!s}_02.pdf", attachment_names)
|
||||
|
||||
@mock.patch(
|
||||
"django.core.mail.message.EmailMessage.send",
|
||||
side_effect=Exception("Email error"),
|
||||
)
|
||||
def test_email_send_error(self, mocked_send):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing documents
|
||||
WHEN:
|
||||
- API request is made to bulk email and error occurs during email send
|
||||
THEN:
|
||||
- Server error response is returned
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.pk],
|
||||
"addresses": "test@example.com",
|
||||
"subject": "Test",
|
||||
"message": "Test message",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
self.assertIn("Error emailing documents", response.content.decode())
|
||||
@@ -184,6 +184,17 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
"filter_filename": "*",
|
||||
"filter_path": "*/samples/*",
|
||||
"filter_has_tags": [self.t1.id],
|
||||
"filter_has_all_tags": [self.t2.id],
|
||||
"filter_has_not_tags": [self.t3.id],
|
||||
"filter_has_not_correspondents": [self.c2.id],
|
||||
"filter_has_not_document_types": [self.dt2.id],
|
||||
"filter_has_not_storage_paths": [self.sp2.id],
|
||||
"filter_custom_field_query": json.dumps(
|
||||
[
|
||||
"AND",
|
||||
[[self.cf1.id, "exact", "value"]],
|
||||
],
|
||||
),
|
||||
"filter_has_document_type": self.dt.id,
|
||||
"filter_has_correspondent": self.c.id,
|
||||
"filter_has_storage_path": self.sp.id,
|
||||
@@ -223,6 +234,36 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Workflow.objects.count(), 2)
|
||||
workflow = Workflow.objects.get(name="Workflow 2")
|
||||
trigger = workflow.triggers.first()
|
||||
self.assertSetEqual(
|
||||
set(trigger.filter_has_tags.values_list("id", flat=True)),
|
||||
{self.t1.id},
|
||||
)
|
||||
self.assertSetEqual(
|
||||
set(trigger.filter_has_all_tags.values_list("id", flat=True)),
|
||||
{self.t2.id},
|
||||
)
|
||||
self.assertSetEqual(
|
||||
set(trigger.filter_has_not_tags.values_list("id", flat=True)),
|
||||
{self.t3.id},
|
||||
)
|
||||
self.assertSetEqual(
|
||||
set(trigger.filter_has_not_correspondents.values_list("id", flat=True)),
|
||||
{self.c2.id},
|
||||
)
|
||||
self.assertSetEqual(
|
||||
set(trigger.filter_has_not_document_types.values_list("id", flat=True)),
|
||||
{self.dt2.id},
|
||||
)
|
||||
self.assertSetEqual(
|
||||
set(trigger.filter_has_not_storage_paths.values_list("id", flat=True)),
|
||||
{self.sp2.id},
|
||||
)
|
||||
self.assertEqual(
|
||||
trigger.filter_custom_field_query,
|
||||
json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]),
|
||||
)
|
||||
|
||||
def test_api_create_invalid_workflow_trigger(self):
|
||||
"""
|
||||
@@ -376,6 +417,14 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
"filter_has_tags": [self.t1.id],
|
||||
"filter_has_all_tags": [self.t2.id],
|
||||
"filter_has_not_tags": [self.t3.id],
|
||||
"filter_has_not_correspondents": [self.c2.id],
|
||||
"filter_has_not_document_types": [self.dt2.id],
|
||||
"filter_has_not_storage_paths": [self.sp2.id],
|
||||
"filter_custom_field_query": json.dumps(
|
||||
["AND", [[self.cf1.id, "exact", "value"]]],
|
||||
),
|
||||
"filter_has_correspondent": self.c.id,
|
||||
"filter_has_document_type": self.dt.id,
|
||||
},
|
||||
@@ -393,6 +442,30 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
workflow = Workflow.objects.get(id=response.data["id"])
|
||||
self.assertEqual(workflow.name, "Workflow Updated")
|
||||
self.assertEqual(workflow.triggers.first().filter_has_tags.first(), self.t1)
|
||||
self.assertEqual(
|
||||
workflow.triggers.first().filter_has_all_tags.first(),
|
||||
self.t2,
|
||||
)
|
||||
self.assertEqual(
|
||||
workflow.triggers.first().filter_has_not_tags.first(),
|
||||
self.t3,
|
||||
)
|
||||
self.assertEqual(
|
||||
workflow.triggers.first().filter_has_not_correspondents.first(),
|
||||
self.c2,
|
||||
)
|
||||
self.assertEqual(
|
||||
workflow.triggers.first().filter_has_not_document_types.first(),
|
||||
self.dt2,
|
||||
)
|
||||
self.assertEqual(
|
||||
workflow.triggers.first().filter_has_not_storage_paths.first(),
|
||||
self.sp2,
|
||||
)
|
||||
self.assertEqual(
|
||||
workflow.triggers.first().filter_custom_field_query,
|
||||
json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]),
|
||||
)
|
||||
self.assertEqual(workflow.actions.first().assign_title, "Action New Title")
|
||||
|
||||
def test_api_update_workflow_no_trigger_actions(self):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import json
|
||||
import shutil
|
||||
import socket
|
||||
from datetime import timedelta
|
||||
@@ -31,6 +32,7 @@ from documents import tasks
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.matching import document_matches_workflow
|
||||
from documents.matching import existing_document_matches_workflow
|
||||
from documents.matching import prefilter_documents_by_workflowtrigger
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
@@ -46,6 +48,7 @@ from documents.models import WorkflowActionEmail
|
||||
from documents.models import WorkflowActionWebhook
|
||||
from documents.models import WorkflowRun
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.serialisers import WorkflowTriggerSerializer
|
||||
from documents.signals import document_consumption_finished
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import DummyProgressManager
|
||||
@@ -1080,9 +1083,409 @@ class TestWorkflows(
|
||||
)
|
||||
expected_str = f"Document did not match {w}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document tags {doc.tags.all()} do not include {trigger.filter_has_tags.all()}"
|
||||
expected_str = f"Document tags {list(doc.tags.all())} do not include {list(trigger.filter_has_tags.all())}"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
def test_document_added_no_match_all_tags(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
)
|
||||
trigger.filter_has_all_tags.set([self.t1, self.t2])
|
||||
action = WorkflowAction.objects.create(
|
||||
assign_title="Doc assign owner",
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
w = Workflow.objects.create(
|
||||
name="Workflow 1",
|
||||
order=0,
|
||||
)
|
||||
w.triggers.add(trigger)
|
||||
w.actions.add(action)
|
||||
w.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
)
|
||||
doc.tags.set([self.t1])
|
||||
doc.save()
|
||||
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
document_consumption_finished.send(
|
||||
sender=self.__class__,
|
||||
document=doc,
|
||||
)
|
||||
expected_str = f"Document did not match {w}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = (
|
||||
f"Document tags {list(doc.tags.all())} do not contain all of"
|
||||
f" {list(trigger.filter_has_all_tags.all())}"
|
||||
)
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
def test_document_added_excluded_tags(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
)
|
||||
trigger.filter_has_not_tags.set([self.t3])
|
||||
action = WorkflowAction.objects.create(
|
||||
assign_title="Doc assign owner",
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
w = Workflow.objects.create(
|
||||
name="Workflow 1",
|
||||
order=0,
|
||||
)
|
||||
w.triggers.add(trigger)
|
||||
w.actions.add(action)
|
||||
w.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
)
|
||||
doc.tags.set([self.t3])
|
||||
doc.save()
|
||||
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
document_consumption_finished.send(
|
||||
sender=self.__class__,
|
||||
document=doc,
|
||||
)
|
||||
expected_str = f"Document did not match {w}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = (
|
||||
f"Document tags {list(doc.tags.all())} include excluded tags"
|
||||
f" {list(trigger.filter_has_not_tags.all())}"
|
||||
)
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
def test_document_added_excluded_correspondent(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
)
|
||||
trigger.filter_has_not_correspondents.set([self.c])
|
||||
action = WorkflowAction.objects.create(
|
||||
assign_title="Doc assign owner",
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
w = Workflow.objects.create(
|
||||
name="Workflow 1",
|
||||
order=0,
|
||||
)
|
||||
w.triggers.add(trigger)
|
||||
w.actions.add(action)
|
||||
w.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
)
|
||||
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
document_consumption_finished.send(
|
||||
sender=self.__class__,
|
||||
document=doc,
|
||||
)
|
||||
expected_str = f"Document did not match {w}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = (
|
||||
f"Document correspondent {doc.correspondent} is excluded by"
|
||||
f" {list(trigger.filter_has_not_correspondents.all())}"
|
||||
)
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
def test_document_added_excluded_document_types(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
)
|
||||
trigger.filter_has_not_document_types.set([self.dt])
|
||||
action = WorkflowAction.objects.create(
|
||||
assign_title="Doc assign owner",
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
w = Workflow.objects.create(
|
||||
name="Workflow 1",
|
||||
order=0,
|
||||
)
|
||||
w.triggers.add(trigger)
|
||||
w.actions.add(action)
|
||||
w.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
document_type=self.dt,
|
||||
original_filename="sample.pdf",
|
||||
)
|
||||
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
document_consumption_finished.send(
|
||||
sender=self.__class__,
|
||||
document=doc,
|
||||
)
|
||||
expected_str = f"Document did not match {w}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = (
|
||||
f"Document doc type {doc.document_type} is excluded by"
|
||||
f" {list(trigger.filter_has_not_document_types.all())}"
|
||||
)
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
def test_document_added_excluded_storage_paths(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
)
|
||||
trigger.filter_has_not_storage_paths.set([self.sp])
|
||||
action = WorkflowAction.objects.create(
|
||||
assign_title="Doc assign owner",
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
w = Workflow.objects.create(
|
||||
name="Workflow 1",
|
||||
order=0,
|
||||
)
|
||||
w.triggers.add(trigger)
|
||||
w.actions.add(action)
|
||||
w.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
storage_path=self.sp,
|
||||
original_filename="sample.pdf",
|
||||
)
|
||||
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
document_consumption_finished.send(
|
||||
sender=self.__class__,
|
||||
document=doc,
|
||||
)
|
||||
expected_str = f"Document did not match {w}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = (
|
||||
f"Document storage path {doc.storage_path} is excluded by"
|
||||
f" {list(trigger.filter_has_not_storage_paths.all())}"
|
||||
)
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
def test_document_added_custom_field_query_no_match(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
filter_custom_field_query=json.dumps(
|
||||
[
|
||||
"AND",
|
||||
[[self.cf1.id, "exact", "expected"]],
|
||||
],
|
||||
),
|
||||
)
|
||||
action = WorkflowAction.objects.create(
|
||||
assign_title="Doc assign owner",
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
workflow = Workflow.objects.create(name="Workflow 1", order=0)
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(action)
|
||||
workflow.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
)
|
||||
CustomFieldInstance.objects.create(
|
||||
document=doc,
|
||||
field=self.cf1,
|
||||
value_text="other",
|
||||
)
|
||||
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
document_consumption_finished.send(
|
||||
sender=self.__class__,
|
||||
document=doc,
|
||||
)
|
||||
expected_str = f"Document did not match {workflow}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
self.assertIn(
|
||||
"Document custom fields do not match the configured custom field query",
|
||||
cm.output[1],
|
||||
)
|
||||
|
||||
def test_document_added_custom_field_query_match(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
filter_custom_field_query=json.dumps(
|
||||
[
|
||||
"AND",
|
||||
[[self.cf1.id, "exact", "expected"]],
|
||||
],
|
||||
),
|
||||
)
|
||||
doc = Document.objects.create(
|
||||
title="sample test",
|
||||
correspondent=self.c,
|
||||
original_filename="sample.pdf",
|
||||
)
|
||||
CustomFieldInstance.objects.create(
|
||||
document=doc,
|
||||
field=self.cf1,
|
||||
value_text="expected",
|
||||
)
|
||||
|
||||
matched, reason = existing_document_matches_workflow(doc, trigger)
|
||||
self.assertTrue(matched)
|
||||
self.assertIsNone(reason)
|
||||
|
||||
def test_prefilter_documents_custom_field_query(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
filter_custom_field_query=json.dumps(
|
||||
[
|
||||
"AND",
|
||||
[[self.cf1.id, "exact", "match"]],
|
||||
],
|
||||
),
|
||||
)
|
||||
doc1 = Document.objects.create(
|
||||
title="doc 1",
|
||||
correspondent=self.c,
|
||||
original_filename="doc1.pdf",
|
||||
checksum="checksum1",
|
||||
)
|
||||
CustomFieldInstance.objects.create(
|
||||
document=doc1,
|
||||
field=self.cf1,
|
||||
value_text="match",
|
||||
)
|
||||
|
||||
doc2 = Document.objects.create(
|
||||
title="doc 2",
|
||||
correspondent=self.c,
|
||||
original_filename="doc2.pdf",
|
||||
checksum="checksum2",
|
||||
)
|
||||
CustomFieldInstance.objects.create(
|
||||
document=doc2,
|
||||
field=self.cf1,
|
||||
value_text="different",
|
||||
)
|
||||
|
||||
filtered = prefilter_documents_by_workflowtrigger(
|
||||
Document.objects.all(),
|
||||
trigger,
|
||||
)
|
||||
self.assertIn(doc1, filtered)
|
||||
self.assertNotIn(doc2, filtered)
|
||||
|
||||
def test_consumption_trigger_requires_filter_configuration(self):
|
||||
serializer = WorkflowTriggerSerializer(
|
||||
data={
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
errors = serializer.errors.get("non_field_errors", [])
|
||||
self.assertIn(
|
||||
"File name, path or mail rule filter are required",
|
||||
[str(error) for error in errors],
|
||||
)
|
||||
|
||||
def test_workflow_trigger_serializer_clears_empty_custom_field_query(self):
|
||||
serializer = WorkflowTriggerSerializer(
|
||||
data={
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
"filter_custom_field_query": "",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
self.assertIsNone(serializer.validated_data.get("filter_custom_field_query"))
|
||||
|
||||
def test_existing_document_invalid_custom_field_query_configuration(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
filter_custom_field_query="{ not json",
|
||||
)
|
||||
|
||||
document = Document.objects.create(
|
||||
title="doc invalid query",
|
||||
original_filename="invalid.pdf",
|
||||
checksum="checksum-invalid-query",
|
||||
)
|
||||
|
||||
matched, reason = existing_document_matches_workflow(document, trigger)
|
||||
self.assertFalse(matched)
|
||||
self.assertEqual(reason, "Invalid custom field query configuration")
|
||||
|
||||
def test_prefilter_documents_returns_none_for_invalid_custom_field_query(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
filter_custom_field_query="{ not json",
|
||||
)
|
||||
|
||||
Document.objects.create(
|
||||
title="doc",
|
||||
original_filename="doc.pdf",
|
||||
checksum="checksum-prefilter-invalid",
|
||||
)
|
||||
|
||||
filtered = prefilter_documents_by_workflowtrigger(
|
||||
Document.objects.all(),
|
||||
trigger,
|
||||
)
|
||||
|
||||
self.assertEqual(list(filtered), [])
|
||||
|
||||
def test_prefilter_documents_applies_all_filters(self):
|
||||
other_document_type = DocumentType.objects.create(name="Other Type")
|
||||
other_storage_path = StoragePath.objects.create(
|
||||
name="Blocked path",
|
||||
path="/blocked/",
|
||||
)
|
||||
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
filter_has_correspondent=self.c,
|
||||
filter_has_document_type=self.dt,
|
||||
filter_has_storage_path=self.sp,
|
||||
)
|
||||
trigger.filter_has_tags.set([self.t1])
|
||||
trigger.filter_has_all_tags.set([self.t1, self.t2])
|
||||
trigger.filter_has_not_tags.set([self.t3])
|
||||
trigger.filter_has_not_correspondents.set([self.c2])
|
||||
trigger.filter_has_not_document_types.set([other_document_type])
|
||||
trigger.filter_has_not_storage_paths.set([other_storage_path])
|
||||
|
||||
allowed_document = Document.objects.create(
|
||||
title="allowed",
|
||||
correspondent=self.c,
|
||||
document_type=self.dt,
|
||||
storage_path=self.sp,
|
||||
original_filename="allow.pdf",
|
||||
checksum="checksum-prefilter-allowed",
|
||||
)
|
||||
allowed_document.tags.set([self.t1, self.t2])
|
||||
|
||||
blocked_document = Document.objects.create(
|
||||
title="blocked",
|
||||
correspondent=self.c2,
|
||||
document_type=other_document_type,
|
||||
storage_path=other_storage_path,
|
||||
original_filename="block.pdf",
|
||||
checksum="checksum-prefilter-blocked",
|
||||
)
|
||||
blocked_document.tags.set([self.t1, self.t3])
|
||||
|
||||
filtered = prefilter_documents_by_workflowtrigger(
|
||||
Document.objects.all(),
|
||||
trigger,
|
||||
)
|
||||
|
||||
self.assertIn(allowed_document, filtered)
|
||||
self.assertNotIn(blocked_document, filtered)
|
||||
|
||||
def test_document_added_no_match_doctype(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
|
||||
@@ -61,6 +61,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
from drf_spectacular.utils import extend_schema_view
|
||||
from drf_spectacular.utils import inline_serializer
|
||||
from guardian.utils import get_group_obj_perms_model
|
||||
@@ -159,6 +160,7 @@ from documents.serialisers import CustomFieldSerializer
|
||||
from documents.serialisers import DocumentListSerializer
|
||||
from documents.serialisers import DocumentSerializer
|
||||
from documents.serialisers import DocumentTypeSerializer
|
||||
from documents.serialisers import EmailSerializer
|
||||
from documents.serialisers import NotesSerializer
|
||||
from documents.serialisers import PostDocumentSerializer
|
||||
from documents.serialisers import RunTaskViewSerializer
|
||||
@@ -486,6 +488,14 @@ class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
component_name="EmailDocumentRequest",
|
||||
exclude_fields=("documents",),
|
||||
)
|
||||
class EmailDocumentDetailSchema(EmailSerializer):
|
||||
pass
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
retrieve=extend_schema(
|
||||
description="Retrieve a single document",
|
||||
@@ -653,20 +663,28 @@ class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
404: None,
|
||||
},
|
||||
),
|
||||
email=extend_schema(
|
||||
email_document=extend_schema(
|
||||
description="Email the document to one or more recipients as an attachment.",
|
||||
request=inline_serializer(
|
||||
name="EmailRequest",
|
||||
fields={
|
||||
"addresses": serializers.CharField(),
|
||||
"subject": serializers.CharField(),
|
||||
"message": serializers.CharField(),
|
||||
"use_archive_version": serializers.BooleanField(default=True),
|
||||
},
|
||||
),
|
||||
request=EmailDocumentDetailSchema,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="EmailResponse",
|
||||
name="EmailDocumentResponse",
|
||||
fields={"message": serializers.CharField()},
|
||||
),
|
||||
400: None,
|
||||
403: None,
|
||||
404: None,
|
||||
500: None,
|
||||
},
|
||||
deprecated=True,
|
||||
),
|
||||
email_documents=extend_schema(
|
||||
operation_id="email_documents",
|
||||
description="Email one or more documents as attachments to one or more recipients.",
|
||||
request=EmailSerializer,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="EmailDocumentsResponse",
|
||||
fields={"message": serializers.CharField()},
|
||||
),
|
||||
400: None,
|
||||
@@ -1236,55 +1254,57 @@ class DocumentViewSet(
|
||||
|
||||
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
def email(self, request, pk=None):
|
||||
try:
|
||||
doc = Document.objects.select_related("owner").get(pk=pk)
|
||||
@action(methods=["post"], detail=True, url_path="email")
|
||||
# TODO: deprecated as of 2.19, remove in future release
|
||||
def email_document(self, request, pk=None):
|
||||
request_data = request.data.copy()
|
||||
request_data.setlist("documents", [pk])
|
||||
return self.email_documents(request, data=request_data)
|
||||
|
||||
@action(
|
||||
methods=["post"],
|
||||
detail=False,
|
||||
url_path="email",
|
||||
serializer_class=EmailSerializer,
|
||||
)
|
||||
def email_documents(self, request, data=None):
|
||||
serializer = EmailSerializer(data=data or request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
document_ids = validated_data.get("documents")
|
||||
addresses = validated_data.get("addresses").split(",")
|
||||
addresses = [addr.strip() for addr in addresses]
|
||||
subject = validated_data.get("subject")
|
||||
message = validated_data.get("message")
|
||||
use_archive_version = validated_data.get("use_archive_version", True)
|
||||
|
||||
documents = Document.objects.select_related("owner").filter(pk__in=document_ids)
|
||||
for document in documents:
|
||||
if request.user is not None and not has_perms_owner_aware(
|
||||
request.user,
|
||||
"view_document",
|
||||
doc,
|
||||
document,
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
except Document.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
try:
|
||||
if (
|
||||
"addresses" not in request.data
|
||||
or "subject" not in request.data
|
||||
or "message" not in request.data
|
||||
):
|
||||
return HttpResponseBadRequest("Missing required fields")
|
||||
|
||||
use_archive_version = request.data.get("use_archive_version", True)
|
||||
|
||||
addresses = request.data.get("addresses").split(",")
|
||||
if not all(
|
||||
re.match(r"[^@]+@[^@]+\.[^@]+", address.strip())
|
||||
for address in addresses
|
||||
):
|
||||
return HttpResponseBadRequest("Invalid email address found")
|
||||
|
||||
send_email(
|
||||
subject=request.data.get("subject"),
|
||||
body=request.data.get("message"),
|
||||
subject=subject,
|
||||
body=message,
|
||||
to=addresses,
|
||||
attachment=(
|
||||
doc.archive_path
|
||||
if use_archive_version and doc.has_archive_version
|
||||
else doc.source_path
|
||||
),
|
||||
attachment_mime_type=doc.mime_type,
|
||||
attachments=documents,
|
||||
use_archive=use_archive_version,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Sent document {doc.id} via email to {addresses}",
|
||||
f"Sent documents {[doc.id for doc in documents]} via email to {addresses}",
|
||||
)
|
||||
return Response({"message": "Email sent"})
|
||||
except Exception as e:
|
||||
logger.warning(f"An error occurred emailing document: {e!s}")
|
||||
logger.warning(f"An error occurred emailing documents: {e!s}")
|
||||
return HttpResponseServerError(
|
||||
"Error emailing document, check logs for more detail.",
|
||||
"Error emailing documents, check logs for more detail.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-09-30 16:50+0000\n"
|
||||
"POT-Creation-Date: 2025-10-13 22:25+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -89,7 +89,7 @@ msgstr ""
|
||||
msgid "Automatic"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:64 documents/models.py:456 documents/models.py:1484
|
||||
#: documents/models.py:64 documents/models.py:456 documents/models.py:1526
|
||||
#: paperless_mail/models.py:23 paperless_mail/models.py:143
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
@@ -264,7 +264,7 @@ msgid "The position of this document in your physical document archive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:318 documents/models.py:699 documents/models.py:753
|
||||
#: documents/models.py:1527
|
||||
#: documents/models.py:1569
|
||||
msgid "document"
|
||||
msgstr ""
|
||||
|
||||
@@ -864,371 +864,399 @@ msgstr ""
|
||||
msgid "has these tag(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1073
|
||||
#: documents/models.py:1072
|
||||
msgid "has all of these tag(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1079
|
||||
msgid "does not have these tag(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1087
|
||||
msgid "has this document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1081
|
||||
#: documents/models.py:1094
|
||||
msgid "does not have these document type(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1102
|
||||
msgid "has this correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1089
|
||||
#: documents/models.py:1109
|
||||
msgid "does not have these correspondent(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1117
|
||||
msgid "has this storage path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1093
|
||||
#: documents/models.py:1124
|
||||
msgid "does not have these storage path(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1128
|
||||
msgid "filter custom field query"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1131
|
||||
msgid "JSON-encoded custom field query expression."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1135
|
||||
msgid "schedule offset days"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1096
|
||||
#: documents/models.py:1138
|
||||
msgid "The number of days to offset the schedule trigger by."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1101
|
||||
#: documents/models.py:1143
|
||||
msgid "schedule is recurring"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1104
|
||||
#: documents/models.py:1146
|
||||
msgid "If the schedule should be recurring."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1109
|
||||
#: documents/models.py:1151
|
||||
msgid "schedule recurring delay in days"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1113
|
||||
#: documents/models.py:1155
|
||||
msgid "The number of days between recurring schedule triggers."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1118
|
||||
#: documents/models.py:1160
|
||||
msgid "schedule date field"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1123
|
||||
#: documents/models.py:1165
|
||||
msgid "The field to check for a schedule trigger."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1132
|
||||
#: documents/models.py:1174
|
||||
msgid "schedule date custom field"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1136
|
||||
#: documents/models.py:1178
|
||||
msgid "workflow trigger"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1137
|
||||
#: documents/models.py:1179
|
||||
msgid "workflow triggers"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1145
|
||||
#: documents/models.py:1187
|
||||
msgid "email subject"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1149
|
||||
#: documents/models.py:1191
|
||||
msgid ""
|
||||
"The subject of the email, can include some placeholders, see documentation."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1155
|
||||
#: documents/models.py:1197
|
||||
msgid "email body"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1158
|
||||
#: documents/models.py:1200
|
||||
msgid ""
|
||||
"The body (message) of the email, can include some placeholders, see "
|
||||
"documentation."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1164
|
||||
#: documents/models.py:1206
|
||||
msgid "emails to"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1167
|
||||
#: documents/models.py:1209
|
||||
msgid "The destination email addresses, comma separated."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1173
|
||||
#: documents/models.py:1215
|
||||
msgid "include document in email"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1184
|
||||
#: documents/models.py:1226
|
||||
msgid "webhook url"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1187
|
||||
#: documents/models.py:1229
|
||||
msgid "The destination URL for the notification."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1192
|
||||
#: documents/models.py:1234
|
||||
msgid "use parameters"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1197
|
||||
#: documents/models.py:1239
|
||||
msgid "send as JSON"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1201
|
||||
#: documents/models.py:1243
|
||||
msgid "webhook parameters"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1204
|
||||
#: documents/models.py:1246
|
||||
msgid "The parameters to send with the webhook URL if body not used."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1208
|
||||
#: documents/models.py:1250
|
||||
msgid "webhook body"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1211
|
||||
#: documents/models.py:1253
|
||||
msgid "The body to send with the webhook URL if parameters not used."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1215
|
||||
#: documents/models.py:1257
|
||||
msgid "webhook headers"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1218
|
||||
#: documents/models.py:1260
|
||||
msgid "The headers to send with the webhook URL."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1223
|
||||
#: documents/models.py:1265
|
||||
msgid "include document in webhook"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1234
|
||||
#: documents/models.py:1276
|
||||
msgid "Assignment"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1238
|
||||
#: documents/models.py:1280
|
||||
msgid "Removal"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1242 documents/templates/account/password_reset.html:15
|
||||
#: documents/models.py:1284 documents/templates/account/password_reset.html:15
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1246
|
||||
#: documents/models.py:1288
|
||||
msgid "Webhook"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1250
|
||||
#: documents/models.py:1292
|
||||
msgid "Workflow Action Type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1256
|
||||
#: documents/models.py:1298
|
||||
msgid "assign title"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1260
|
||||
#: documents/models.py:1302
|
||||
msgid "Assign a document title, must be a Jinja2 template, see documentation."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1268 paperless_mail/models.py:274
|
||||
#: documents/models.py:1310 paperless_mail/models.py:274
|
||||
msgid "assign this tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1277 paperless_mail/models.py:282
|
||||
#: documents/models.py:1319 paperless_mail/models.py:282
|
||||
msgid "assign this document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1286 paperless_mail/models.py:296
|
||||
#: documents/models.py:1328 paperless_mail/models.py:296
|
||||
msgid "assign this correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1295
|
||||
#: documents/models.py:1337
|
||||
msgid "assign this storage path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1304
|
||||
#: documents/models.py:1346
|
||||
msgid "assign this owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1311
|
||||
#: documents/models.py:1353
|
||||
msgid "grant view permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1318
|
||||
#: documents/models.py:1360
|
||||
msgid "grant view permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1325
|
||||
#: documents/models.py:1367
|
||||
msgid "grant change permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1332
|
||||
#: documents/models.py:1374
|
||||
msgid "grant change permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1339
|
||||
#: documents/models.py:1381
|
||||
msgid "assign these custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1343
|
||||
#: documents/models.py:1385
|
||||
msgid "custom field values"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1347
|
||||
#: documents/models.py:1389
|
||||
msgid "Optional values to assign to the custom fields."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1356
|
||||
#: documents/models.py:1398
|
||||
msgid "remove these tag(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1361
|
||||
#: documents/models.py:1403
|
||||
msgid "remove all tags"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1368
|
||||
#: documents/models.py:1410
|
||||
msgid "remove these document type(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1373
|
||||
#: documents/models.py:1415
|
||||
msgid "remove all document types"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1380
|
||||
#: documents/models.py:1422
|
||||
msgid "remove these correspondent(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1385
|
||||
#: documents/models.py:1427
|
||||
msgid "remove all correspondents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1392
|
||||
#: documents/models.py:1434
|
||||
msgid "remove these storage path(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1397
|
||||
#: documents/models.py:1439
|
||||
msgid "remove all storage paths"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1404
|
||||
#: documents/models.py:1446
|
||||
msgid "remove these owner(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1409
|
||||
#: documents/models.py:1451
|
||||
msgid "remove all owners"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1416
|
||||
#: documents/models.py:1458
|
||||
msgid "remove view permissions for these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1423
|
||||
#: documents/models.py:1465
|
||||
msgid "remove view permissions for these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1430
|
||||
#: documents/models.py:1472
|
||||
msgid "remove change permissions for these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1437
|
||||
#: documents/models.py:1479
|
||||
msgid "remove change permissions for these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1442
|
||||
#: documents/models.py:1484
|
||||
msgid "remove all permissions"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1449
|
||||
#: documents/models.py:1491
|
||||
msgid "remove these custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1454
|
||||
#: documents/models.py:1496
|
||||
msgid "remove all custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1463
|
||||
#: documents/models.py:1505
|
||||
msgid "email"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1472
|
||||
#: documents/models.py:1514
|
||||
msgid "webhook"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1476
|
||||
#: documents/models.py:1518
|
||||
msgid "workflow action"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1477
|
||||
#: documents/models.py:1519
|
||||
msgid "workflow actions"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1486 paperless_mail/models.py:145
|
||||
#: documents/models.py:1528 paperless_mail/models.py:145
|
||||
msgid "order"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1492
|
||||
#: documents/models.py:1534
|
||||
msgid "triggers"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1499
|
||||
#: documents/models.py:1541
|
||||
msgid "actions"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1502 paperless_mail/models.py:154
|
||||
#: documents/models.py:1544 paperless_mail/models.py:154
|
||||
msgid "enabled"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1513
|
||||
#: documents/models.py:1555
|
||||
msgid "workflow"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1517
|
||||
#: documents/models.py:1559
|
||||
msgid "workflow trigger type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1531
|
||||
#: documents/models.py:1573
|
||||
msgid "date run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1537
|
||||
#: documents/models.py:1579
|
||||
msgid "workflow run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1538
|
||||
#: documents/models.py:1580
|
||||
msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:141
|
||||
#: documents/serialisers.py:143
|
||||
#, python-format
|
||||
msgid "Invalid regular expression: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:607
|
||||
#: documents/serialisers.py:609
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:636
|
||||
#: documents/serialisers.py:638
|
||||
msgid "Invalid parent tag."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1793
|
||||
#: documents/serialisers.py:1795
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1837
|
||||
#: documents/serialisers.py:1839
|
||||
#, python-format
|
||||
msgid "Custom field id must be an integer: %(id)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1844
|
||||
#: documents/serialisers.py:1846
|
||||
#, python-format
|
||||
msgid "Custom field with id %(id)s does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1861 documents/serialisers.py:1871
|
||||
#: documents/serialisers.py:1863 documents/serialisers.py:1873
|
||||
msgid ""
|
||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1866
|
||||
#: documents/serialisers.py:1868
|
||||
msgid "Some custom fields don't exist or were specified twice."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1936
|
||||
#: documents/serialisers.py:1983
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user