mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-12 00:19:48 +00:00
Compare commits
4 Commits
feature-ch
...
feature-lo
Author | SHA1 | Date | |
---|---|---|---|
![]() |
611adb4ffc | ||
![]() |
397bb8f6ee | ||
![]() |
5821816ebb | ||
![]() |
b1a84c65ed |
@@ -1,3 +0,0 @@
|
|||||||
[codespell]
|
|
||||||
write-changes = True
|
|
||||||
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure,assertIn
|
|
@@ -31,7 +31,6 @@ repos:
|
|||||||
rev: v2.4.1
|
rev: v2.4.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)|(^src/documents/tests/samples/)"
|
|
||||||
exclude_types:
|
exclude_types:
|
||||||
- pofile
|
- pofile
|
||||||
- json
|
- json
|
||||||
|
@@ -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
|
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.
|
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
|
#### 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
|
- `{{ 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
|
||||||
|
@@ -15,6 +15,7 @@ classifiers = [
|
|||||||
# This will allow testing to not install a webserver, mysql, etc
|
# This will allow testing to not install a webserver, mysql, etc
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"babel>=2.17",
|
||||||
"bleach~=6.2.0",
|
"bleach~=6.2.0",
|
||||||
"celery[redis]~=5.5.1",
|
"celery[redis]~=5.5.1",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
@@ -35,7 +36,7 @@ dependencies = [
|
|||||||
"django-guardian~=3.0.3",
|
"django-guardian~=3.0.3",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.15",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
"djangorestframework-guardian~=0.4.0",
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular~=0.28",
|
||||||
"drf-spectacular-sidecar~=2025.8.1",
|
"drf-spectacular-sidecar~=2025.8.1",
|
||||||
@@ -221,6 +222,11 @@ lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
|
|||||||
]
|
]
|
||||||
lint.isort.force-single-line = true
|
lint.isort.force-single-line = true
|
||||||
|
|
||||||
|
[tool.codespell]
|
||||||
|
write-changes = true
|
||||||
|
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]
|
[tool.pytest.ini_options]
|
||||||
minversion = "8.0"
|
minversion = "8.0"
|
||||||
pythonpath = [
|
pythonpath = [
|
||||||
|
@@ -2,10 +2,13 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from datetime import date
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import PurePath
|
from pathlib import PurePath
|
||||||
|
|
||||||
import pathvalidate
|
import pathvalidate
|
||||||
|
from babel import Locale
|
||||||
|
from babel import dates
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.dateparse import parse_date
|
from django.utils.dateparse import parse_date
|
||||||
from django.utils.text import slugify as django_slugify
|
from django.utils.text import slugify as django_slugify
|
||||||
@@ -90,19 +93,51 @@ def get_cf_value(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_template_environment.filters["get_cf_value"] = get_cf_value
|
|
||||||
|
|
||||||
|
|
||||||
def format_datetime(value: str | datetime, format: str) -> str:
|
def format_datetime(value: str | datetime, format: str) -> str:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = parse_date(value)
|
value = parse_date(value)
|
||||||
return value.strftime(format=format)
|
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["datetime"] = format_datetime
|
||||||
|
|
||||||
_template_environment.filters["slugify"] = django_slugify
|
_template_environment.filters["slugify"] = django_slugify
|
||||||
|
|
||||||
|
_template_environment.filters["localize_date"] = localize_date
|
||||||
|
|
||||||
|
|
||||||
def create_dummy_document():
|
def create_dummy_document():
|
||||||
"""
|
"""
|
||||||
|
@@ -4,6 +4,7 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from auditlog.context import disable_auditlog
|
from auditlog.context import disable_auditlog
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@@ -22,6 +23,7 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.tasks import empty_trash
|
from documents.tasks import empty_trash
|
||||||
|
from documents.templating.filepath import localize_date
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from documents.tests.utils import FileSystemAssertsMixin
|
||||||
|
|
||||||
@@ -1586,3 +1588,164 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
|
|||||||
generate_filename(doc),
|
generate_filename(doc),
|
||||||
Path("brussels-belgium/some-title-with-special-characters.pdf"),
|
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)
|
||||||
|
Reference in New Issue
Block a user