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
###### 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')
#### 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
--- /dev/null
+# 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,
+ ),
+ ]
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.",
),
)
--- /dev/null
+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"],
+)
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
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:
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
--- /dev/null
+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
+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,
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,
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
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")
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()
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
)
-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",
--- /dev/null
+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
+import datetime
import shutil
import socket
from datetime import timedelta
+from pathlib import Path
from typing import TYPE_CHECKING
from unittest import mock
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
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
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,
)
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,
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,
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,
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(
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:
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,
)
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,
)
)
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,
)
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