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
# 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",
[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]
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
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():
"""
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
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
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)
version = 1
-revision = 3
+revision = 2
requires-python = ">=3.10"
resolution-markers = [
"sys_platform == 'darwin'",
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'" },
[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" },