From 2f529a95002e90350c7195106aef4a62acbd7874 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Thu, 14 Aug 2025 09:13:08 -0700 Subject: [PATCH] Feature: Add filter to localize dates for filepath templating (#10559) --- docs/advanced_usage.md | 130 ++++++++++++++ pyproject.toml | 3 +- src/documents/templating/filepath.py | 41 ++++- src/documents/tests/test_file_handling.py | 196 ++++++++++++++++++++++ uv.lock | 4 +- 5 files changed, 369 insertions(+), 5 deletions(-) diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index aa52d2f59..763488189 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -434,6 +434,136 @@ provided. The template is provided as a string, potentially multiline, and rende In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed with more complex logic. +#### Custom Jinja2 Filters + +##### Custom Field Access + +The `get_cf_value` filter retrieves a value from custom field data with optional default fallback. + +###### Syntax + +```jinja2 +{{ custom_fields | get_cf_value('field_name') }} +{{ custom_fields | get_cf_value('field_name', 'default_value') }} +``` + +###### Parameters + +- `custom_fields`: This _must_ be the provided custom field data +- `name` (str): Name of the custom field to retrieve +- `default` (str, optional): Default value to return if field is not found or has no value + +###### Returns + +- `str | None`: The field value, default value, or `None` if neither exists + +###### Examples + +```jinja2 + +{{ custom_fields | get_cf_value('department') }} + + +{{ custom_fields | get_cf_value('phone', 'Not provided') }} +``` + +##### Datetime Formatting + +The `format_datetime`filter formats a datetime string or datetime object using Python's strftime formatting. + +###### Syntax + +```jinja2 +{{ datetime_value | format_datetime('%Y-%m-%d %H:%M:%S') }} +``` + +###### Parameters + +- `value` (str | datetime): Date/time value to format (strings will be parsed automatically) +- `format` (str): Python strftime format string + +###### Returns + +- `str`: Formatted datetime string + +###### Examples + +```jinja2 + +{{ created_at | format_datetime('%B %d, %Y at %I:%M %p') }} + + + +{{ "2024-01-15T14:30:00" | format_datetime('%m/%d/%Y') }} + + + +{{ timestamp | format_datetime('%A, %B %d, %Y') }} + +``` + +See the [strftime format code documentation](https://docs.python.org/3.13/library/datetime.html#strftime-and-strptime-format-codes) +for the possible codes and their meanings. + +##### Date Localization + +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. + +###### Syntax + +```jinja2 +{{ date_value | localize_date('medium', 'en_US') }} +{{ datetime_value | localize_date('short', 'fr_FR') }} +``` + +###### Parameters + +- `value` (date | datetime): Date or datetime object 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') + +###### Returns + +- `str`: Localized, formatted date string + +###### Examples + +```jinja2 + +{{ created_date | localize_date('short', 'en_US') }} + + +{{ created_date | localize_date('medium', 'en_US') }} + + +{{ created_date | localize_date('long', 'en_US') }} + + +{{ created_date | localize_date('full', 'en_US') }} + + + +{{ created_date | localize_date('medium', 'fr_FR') }} + + +{{ created_date | localize_date('medium', 'de_DE') }} + + + +{{ created_date | localize_date('dd/MM/yyyy', 'en_GB') }} + +``` + +See the [supported format codes](https://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns) for more options. + +### Format Presets + +- **short**: Abbreviated format (e.g., "1/15/24") +- **medium**: Medium-length format (e.g., "Jan 15, 2024") +- **long**: Long format with full month name (e.g., "January 15, 2024") +- **full**: Full format including day of week (e.g., "Monday, January 15, 2024") + #### Additional Variables - `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string diff --git a/pyproject.toml b/pyproject.toml index 3f3846cf8..dcc1d5c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ # This will allow testing to not install a webserver, mysql, etc dependencies = [ + "babel>=2.17", "bleach~=6.2.0", "celery[redis]~=5.5.1", "channels~=4.2", @@ -223,7 +224,7 @@ lint.isort.force-single-line = true [tool.codespell] write-changes = true -ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn" +ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober" skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json" [tool.pytest.ini_options] diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py index 633a85cc8..861c11cdb 100644 --- a/src/documents/templating/filepath.py +++ b/src/documents/templating/filepath.py @@ -2,10 +2,13 @@ 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 @@ -90,19 +93,51 @@ def get_cf_value( return None -_template_environment.filters["get_cf_value"] = get_cf_value - - 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.filters["get_cf_value"] = get_cf_value + _template_environment.filters["datetime"] = format_datetime _template_environment.filters["slugify"] = django_slugify +_template_environment.filters["localize_date"] = localize_date + def create_dummy_document(): """ diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py index d879137b9..9e3274dc4 100644 --- a/src/documents/tests/test_file_handling.py +++ b/src/documents/tests/test_file_handling.py @@ -4,6 +4,7 @@ import tempfile from pathlib import Path from unittest import mock +import pytest from auditlog.context import disable_auditlog from django.conf import settings from django.contrib.auth.models import User @@ -22,6 +23,8 @@ 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 @@ -1586,3 +1589,196 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): generate_filename(doc), Path("brussels-belgium/some-title-with-special-characters.pdf"), ) + + +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, + ) + + @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", + [ + pytest.param( + "{{title}}_{{ document.created | localize_date('MMMM', 'es_ES')}}", + "My Document_octubre.pdf", + id="spanish_month_name", + ), + pytest.param( + "{{title}}_{{ document.created | localize_date('EEEE', 'fr_FR')}}", + "My Document_jeudi.pdf", + id="french_day_of_week", + ), + pytest.param( + "{{title}}_{{ document.created | localize_date('dd/MM/yyyy', 'en_GB')}}", + "My Document_26/10/2023.pdf", + id="uk_date_format", + ), + ], + ) + def test_localize_date_path_building(self, filename_format, expected_filename): + document = DocumentFactory.create( + title="My Document", + mime_type="application/pdf", + storage_type=Document.STORAGE_TYPE_UNENCRYPTED, + created=self.TEST_DATE, # 2023-10-26 (which is a Thursday) + ) + with override_settings(FILENAME_FORMAT=filename_format): + filename = generate_filename(document) + assert filename == Path(expected_filename) diff --git a/uv.lock b/uv.lock index 055fc32d5..39ae561e0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "sys_platform == 'darwin'", @@ -1911,6 +1911,7 @@ name = "paperless-ngx" version = "2.17.1" source = { virtual = "." } dependencies = [ + { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "bleach", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "celery", extra = ["redis"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "channels", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2044,6 +2045,7 @@ typing = [ [package.metadata] requires-dist = [ + { name = "babel", specifier = ">=2.17.0" }, { name = "bleach", specifier = "~=6.2.0" }, { name = "celery", extras = ["redis"], specifier = "~=5.5.1" }, { name = "channels", specifier = "~=4.2" },