]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: jinja template support for workflow title assignment (#10700)
authorsidey79 <7968127+sidey79@users.noreply.github.com>
Thu, 11 Sep 2025 13:56:16 +0000 (15:56 +0200)
committerGitHub <noreply@github.com>
Thu, 11 Sep 2025 13:56:16 +0000 (06:56 -0700)
---------

Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
12 files changed:
docs/advanced_usage.md
docs/usage.md
src/documents/migrations/1069_migrate_workflow_title_jinja.py [new file with mode: 0644]
src/documents/models.py
src/documents/templating/environment.py [new file with mode: 0644]
src/documents/templating/filepath.py
src/documents/templating/filters.py [new file with mode: 0644]
src/documents/templating/workflows.py
src/documents/tests/test_consumer.py
src/documents/tests/test_file_handling.py
src/documents/tests/test_filters.py [new file with mode: 0644]
src/documents/tests/test_workflows.py

index 378ad424a4a7e3d557ac6e60beb5590a25626538..de10688646a4d74bd4abaf6b368bd927840f4320 100644 (file)
@@ -506,6 +506,7 @@ for the possible codes and their meanings.
 The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization.
 This takes into account the provided locale for translation. Since this must be used on a date or datetime object,
 you must access the field directly, i.e. `document.created`.
+An ISO string can also be provided to control the output format.
 
 ###### Syntax
 
@@ -516,7 +517,7 @@ you must access the field directly, i.e. `document.created`.
 
 ###### Parameters
 
--   `value` (date | datetime): Date or datetime object to format (datetime should be timezone-aware)
+-   `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
 -   `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
 -   `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
 
index 9310d9a2f993bee9b60742f5232efdb9d7736fae..864eab0c10f20c4cd1f89097f4ff930d8eb51e80 100644 (file)
@@ -505,35 +505,52 @@ you may want to adjust these settings to prevent abuse.
 
 #### Workflow placeholders
 
-Some workflow text can include placeholders but the available options differ depending on the type of
-workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
-applied. You can use the following placeholders with any trigger type:
-
--   `{correspondent}`: assigned correspondent name
--   `{document_type}`: assigned document type name
--   `{owner_username}`: assigned owner username
--   `{added}`: added datetime
--   `{added_year}`: added year
--   `{added_year_short}`: added year
--   `{added_month}`: added month
--   `{added_month_name}`: added month name
--   `{added_month_name_short}`: added month short name
--   `{added_day}`: added day
--   `{added_time}`: added time in HH:MM format
--   `{original_filename}`: original file name without extension
--   `{filename}`: current file name without extension
+Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
+This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
+and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
+The template is provided as a string.
+
+Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title.
+
+The available inputs differ depending on the type of workflow trigger.
+This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
+applied. You can use the following placeholders in the template with any trigger type:
+
+-   `{{correspondent}}`: assigned correspondent name
+-   `{{document_type}}`: assigned document type name
+-   `{{owner_username}}`: assigned owner username
+-   `{{added}}`: added datetime
+-   `{{added_year}}`: added year
+-   `{{added_year_short}}`: added year
+-   `{{added_month}}`: added month
+-   `{{added_month_name}}`: added month name
+-   `{{added_month_name_short}}`: added month short name
+-   `{{added_day}}`: added day
+-   `{{added_time}}`: added time in HH:MM format
+-   `{{original_filename}}`: original file name without extension
+-   `{{filename}}`: current file name without extension
 
 The following placeholders are only available for "added" or "updated" triggers
 
--   `{created}`: created datetime
--   `{created_year}`: created year
--   `{created_year_short}`: created year
--   `{created_month}`: created month
--   `{created_month_name}`: created month name
--   `{created_month_name_short}`: created month short name
--   `{created_day}`: created day
--   `{created_time}`: created time in HH:MM format
--   `{doc_url}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
+-   `{{created}}`: created datetime
+-   `{{created_year}}`: created year
+-   `{{created_year_short}}`: created year
+-   `{{created_month}}`: created month
+-   `{{created_month_name}}`: created month name
+-   `{created_month_name_short}}`: created month short name
+-   `{{created_day}}`: created day
+-   `{{created_time}}`: created time in HH:MM format
+-   `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
+
+##### Examples
+
+```jinja2
+{{ created | localize_date('MMMM', 'en_US') }}
+<!-- Output: "January" -->
+
+{{ added | localize_date('MMMM', 'de_DE') }}
+<!-- Output: "Juni" --> # codespell:ignore
+```
 
 ### Workflow permissions
 
diff --git a/src/documents/migrations/1069_migrate_workflow_title_jinja.py b/src/documents/migrations/1069_migrate_workflow_title_jinja.py
new file mode 100644 (file)
index 0000000..52b7019
--- /dev/null
@@ -0,0 +1,51 @@
+# Generated by Django 5.2.5 on 2025-08-27 22:02
+import logging
+
+from django.db import migrations
+from django.db import models
+from django.db import transaction
+
+from documents.templating.utils import convert_format_str_to_template_format
+
+logger = logging.getLogger("paperless.migrations")
+
+
+def convert_from_format_to_template(apps, schema_editor):
+    WorkflowActions = apps.get_model("documents", "WorkflowAction")
+
+    with transaction.atomic():
+        for WorkflowAction in WorkflowActions.objects.all():
+            WorkflowAction.assign_title = convert_format_str_to_template_format(
+                WorkflowAction.assign_title,
+            )
+            logger.debug(
+                "Converted WorkflowAction id %d title to template format: %s",
+                WorkflowAction.id,
+                WorkflowAction.assign_title,
+            )
+            WorkflowAction.save()
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("documents", "1068_alter_document_created"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="WorkflowAction",
+            name="assign_title",
+            field=models.TextField(
+                null=True,
+                blank=True,
+                help_text=(
+                    "Assign a document title, can be a JINJA2 template, "
+                    "see documentation.",
+                ),
+            ),
+        ),
+        migrations.RunPython(
+            convert_from_format_to_template,
+            migrations.RunPython.noop,
+        ),
+    ]
index 72e3996d5010f11afbec5c1532e433cf6ea25f8d..fc7dd3fdff097deac6b505c92f39696291789243 100644 (file)
@@ -1207,14 +1207,12 @@ class WorkflowAction(models.Model):
         default=WorkflowActionType.ASSIGNMENT,
     )
 
-    assign_title = models.CharField(
+    assign_title = models.TextField(
         _("assign title"),
-        max_length=256,
         null=True,
         blank=True,
         help_text=_(
-            "Assign a document title, can include some placeholders, "
-            "see documentation.",
+            "Assign a document title, must  be a Jinja2 template, see documentation.",
         ),
     )
 
diff --git a/src/documents/templating/environment.py b/src/documents/templating/environment.py
new file mode 100644 (file)
index 0000000..e991842
--- /dev/null
@@ -0,0 +1,27 @@
+from jinja2.sandbox import SandboxedEnvironment
+
+
+class JinjaEnvironment(SandboxedEnvironment):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.undefined_tracker = None
+
+    def is_safe_callable(self, obj):
+        # Block access to .save() and .delete() methods
+        if callable(obj) and getattr(obj, "__name__", None) in (
+            "save",
+            "delete",
+            "update",
+        ):
+            return False
+        # Call the parent method for other cases
+        return super().is_safe_callable(obj)
+
+
+_template_environment = JinjaEnvironment(
+    trim_blocks=True,
+    lstrip_blocks=True,
+    keep_trailing_newline=False,
+    autoescape=False,
+    extensions=["jinja2.ext.loopcontrols"],
+)
index 861c11cdbe5260c278e96da1cb344fd123fbd7f5..a3354109528d0ca5ecbf57b023d509543689f045 100644 (file)
@@ -2,22 +2,16 @@ import logging
 import os
 import re
 from collections.abc import Iterable
-from datetime import date
-from datetime import datetime
 from pathlib import PurePath
 
 import pathvalidate
-from babel import Locale
-from babel import dates
 from django.utils import timezone
-from django.utils.dateparse import parse_date
 from django.utils.text import slugify as django_slugify
 from jinja2 import StrictUndefined
 from jinja2 import Template
 from jinja2 import TemplateSyntaxError
 from jinja2 import UndefinedError
 from jinja2 import make_logging_undefined
-from jinja2.sandbox import SandboxedEnvironment
 from jinja2.sandbox import SecurityError
 
 from documents.models import Correspondent
@@ -27,39 +21,16 @@ from documents.models import Document
 from documents.models import DocumentType
 from documents.models import StoragePath
 from documents.models import Tag
+from documents.templating.environment import _template_environment
+from documents.templating.filters import format_datetime
+from documents.templating.filters import get_cf_value
+from documents.templating.filters import localize_date
 
 logger = logging.getLogger("paperless.templating")
 
 _LogStrictUndefined = make_logging_undefined(logger, StrictUndefined)
 
 
-class FilePathEnvironment(SandboxedEnvironment):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.undefined_tracker = None
-
-    def is_safe_callable(self, obj):
-        # Block access to .save() and .delete() methods
-        if callable(obj) and getattr(obj, "__name__", None) in (
-            "save",
-            "delete",
-            "update",
-        ):
-            return False
-        # Call the parent method for other cases
-        return super().is_safe_callable(obj)
-
-
-_template_environment = FilePathEnvironment(
-    trim_blocks=True,
-    lstrip_blocks=True,
-    keep_trailing_newline=False,
-    autoescape=False,
-    extensions=["jinja2.ext.loopcontrols"],
-    undefined=_LogStrictUndefined,
-)
-
-
 class FilePathTemplate(Template):
     def render(self, *args, **kwargs) -> str:
         def clean_filepath(value: str) -> str:
@@ -81,54 +52,7 @@ class FilePathTemplate(Template):
         return clean_filepath(original_render)
 
 
-def get_cf_value(
-    custom_field_data: dict[str, dict[str, str]],
-    name: str,
-    default: str | None = None,
-) -> str | None:
-    if name in custom_field_data and custom_field_data[name]["value"] is not None:
-        return custom_field_data[name]["value"]
-    elif default is not None:
-        return default
-    return None
-
-
-def format_datetime(value: str | datetime, format: str) -> str:
-    if isinstance(value, str):
-        value = parse_date(value)
-    return value.strftime(format=format)
-
-
-def localize_date(value: date | datetime, format: str, locale: str) -> str:
-    """
-    Format a date or datetime object into a localized string using Babel.
-
-    Args:
-        value (date | datetime): The date or datetime to format. If a datetime
-            is provided, it should be timezone-aware (e.g., UTC from a Django DB object).
-        format (str): The format to use. Can be one of Babel's preset formats
-            ('short', 'medium', 'long', 'full') or a custom pattern string.
-        locale (str): The locale code (e.g., 'en_US', 'fr_FR') to use for
-            localization.
-
-    Returns:
-        str: The localized, formatted date string.
-
-    Raises:
-        TypeError: If `value` is not a date or datetime instance.
-    """
-    try:
-        Locale.parse(locale)
-    except Exception as e:
-        raise ValueError(f"Invalid locale identifier: {locale}") from e
-
-    if isinstance(value, datetime):
-        return dates.format_datetime(value, format=format, locale=locale)
-    elif isinstance(value, date):
-        return dates.format_date(value, format=format, locale=locale)
-    else:
-        raise TypeError(f"Unsupported type {type(value)} for localize_date")
-
+_template_environment.undefined = _LogStrictUndefined
 
 _template_environment.filters["get_cf_value"] = get_cf_value
 
diff --git a/src/documents/templating/filters.py b/src/documents/templating/filters.py
new file mode 100644 (file)
index 0000000..e703f3a
--- /dev/null
@@ -0,0 +1,60 @@
+from datetime import date
+from datetime import datetime
+
+from babel import Locale
+from babel import dates
+from django.utils.dateparse import parse_date
+from django.utils.dateparse import parse_datetime
+
+
+def localize_date(value: date | datetime | str, format: str, locale: str) -> str:
+    """
+    Format a date, datetime or str object into a localized string using Babel.
+
+    Args:
+        value (date | datetime | str): The date or datetime to format. If a datetime
+            is provided, it should be timezone-aware (e.g., UTC from a Django DB object).
+            if str is provided is is parsed as date.
+        format (str): The format to use. Can be one of Babel's preset formats
+            ('short', 'medium', 'long', 'full') or a custom pattern string.
+        locale (str): The locale code (e.g., 'en_US', 'fr_FR') to use for
+            localization.
+
+    Returns:
+        str: The localized, formatted date string.
+
+    Raises:
+        TypeError: If `value` is not a date, datetime or str instance.
+    """
+    if isinstance(value, str):
+        value = parse_datetime(value)
+
+    try:
+        Locale.parse(locale)
+    except Exception as e:
+        raise ValueError(f"Invalid locale identifier: {locale}") from e
+
+    if isinstance(value, datetime):
+        return dates.format_datetime(value, format=format, locale=locale)
+    elif isinstance(value, date):
+        return dates.format_date(value, format=format, locale=locale)
+    else:
+        raise TypeError(f"Unsupported type {type(value)} for localize_date")
+
+
+def format_datetime(value: str | datetime, format: str) -> str:
+    if isinstance(value, str):
+        value = parse_date(value)
+    return value.strftime(format=format)
+
+
+def get_cf_value(
+    custom_field_data: dict[str, dict[str, str]],
+    name: str,
+    default: str | None = None,
+) -> str | None:
+    if name in custom_field_data and custom_field_data[name]["value"] is not None:
+        return custom_field_data[name]["value"]
+    elif default is not None:
+        return default
+    return None
index e679dbaa14b17ca7e7e80c591649251fe9f39c87..25f1e57ef6bf22f271002212db7e9fa088033303 100644 (file)
@@ -1,7 +1,33 @@
+import logging
 from datetime import date
 from datetime import datetime
 from pathlib import Path
 
+from django.utils.text import slugify as django_slugify
+from jinja2 import StrictUndefined
+from jinja2 import Template
+from jinja2 import TemplateSyntaxError
+from jinja2 import UndefinedError
+from jinja2 import make_logging_undefined
+from jinja2.sandbox import SecurityError
+
+from documents.templating.environment import _template_environment
+from documents.templating.filters import format_datetime
+from documents.templating.filters import localize_date
+
+logger = logging.getLogger("paperless.templating")
+
+_LogStrictUndefined = make_logging_undefined(logger, StrictUndefined)
+
+
+_template_environment.undefined = _LogStrictUndefined
+
+_template_environment.filters["datetime"] = format_datetime
+
+_template_environment.filters["slugify"] = django_slugify
+
+_template_environment.filters["localize_date"] = localize_date
+
 
 def parse_w_workflow_placeholders(
     text: str,
@@ -20,6 +46,7 @@ def parse_w_workflow_placeholders(
     e.g. for pre-consumption triggers created will not have been parsed yet, but it will
     for added / updated triggers
     """
+
     formatting = {
         "correspondent": correspondent_name,
         "document_type": doc_type_name,
@@ -52,4 +79,28 @@ def parse_w_workflow_placeholders(
         formatting.update({"doc_title": doc_title})
     if doc_url is not None:
         formatting.update({"doc_url": doc_url})
-    return text.format(**formatting).strip()
+
+    logger.debug(f"Jinja Template is : {text}")
+    try:
+        template = _template_environment.from_string(
+            text,
+            template_class=Template,
+        )
+        rendered_template = template.render(formatting)
+
+        # We're good!
+        return rendered_template
+    except UndefinedError as e:
+        # The undefined class logs this already for us
+        raise e
+    except TemplateSyntaxError as e:
+        logger.warning(f"Template syntax error in title generation: {e}")
+    except SecurityError as e:
+        logger.warning(f"Template attempted restricted operation: {e}")
+    except Exception as e:
+        logger.warning(f"Unknown error in title generation: {e}")
+        logger.warning(
+            f"Invalid title format '{text}', workflow not applied: {e}",
+        )
+        raise e
+    return None
index 6709155d9e32b7d985774409d60c6040ba2b40ae..6387b5e95b0189873d69e31bddf6092f2245d3e1 100644 (file)
@@ -304,22 +304,6 @@ class TestConsumer(
         self.assertEqual(document.title, "Override Title")
         self._assert_first_last_send_progress()
 
-    def testOverrideTitleInvalidPlaceholders(self):
-        with self.assertLogs("paperless.consumer", level="ERROR") as cm:
-            with self.get_consumer(
-                self.get_test_file(),
-                DocumentMetadataOverrides(title="Override {correspondent]"),
-            ) as consumer:
-                consumer.run()
-
-                document = Document.objects.first()
-
-            self.assertIsNotNone(document)
-
-            self.assertEqual(document.title, "sample")
-            expected_str = "Error occurred parsing title override 'Override {correspondent]', falling back to original"
-            self.assertIn(expected_str, cm.output[0])
-
     def testOverrideCorrespondent(self):
         c = Correspondent.objects.create(name="test")
 
@@ -437,7 +421,7 @@ class TestConsumer(
             DocumentMetadataOverrides(
                 correspondent_id=c.pk,
                 document_type_id=dt.pk,
-                title="{correspondent}{document_type} {added_month}-{added_year_short}",
+                title="{{correspondent}}{{document_type}} {{added_month}}-{{added_year_short}}",
             ),
         ) as consumer:
             consumer.run()
index 9e3274dc4371778113ae3de1d49821c6fcfd226a..62ca52d71e9fe4cd35795c334b033fbaa124be85 100644 (file)
@@ -23,7 +23,6 @@ from documents.models import Document
 from documents.models import DocumentType
 from documents.models import StoragePath
 from documents.tasks import empty_trash
-from documents.templating.filepath import localize_date
 from documents.tests.factories import DocumentFactory
 from documents.tests.utils import DirectoriesMixin
 from documents.tests.utils import FileSystemAssertsMixin
@@ -1591,166 +1590,13 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
             )
 
 
-class TestDateLocalization:
+class TestPathDateLocalization:
     """
     Groups all tests related to the `localize_date` function.
     """
 
     TEST_DATE = datetime.date(2023, 10, 26)
 
-    TEST_DATETIME = datetime.datetime(
-        2023,
-        10,
-        26,
-        14,
-        30,
-        5,
-        tzinfo=datetime.timezone.utc,
-    )
-
-    @pytest.mark.parametrize(
-        "value, format_style, locale_str, expected_output",
-        [
-            pytest.param(
-                TEST_DATE,
-                "EEEE, MMM d, yyyy",
-                "en_US",
-                "Thursday, Oct 26, 2023",
-                id="date-en_US-custom",
-            ),
-            pytest.param(
-                TEST_DATE,
-                "dd.MM.yyyy",
-                "de_DE",
-                "26.10.2023",
-                id="date-de_DE-custom",
-            ),
-            # German weekday and month name translation
-            pytest.param(
-                TEST_DATE,
-                "EEEE",
-                "de_DE",
-                "Donnerstag",
-                id="weekday-de_DE",
-            ),
-            pytest.param(
-                TEST_DATE,
-                "MMMM",
-                "de_DE",
-                "Oktober",
-                id="month-de_DE",
-            ),
-            # French weekday and month name translation
-            pytest.param(
-                TEST_DATE,
-                "EEEE",
-                "fr_FR",
-                "jeudi",
-                id="weekday-fr_FR",
-            ),
-            pytest.param(
-                TEST_DATE,
-                "MMMM",
-                "fr_FR",
-                "octobre",
-                id="month-fr_FR",
-            ),
-        ],
-    )
-    def test_localize_date_with_date_objects(
-        self,
-        value: datetime.date,
-        format_style: str,
-        locale_str: str,
-        expected_output: str,
-    ):
-        """
-        Tests `localize_date` with `date` objects across different locales and formats.
-        """
-        assert localize_date(value, format_style, locale_str) == expected_output
-
-    @pytest.mark.parametrize(
-        "value, format_style, locale_str, expected_output",
-        [
-            pytest.param(
-                TEST_DATETIME,
-                "yyyy.MM.dd G 'at' HH:mm:ss zzz",
-                "en_US",
-                "2023.10.26 AD at 14:30:05 UTC",
-                id="datetime-en_US-custom",
-            ),
-            pytest.param(
-                TEST_DATETIME,
-                "dd.MM.yyyy",
-                "fr_FR",
-                "26.10.2023",
-                id="date-fr_FR-custom",
-            ),
-            # Spanish weekday and month translation
-            pytest.param(
-                TEST_DATETIME,
-                "EEEE",
-                "es_ES",
-                "jueves",
-                id="weekday-es_ES",
-            ),
-            pytest.param(
-                TEST_DATETIME,
-                "MMMM",
-                "es_ES",
-                "octubre",
-                id="month-es_ES",
-            ),
-            # Italian weekday and month translation
-            pytest.param(
-                TEST_DATETIME,
-                "EEEE",
-                "it_IT",
-                "giovedì",
-                id="weekday-it_IT",
-            ),
-            pytest.param(
-                TEST_DATETIME,
-                "MMMM",
-                "it_IT",
-                "ottobre",
-                id="month-it_IT",
-            ),
-        ],
-    )
-    def test_localize_date_with_datetime_objects(
-        self,
-        value: datetime.datetime,
-        format_style: str,
-        locale_str: str,
-        expected_output: str,
-    ):
-        # To handle the non-breaking space in French and other locales
-        result = localize_date(value, format_style, locale_str)
-        assert result.replace("\u202f", " ") == expected_output.replace("\u202f", " ")
-
-    @pytest.mark.parametrize(
-        "invalid_value",
-        [
-            "2023-10-26",
-            1698330605,
-            None,
-            [],
-            {},
-        ],
-    )
-    def test_localize_date_raises_type_error_for_invalid_input(self, invalid_value):
-        with pytest.raises(TypeError) as excinfo:
-            localize_date(invalid_value, "medium", "en_US")
-
-        assert f"Unsupported type {type(invalid_value)}" in str(excinfo.value)
-
-    def test_localize_date_raises_error_for_invalid_locale(self):
-        with pytest.raises(ValueError) as excinfo:
-            localize_date(self.TEST_DATE, "medium", "invalid_locale_code")
-
-        assert "Invalid locale identifier" in str(excinfo.value)
-
     @pytest.mark.django_db
     @pytest.mark.parametrize(
         "filename_format,expected_filename",
diff --git a/src/documents/tests/test_filters.py b/src/documents/tests/test_filters.py
new file mode 100644 (file)
index 0000000..6283bed
--- /dev/null
@@ -0,0 +1,296 @@
+import datetime
+from typing import Any
+from typing import Literal
+
+import pytest
+
+from documents.templating.filters import localize_date
+
+
+class TestDateLocalization:
+    """
+    Groups all tests related to the `localize_date` function.
+    """
+
+    TEST_DATE = datetime.date(2023, 10, 26)
+
+    TEST_DATETIME = datetime.datetime(
+        2023,
+        10,
+        26,
+        14,
+        30,
+        5,
+        tzinfo=datetime.timezone.utc,
+    )
+
+    TEST_DATETIME_STRING: str = "2023-10-26T14:30:05+00:00"
+
+    TEST_DATE_STRING: str = "2023-10-26"
+
+    @pytest.mark.parametrize(
+        "value, format_style, locale_str, expected_output",
+        [
+            pytest.param(
+                TEST_DATE,
+                "EEEE, MMM d, yyyy",
+                "en_US",
+                "Thursday, Oct 26, 2023",
+                id="date-en_US-custom",
+            ),
+            pytest.param(
+                TEST_DATE,
+                "dd.MM.yyyy",
+                "de_DE",
+                "26.10.2023",
+                id="date-de_DE-custom",
+            ),
+            # German weekday and month name translation
+            pytest.param(
+                TEST_DATE,
+                "EEEE",
+                "de_DE",
+                "Donnerstag",
+                id="weekday-de_DE",
+            ),
+            pytest.param(
+                TEST_DATE,
+                "MMMM",
+                "de_DE",
+                "Oktober",
+                id="month-de_DE",
+            ),
+            # French weekday and month name translation
+            pytest.param(
+                TEST_DATE,
+                "EEEE",
+                "fr_FR",
+                "jeudi",
+                id="weekday-fr_FR",
+            ),
+            pytest.param(
+                TEST_DATE,
+                "MMMM",
+                "fr_FR",
+                "octobre",
+                id="month-fr_FR",
+            ),
+        ],
+    )
+    def test_localize_date_with_date_objects(
+        self,
+        value: datetime.date,
+        format_style: str,
+        locale_str: str,
+        expected_output: str,
+    ):
+        """
+        Tests `localize_date` with `date` objects across different locales and formats.
+        """
+        assert localize_date(value, format_style, locale_str) == expected_output
+
+    @pytest.mark.parametrize(
+        "value, format_style, locale_str, expected_output",
+        [
+            pytest.param(
+                TEST_DATETIME,
+                "yyyy.MM.dd G 'at' HH:mm:ss zzz",
+                "en_US",
+                "2023.10.26 AD at 14:30:05 UTC",
+                id="datetime-en_US-custom",
+            ),
+            pytest.param(
+                TEST_DATETIME,
+                "dd.MM.yyyy",
+                "fr_FR",
+                "26.10.2023",
+                id="date-fr_FR-custom",
+            ),
+            # Spanish weekday and month translation
+            pytest.param(
+                TEST_DATETIME,
+                "EEEE",
+                "es_ES",
+                "jueves",
+                id="weekday-es_ES",
+            ),
+            pytest.param(
+                TEST_DATETIME,
+                "MMMM",
+                "es_ES",
+                "octubre",
+                id="month-es_ES",
+            ),
+            # Italian weekday and month translation
+            pytest.param(
+                TEST_DATETIME,
+                "EEEE",
+                "it_IT",
+                "giovedì",
+                id="weekday-it_IT",
+            ),
+            pytest.param(
+                TEST_DATETIME,
+                "MMMM",
+                "it_IT",
+                "ottobre",
+                id="month-it_IT",
+            ),
+        ],
+    )
+    def test_localize_date_with_datetime_objects(
+        self,
+        value: datetime.datetime,
+        format_style: str,
+        locale_str: str,
+        expected_output: str,
+    ):
+        # To handle the non-breaking space in French and other locales
+        result = localize_date(value, format_style, locale_str)
+        assert result.replace("\u202f", " ") == expected_output.replace("\u202f", " ")
+
+    @pytest.mark.parametrize(
+        "invalid_value",
+        [
+            1698330605,
+            None,
+            [],
+            {},
+        ],
+    )
+    def test_localize_date_raises_type_error_for_invalid_input(
+        self,
+        invalid_value: None | list[object] | dict[Any, Any] | Literal[1698330605],
+    ):
+        with pytest.raises(TypeError) as excinfo:
+            localize_date(invalid_value, "medium", "en_US")
+
+        assert f"Unsupported type {type(invalid_value)}" in str(excinfo.value)
+
+    def test_localize_date_raises_error_for_invalid_locale(self):
+        with pytest.raises(ValueError) as excinfo:
+            localize_date(self.TEST_DATE, "medium", "invalid_locale_code")
+
+        assert "Invalid locale identifier" in str(excinfo.value)
+
+    @pytest.mark.parametrize(
+        "value, format_style, locale_str, expected_output",
+        [
+            pytest.param(
+                TEST_DATETIME_STRING,
+                "EEEE, MMM d, yyyy",
+                "en_US",
+                "Thursday, Oct 26, 2023",
+                id="date-en_US-custom",
+            ),
+            pytest.param(
+                TEST_DATETIME_STRING,
+                "dd.MM.yyyy",
+                "de_DE",
+                "26.10.2023",
+                id="date-de_DE-custom",
+            ),
+            # German weekday and month name translation
+            pytest.param(
+                TEST_DATETIME_STRING,
+                "EEEE",
+                "de_DE",
+                "Donnerstag",
+                id="weekday-de_DE",
+            ),
+            pytest.param(
+                TEST_DATETIME_STRING,
+                "MMMM",
+                "de_DE",
+                "Oktober",
+                id="month-de_DE",
+            ),
+            # French weekday and month name translation
+            pytest.param(
+                TEST_DATETIME_STRING,
+                "EEEE",
+                "fr_FR",
+                "jeudi",
+                id="weekday-fr_FR",
+            ),
+            pytest.param(
+                TEST_DATETIME_STRING,
+                "MMMM",
+                "fr_FR",
+                "octobre",
+                id="month-fr_FR",
+            ),
+        ],
+    )
+    def test_localize_date_with_datetime_string(
+        self,
+        value: str,
+        format_style: str,
+        locale_str: str,
+        expected_output: str,
+    ):
+        """
+        Tests `localize_date` with `date` string across different locales and formats.
+        """
+        assert localize_date(value, format_style, locale_str) == expected_output
+
+    @pytest.mark.parametrize(
+        "value, format_style, locale_str, expected_output",
+        [
+            pytest.param(
+                TEST_DATE_STRING,
+                "EEEE, MMM d, yyyy",
+                "en_US",
+                "Thursday, Oct 26, 2023",
+                id="date-en_US-custom",
+            ),
+            pytest.param(
+                TEST_DATE_STRING,
+                "dd.MM.yyyy",
+                "de_DE",
+                "26.10.2023",
+                id="date-de_DE-custom",
+            ),
+            # German weekday and month name translation
+            pytest.param(
+                TEST_DATE_STRING,
+                "EEEE",
+                "de_DE",
+                "Donnerstag",
+                id="weekday-de_DE",
+            ),
+            pytest.param(
+                TEST_DATE_STRING,
+                "MMMM",
+                "de_DE",
+                "Oktober",
+                id="month-de_DE",
+            ),
+            # French weekday and month name translation
+            pytest.param(
+                TEST_DATE_STRING,
+                "EEEE",
+                "fr_FR",
+                "jeudi",
+                id="weekday-fr_FR",
+            ),
+            pytest.param(
+                TEST_DATE_STRING,
+                "MMMM",
+                "fr_FR",
+                "octobre",
+                id="month-fr_FR",
+            ),
+        ],
+    )
+    def test_localize_date_with_date_string(
+        self,
+        value: str,
+        format_style: str,
+        locale_str: str,
+        expected_output: str,
+    ):
+        """
+        Tests `localize_date` with `date` string across different locales and formats.
+        """
+        assert localize_date(value, format_style, locale_str) == expected_output
index 08bcc1f782eeb80298a3fdaa90aae66295ea1cab..8c5e8ec9dcf5327cbef1a8a7a60644c72041c4ab 100644 (file)
@@ -1,6 +1,8 @@
+import datetime
 import shutil
 import socket
 from datetime import timedelta
+from pathlib import Path
 from typing import TYPE_CHECKING
 from unittest import mock
 
@@ -15,6 +17,7 @@ from guardian.shortcuts import get_users_with_perms
 from httpx import HTTPError
 from httpx import HTTPStatusError
 from pytest_httpx import HTTPXMock
+from rest_framework.test import APIClient
 from rest_framework.test import APITestCase
 
 from documents.signals.handlers import run_workflows
@@ -22,7 +25,7 @@ from documents.signals.handlers import send_webhook
 
 if TYPE_CHECKING:
     from django.db.models import QuerySet
-
+from pytest_django.fixtures import SettingsWrapper
 
 from documents import tasks
 from documents.data_models import ConsumableDocument
@@ -122,7 +125,7 @@ class TestWorkflows(
             filter_path=f"*/{self.dirs.scratch_dir.parts[-1]}/*",
         )
         action = WorkflowAction.objects.create(
-            assign_title="Doc from {correspondent}",
+            assign_title="Doc from {{correspondent}}",
             assign_correspondent=self.c,
             assign_document_type=self.dt,
             assign_storage_path=self.sp,
@@ -241,7 +244,7 @@ class TestWorkflows(
         )
 
         action = WorkflowAction.objects.create(
-            assign_title="Doc from {correspondent}",
+            assign_title="Doc from {{correspondent}}",
             assign_correspondent=self.c,
             assign_document_type=self.dt,
             assign_storage_path=self.sp,
@@ -892,7 +895,7 @@ class TestWorkflows(
             filter_filename="*sample*",
         )
         action = WorkflowAction.objects.create(
-            assign_title="Doc created in {created_year}",
+            assign_title="Doc created in {{created_year}}",
             assign_correspondent=self.c2,
             assign_document_type=self.dt,
             assign_storage_path=self.sp,
@@ -1155,7 +1158,7 @@ class TestWorkflows(
         WHEN:
             - File that matches is added
         THEN:
-            - Title is not updated, error is output
+            - Title is updated but the placeholder isn't replaced
         """
         trigger = WorkflowTrigger.objects.create(
             type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
@@ -1181,15 +1184,12 @@ class TestWorkflows(
             created=created,
         )
 
-        with self.assertLogs("paperless.handlers", level="ERROR") as cm:
-            document_consumption_finished.send(
-                sender=self.__class__,
-                document=doc,
-            )
-            expected_str = f"Error occurred parsing title assignment '{action.assign_title}', falling back to original"
-            self.assertIn(expected_str, cm.output[0])
+        document_consumption_finished.send(
+            sender=self.__class__,
+            document=doc,
+        )
 
-        self.assertEqual(doc.title, "sample test")
+        self.assertEqual(doc.title, "Doc {created_year]")
 
     def test_document_updated_workflow(self):
         trigger = WorkflowTrigger.objects.create(
@@ -1223,6 +1223,45 @@ class TestWorkflows(
 
         self.assertEqual(doc.custom_fields.all().count(), 1)
 
+    def test_document_consumption_workflow_month_placeholder_addded(self):
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+            sources=f"{DocumentSource.ApiUpload}",
+            filter_filename="simple*",
+        )
+
+        action = WorkflowAction.objects.create(
+            assign_title="Doc added in {{added_month_name_short}}",
+        )
+
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        superuser = User.objects.create_superuser("superuser")
+        self.client.force_authenticate(user=superuser)
+        test_file = shutil.copy(
+            self.SAMPLE_DIR / "simple.pdf",
+            self.dirs.scratch_dir / "simple.pdf",
+        )
+        with mock.patch("documents.tasks.ProgressManager", DummyProgressManager):
+            tasks.consume_file(
+                ConsumableDocument(
+                    source=DocumentSource.ApiUpload,
+                    original_file=test_file,
+                ),
+                None,
+            )
+            document = Document.objects.first()
+            self.assertRegex(
+                document.title,
+                r"Doc added in \w{3,}",
+            )  # Match any 3-letter month name
+
     def test_document_updated_workflow_existing_custom_field(self):
         """
         GIVEN:
@@ -2035,7 +2074,7 @@ class TestWorkflows(
             filter_filename="*simple*",
         )
         action = WorkflowAction.objects.create(
-            assign_title="Doc from {correspondent}",
+            assign_title="Doc from {{correspondent}}",
             assign_correspondent=self.c,
             assign_document_type=self.dt,
             assign_storage_path=self.sp,
@@ -2614,7 +2653,7 @@ class TestWorkflows(
         )
         webhook_action = WorkflowActionWebhook.objects.create(
             use_params=False,
-            body="Test message: {doc_url}",
+            body="Test message: {{doc_url}}",
             url="http://paperless-ngx.com",
             include_document=False,
         )
@@ -2673,7 +2712,7 @@ class TestWorkflows(
         )
         webhook_action = WorkflowActionWebhook.objects.create(
             use_params=False,
-            body="Test message: {doc_url}",
+            body="Test message: {{doc_url}}",
             url="http://paperless-ngx.com",
             include_document=True,
         )
@@ -3130,3 +3169,234 @@ class TestWebhookSecurity:
         req = httpx_mock.get_request()
         assert req.headers["Host"] == "paperless-ngx.com"
         assert "evil.test" not in req.headers.get("Host", "")
+
+
+@pytest.mark.django_db
+class TestDateWorkflowLocalization(
+    SampleDirMixin,
+):
+    """Test cases for workflows that use date localization in templates."""
+
+    TEST_DATETIME = datetime.datetime(
+        2023,
+        6,
+        26,
+        14,
+        30,
+        5,
+        tzinfo=datetime.timezone.utc,
+    )
+
+    @pytest.mark.parametrize(
+        "title_template,expected_title",
+        [
+            pytest.param(
+                "Created at {{ created | localize_date('MMMM', 'es_ES') }}",
+                "Created at junio",
+                id="spanish_month",
+            ),
+            pytest.param(
+                "Created at {{ created | localize_date('MMMM', 'de_DE') }}",
+                "Created at Juni",  # codespell:ignore
+                id="german_month",
+            ),
+            pytest.param(
+                "Created at {{ created | localize_date('dd/MM/yyyy', 'en_GB') }}",
+                "Created at 26/06/2023",
+                id="british_date_format",
+            ),
+        ],
+    )
+    def test_document_added_workflow_localization(
+        self,
+        title_template: str,
+        expected_title: str,
+    ):
+        """
+        GIVEN:
+            - Document added workflow with title template using localize_date filter
+        WHEN:
+            - Document is consumed
+        THEN:
+            - Document title is set with localized date
+        """
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
+            filter_filename="*sample*",
+        )
+
+        action = WorkflowAction.objects.create(
+            assign_title=title_template,
+        )
+
+        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=None,
+            original_filename="sample.pdf",
+            created=self.TEST_DATETIME,
+        )
+
+        document_consumption_finished.send(
+            sender=self.__class__,
+            document=doc,
+        )
+
+        doc.refresh_from_db()
+        assert doc.title == expected_title
+
+    @pytest.mark.parametrize(
+        "title_template,expected_title",
+        [
+            pytest.param(
+                "Created at {{ created | localize_date('MMMM', 'es_ES') }}",
+                "Created at junio",
+                id="spanish_month",
+            ),
+            pytest.param(
+                "Created at {{ created | localize_date('MMMM', 'de_DE') }}",
+                "Created at Juni",  # codespell:ignore
+                id="german_month",
+            ),
+            pytest.param(
+                "Created at {{ created | localize_date('dd/MM/yyyy', 'en_GB') }}",
+                "Created at 26/06/2023",
+                id="british_date_format",
+            ),
+        ],
+    )
+    def test_document_updated_workflow_localization(
+        self,
+        title_template: str,
+        expected_title: str,
+    ):
+        """
+        GIVEN:
+            - Document updated workflow with title template using localize_date filter
+        WHEN:
+            - Document is updated via API
+        THEN:
+            - Document title is set with localized date
+        """
+        # Setup test data
+        dt = DocumentType.objects.create(name="DocType Name")
+        c = Correspondent.objects.create(name="Correspondent Name")
+
+        client = APIClient()
+        superuser = User.objects.create_superuser("superuser")
+        client.force_authenticate(user=superuser)
+
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+            filter_has_document_type=dt,
+        )
+
+        doc = Document.objects.create(
+            title="sample test",
+            correspondent=c,
+            original_filename="sample.pdf",
+            created=self.TEST_DATETIME,
+        )
+
+        action = WorkflowAction.objects.create(
+            assign_title=title_template,
+        )
+
+        workflow = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        workflow.triggers.add(trigger)
+        workflow.actions.add(action)
+        workflow.save()
+
+        client.patch(
+            f"/api/documents/{doc.id}/",
+            {"document_type": dt.id},
+            format="json",
+        )
+
+        doc.refresh_from_db()
+        assert doc.title == expected_title
+
+    @pytest.mark.parametrize(
+        "title_template,expected_title",
+        [
+            pytest.param(
+                "Added at {{ added | localize_date('MMMM', 'es_ES') }}",
+                "Added at junio",
+                id="spanish_month",
+            ),
+            pytest.param(
+                "Added at {{ added | localize_date('MMMM', 'de_DE') }}",
+                "Added at Juni",  # codespell:ignore
+                id="german_month",
+            ),
+            pytest.param(
+                "Added at {{ added | localize_date('dd/MM/yyyy', 'en_GB') }}",
+                "Added at 26/06/2023",
+                id="british_date_format",
+            ),
+        ],
+    )
+    def test_document_consumption_workflow_localization(
+        self,
+        tmp_path: Path,
+        settings: SettingsWrapper,
+        title_template: str,
+        expected_title: str,
+    ):
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+            sources=f"{DocumentSource.ApiUpload}",
+            filter_filename="simple*",
+        )
+
+        test_file = shutil.copy(
+            self.SAMPLE_DIR / "simple.pdf",
+            tmp_path / "simple.pdf",
+        )
+
+        action = WorkflowAction.objects.create(
+            assign_title=title_template,
+        )
+
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        settings.SCRATCH_DIR = tmp_path / "scratch"
+        (tmp_path / "scratch").mkdir(parents=True, exist_ok=True)
+
+        # Temporarily override "now" for the environment so templates using
+        # added/created placeholders behave as if it's a different system date.
+        with (
+            mock.patch(
+                "documents.tasks.ProgressManager",
+                DummyProgressManager,
+            ),
+            mock.patch(
+                "django.utils.timezone.now",
+                return_value=self.TEST_DATETIME,
+            ),
+        ):
+            tasks.consume_file(
+                ConsumableDocument(
+                    source=DocumentSource.ApiUpload,
+                    original_file=test_file,
+                ),
+                None,
+            )
+            document = Document.objects.first()
+            assert document.title == expected_title