]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: Add filter to localize dates for filepath templating (#10559)
authorTrenton H <797416+stumpylog@users.noreply.github.com>
Thu, 14 Aug 2025 16:13:08 +0000 (09:13 -0700)
committerGitHub <noreply@github.com>
Thu, 14 Aug 2025 16:13:08 +0000 (09:13 -0700)
docs/advanced_usage.md
pyproject.toml
src/documents/templating/filepath.py
src/documents/tests/test_file_handling.py
uv.lock

index aa52d2f5978f78778f76f23e369ff2a6443cafb6..76348818910be99281274e91e351d16d24d4636d 100644 (file)
@@ -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
+<!-- Basic usage -->
+{{ custom_fields | get_cf_value('department') }}
+
+<!-- With default value -->
+{{ 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
+<!-- Format datetime object -->
+{{ created_at | format_datetime('%B %d, %Y at %I:%M %p') }}
+<!-- Output: "January 15, 2024 at 02:30 PM" -->
+
+<!-- Format datetime string -->
+{{ "2024-01-15T14:30:00" | format_datetime('%m/%d/%Y') }}
+<!-- Output: "01/15/2024" -->
+
+<!-- Custom formatting -->
+{{ timestamp | format_datetime('%A, %B %d, %Y') }}
+<!-- Output: "Monday, January 15, 2024" -->
+```
+
+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
+<!-- Preset formats -->
+{{ created_date | localize_date('short', 'en_US') }}
+<!-- Output: "1/15/24" -->
+
+{{ created_date | localize_date('medium', 'en_US') }}
+<!-- Output: "Jan 15, 2024" -->
+
+{{ created_date | localize_date('long', 'en_US') }}
+<!-- Output: "January 15, 2024" -->
+
+{{ created_date | localize_date('full', 'en_US') }}
+<!-- Output: "Monday, January 15, 2024" -->
+
+<!-- Different locales -->
+{{ created_date | localize_date('medium', 'fr_FR') }}
+<!-- Output: "15 janv. 2024" -->
+
+{{ created_date | localize_date('medium', 'de_DE') }}
+<!-- Output: "15.01.2024" -->
+
+<!-- Custom patterns -->
+{{ created_date | localize_date('dd/MM/yyyy', 'en_GB') }}
+<!-- Output: "15/01/2024" -->
+```
+
+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
index 3f3846cf8f995088ba8e3e9e02436bb625a50d17..dcc1d5c2a11face2f14c6249f31eeec9636f3001 100644 (file)
@@ -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]
index 633a85cc8a5bce218bcfe76e468d86c7681bf6b5..861c11cdbe5260c278e96da1cb344fd123fbd7f5 100644 (file)
@@ -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():
     """
index d879137b9b98db69c74035f9775173d5e225057f..9e3274dc4371778113ae3de1d49821c6fcfd226a 100644 (file)
@@ -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 055fc32d584b1577b65e1afa5453af61a7cad6cc..39ae561e0fd4ecb90f9ad37b7061810a0193413f 100644 (file)
--- 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" },