diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index 378ad424a..de1068864 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -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') diff --git a/docs/usage.md b/docs/usage.md index 9310d9a2f..864eab0c1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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: +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. -- `{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 +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') }} + + +{{ added | localize_date('MMMM', 'de_DE') }} + # 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 index 000000000..52b701957 --- /dev/null +++ b/src/documents/migrations/1069_migrate_workflow_title_jinja.py @@ -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, + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 72e3996d5..fc7dd3fdf 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -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 index 000000000..e99184252 --- /dev/null +++ b/src/documents/templating/environment.py @@ -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"], +) diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py index 861c11cdb..a33541095 100644 --- a/src/documents/templating/filepath.py +++ b/src/documents/templating/filepath.py @@ -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 index 000000000..e703f3a63 --- /dev/null +++ b/src/documents/templating/filters.py @@ -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 diff --git a/src/documents/templating/workflows.py b/src/documents/templating/workflows.py index e679dbaa1..25f1e57ef 100644 --- a/src/documents/templating/workflows.py +++ b/src/documents/templating/workflows.py @@ -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 diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index 6709155d9..6387b5e95 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -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() diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py index 9e3274dc4..62ca52d71 100644 --- a/src/documents/tests/test_file_handling.py +++ b/src/documents/tests/test_file_handling.py @@ -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 index 000000000..6283bed78 --- /dev/null +++ b/src/documents/tests/test_filters.py @@ -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 diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 08bcc1f78..8c5e8ec9d 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -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