mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-12 21:35:40 -05:00
Enhancement: jinja template support for workflow title assignment (#10700)
--------- Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
@@ -506,6 +506,7 @@ for the possible codes and their meanings.
|
|||||||
The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization.
|
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,
|
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`.
|
you must access the field directly, i.e. `document.created`.
|
||||||
|
An ISO string can also be provided to control the output format.
|
||||||
|
|
||||||
###### Syntax
|
###### Syntax
|
||||||
|
|
||||||
@@ -516,7 +517,7 @@ you must access the field directly, i.e. `document.created`.
|
|||||||
|
|
||||||
###### Parameters
|
###### 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
|
- `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')
|
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
||||||
|
|
||||||
|
@@ -505,35 +505,52 @@ you may want to adjust these settings to prevent abuse.
|
|||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Some workflow text can include placeholders but the available options differ depending on the type of
|
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
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)
|
||||||
applied. You can use the following placeholders with any trigger type:
|
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
||||||
|
The template is provided as a string.
|
||||||
|
|
||||||
- `{correspondent}`: assigned correspondent name
|
Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title.
|
||||||
- `{document_type}`: assigned document type name
|
|
||||||
- `{owner_username}`: assigned owner username
|
The available inputs differ depending on the type of workflow trigger.
|
||||||
- `{added}`: added datetime
|
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
||||||
- `{added_year}`: added year
|
applied. You can use the following placeholders in the template with any trigger type:
|
||||||
- `{added_year_short}`: added year
|
|
||||||
- `{added_month}`: added month
|
- `{{correspondent}}`: assigned correspondent name
|
||||||
- `{added_month_name}`: added month name
|
- `{{document_type}}`: assigned document type name
|
||||||
- `{added_month_name_short}`: added month short name
|
- `{{owner_username}}`: assigned owner username
|
||||||
- `{added_day}`: added day
|
- `{{added}}`: added datetime
|
||||||
- `{added_time}`: added time in HH:MM format
|
- `{{added_year}}`: added year
|
||||||
- `{original_filename}`: original file name without extension
|
- `{{added_year_short}}`: added year
|
||||||
- `{filename}`: current file name without extension
|
- `{{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
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
- `{created}`: created datetime
|
- `{{created}}`: created datetime
|
||||||
- `{created_year}`: created year
|
- `{{created_year}}`: created year
|
||||||
- `{created_year_short}`: created year
|
- `{{created_year_short}}`: created year
|
||||||
- `{created_month}`: created month
|
- `{{created_month}}`: created month
|
||||||
- `{created_month_name}`: created month name
|
- `{{created_month_name}}`: created month name
|
||||||
- `{created_month_name_short}`: created month short name
|
- `{created_month_name_short}}`: created month short name
|
||||||
- `{created_day}`: created day
|
- `{{created_day}}`: created day
|
||||||
- `{created_time}`: created time in HH:MM format
|
- `{{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.
|
- `{{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
|
### Workflow permissions
|
||||||
|
|
||||||
|
@@ -0,0 +1,51 @@
|
|||||||
|
# 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,
|
||||||
|
),
|
||||||
|
]
|
@@ -1207,14 +1207,12 @@ class WorkflowAction(models.Model):
|
|||||||
default=WorkflowActionType.ASSIGNMENT,
|
default=WorkflowActionType.ASSIGNMENT,
|
||||||
)
|
)
|
||||||
|
|
||||||
assign_title = models.CharField(
|
assign_title = models.TextField(
|
||||||
_("assign title"),
|
_("assign title"),
|
||||||
max_length=256,
|
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"Assign a document title, can include some placeholders, "
|
"Assign a document title, must be a Jinja2 template, see documentation.",
|
||||||
"see documentation.",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
27
src/documents/templating/environment.py
Normal file
27
src/documents/templating/environment.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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"],
|
||||||
|
)
|
@@ -2,22 +2,16 @@ 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 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.text import slugify as django_slugify
|
from django.utils.text import slugify as django_slugify
|
||||||
from jinja2 import StrictUndefined
|
from jinja2 import StrictUndefined
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from jinja2 import TemplateSyntaxError
|
from jinja2 import TemplateSyntaxError
|
||||||
from jinja2 import UndefinedError
|
from jinja2 import UndefinedError
|
||||||
from jinja2 import make_logging_undefined
|
from jinja2 import make_logging_undefined
|
||||||
from jinja2.sandbox import SandboxedEnvironment
|
|
||||||
from jinja2.sandbox import SecurityError
|
from jinja2.sandbox import SecurityError
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
@@ -27,39 +21,16 @@ 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.models import Tag
|
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")
|
logger = logging.getLogger("paperless.templating")
|
||||||
|
|
||||||
_LogStrictUndefined = make_logging_undefined(logger, StrictUndefined)
|
_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):
|
class FilePathTemplate(Template):
|
||||||
def render(self, *args, **kwargs) -> str:
|
def render(self, *args, **kwargs) -> str:
|
||||||
def clean_filepath(value: str) -> str:
|
def clean_filepath(value: str) -> str:
|
||||||
@@ -81,54 +52,7 @@ class FilePathTemplate(Template):
|
|||||||
return clean_filepath(original_render)
|
return clean_filepath(original_render)
|
||||||
|
|
||||||
|
|
||||||
def get_cf_value(
|
_template_environment.undefined = _LogStrictUndefined
|
||||||
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.filters["get_cf_value"] = get_cf_value
|
_template_environment.filters["get_cf_value"] = get_cf_value
|
||||||
|
|
||||||
|
60
src/documents/templating/filters.py
Normal file
60
src/documents/templating/filters.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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
|
@@ -1,7 +1,33 @@
|
|||||||
|
import logging
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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(
|
def parse_w_workflow_placeholders(
|
||||||
text: str,
|
text: str,
|
||||||
@@ -20,6 +46,7 @@ def parse_w_workflow_placeholders(
|
|||||||
e.g. for pre-consumption triggers created will not have been parsed yet, but it will
|
e.g. for pre-consumption triggers created will not have been parsed yet, but it will
|
||||||
for added / updated triggers
|
for added / updated triggers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
formatting = {
|
formatting = {
|
||||||
"correspondent": correspondent_name,
|
"correspondent": correspondent_name,
|
||||||
"document_type": doc_type_name,
|
"document_type": doc_type_name,
|
||||||
@@ -52,4 +79,28 @@ def parse_w_workflow_placeholders(
|
|||||||
formatting.update({"doc_title": doc_title})
|
formatting.update({"doc_title": doc_title})
|
||||||
if doc_url is not None:
|
if doc_url is not None:
|
||||||
formatting.update({"doc_url": doc_url})
|
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
|
||||||
|
@@ -304,22 +304,6 @@ class TestConsumer(
|
|||||||
self.assertEqual(document.title, "Override Title")
|
self.assertEqual(document.title, "Override Title")
|
||||||
self._assert_first_last_send_progress()
|
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):
|
def testOverrideCorrespondent(self):
|
||||||
c = Correspondent.objects.create(name="test")
|
c = Correspondent.objects.create(name="test")
|
||||||
|
|
||||||
@@ -437,7 +421,7 @@ class TestConsumer(
|
|||||||
DocumentMetadataOverrides(
|
DocumentMetadataOverrides(
|
||||||
correspondent_id=c.pk,
|
correspondent_id=c.pk,
|
||||||
document_type_id=dt.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:
|
) as consumer:
|
||||||
consumer.run()
|
consumer.run()
|
||||||
|
@@ -23,7 +23,6 @@ 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.factories import DocumentFactory
|
from documents.tests.factories import DocumentFactory
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from documents.tests.utils import FileSystemAssertsMixin
|
||||||
@@ -1591,166 +1590,13 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDateLocalization:
|
class TestPathDateLocalization:
|
||||||
"""
|
"""
|
||||||
Groups all tests related to the `localize_date` function.
|
Groups all tests related to the `localize_date` function.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TEST_DATE = datetime.date(2023, 10, 26)
|
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.django_db
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"filename_format,expected_filename",
|
"filename_format,expected_filename",
|
||||||
|
296
src/documents/tests/test_filters.py
Normal file
296
src/documents/tests/test_filters.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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
|
@@ -1,6 +1,8 @@
|
|||||||
|
import datetime
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ from guardian.shortcuts import get_users_with_perms
|
|||||||
from httpx import HTTPError
|
from httpx import HTTPError
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
from pytest_httpx import HTTPXMock
|
from pytest_httpx import HTTPXMock
|
||||||
|
from rest_framework.test import APIClient
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
@@ -22,7 +25,7 @@ from documents.signals.handlers import send_webhook
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
from documents import tasks
|
from documents import tasks
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
@@ -122,7 +125,7 @@ class TestWorkflows(
|
|||||||
filter_path=f"*/{self.dirs.scratch_dir.parts[-1]}/*",
|
filter_path=f"*/{self.dirs.scratch_dir.parts[-1]}/*",
|
||||||
)
|
)
|
||||||
action = WorkflowAction.objects.create(
|
action = WorkflowAction.objects.create(
|
||||||
assign_title="Doc from {correspondent}",
|
assign_title="Doc from {{correspondent}}",
|
||||||
assign_correspondent=self.c,
|
assign_correspondent=self.c,
|
||||||
assign_document_type=self.dt,
|
assign_document_type=self.dt,
|
||||||
assign_storage_path=self.sp,
|
assign_storage_path=self.sp,
|
||||||
@@ -241,7 +244,7 @@ class TestWorkflows(
|
|||||||
)
|
)
|
||||||
|
|
||||||
action = WorkflowAction.objects.create(
|
action = WorkflowAction.objects.create(
|
||||||
assign_title="Doc from {correspondent}",
|
assign_title="Doc from {{correspondent}}",
|
||||||
assign_correspondent=self.c,
|
assign_correspondent=self.c,
|
||||||
assign_document_type=self.dt,
|
assign_document_type=self.dt,
|
||||||
assign_storage_path=self.sp,
|
assign_storage_path=self.sp,
|
||||||
@@ -892,7 +895,7 @@ class TestWorkflows(
|
|||||||
filter_filename="*sample*",
|
filter_filename="*sample*",
|
||||||
)
|
)
|
||||||
action = WorkflowAction.objects.create(
|
action = WorkflowAction.objects.create(
|
||||||
assign_title="Doc created in {created_year}",
|
assign_title="Doc created in {{created_year}}",
|
||||||
assign_correspondent=self.c2,
|
assign_correspondent=self.c2,
|
||||||
assign_document_type=self.dt,
|
assign_document_type=self.dt,
|
||||||
assign_storage_path=self.sp,
|
assign_storage_path=self.sp,
|
||||||
@@ -1155,7 +1158,7 @@ class TestWorkflows(
|
|||||||
WHEN:
|
WHEN:
|
||||||
- File that matches is added
|
- File that matches is added
|
||||||
THEN:
|
THEN:
|
||||||
- Title is not updated, error is output
|
- Title is updated but the placeholder isn't replaced
|
||||||
"""
|
"""
|
||||||
trigger = WorkflowTrigger.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
@@ -1181,15 +1184,12 @@ class TestWorkflows(
|
|||||||
created=created,
|
created=created,
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertLogs("paperless.handlers", level="ERROR") as cm:
|
document_consumption_finished.send(
|
||||||
document_consumption_finished.send(
|
sender=self.__class__,
|
||||||
sender=self.__class__,
|
document=doc,
|
||||||
document=doc,
|
)
|
||||||
)
|
|
||||||
expected_str = f"Error occurred parsing title assignment '{action.assign_title}', falling back to original"
|
|
||||||
self.assertIn(expected_str, cm.output[0])
|
|
||||||
|
|
||||||
self.assertEqual(doc.title, "sample test")
|
self.assertEqual(doc.title, "Doc {created_year]")
|
||||||
|
|
||||||
def test_document_updated_workflow(self):
|
def test_document_updated_workflow(self):
|
||||||
trigger = WorkflowTrigger.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
@@ -1223,6 +1223,45 @@ class TestWorkflows(
|
|||||||
|
|
||||||
self.assertEqual(doc.custom_fields.all().count(), 1)
|
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):
|
def test_document_updated_workflow_existing_custom_field(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -2035,7 +2074,7 @@ class TestWorkflows(
|
|||||||
filter_filename="*simple*",
|
filter_filename="*simple*",
|
||||||
)
|
)
|
||||||
action = WorkflowAction.objects.create(
|
action = WorkflowAction.objects.create(
|
||||||
assign_title="Doc from {correspondent}",
|
assign_title="Doc from {{correspondent}}",
|
||||||
assign_correspondent=self.c,
|
assign_correspondent=self.c,
|
||||||
assign_document_type=self.dt,
|
assign_document_type=self.dt,
|
||||||
assign_storage_path=self.sp,
|
assign_storage_path=self.sp,
|
||||||
@@ -2614,7 +2653,7 @@ class TestWorkflows(
|
|||||||
)
|
)
|
||||||
webhook_action = WorkflowActionWebhook.objects.create(
|
webhook_action = WorkflowActionWebhook.objects.create(
|
||||||
use_params=False,
|
use_params=False,
|
||||||
body="Test message: {doc_url}",
|
body="Test message: {{doc_url}}",
|
||||||
url="http://paperless-ngx.com",
|
url="http://paperless-ngx.com",
|
||||||
include_document=False,
|
include_document=False,
|
||||||
)
|
)
|
||||||
@@ -2673,7 +2712,7 @@ class TestWorkflows(
|
|||||||
)
|
)
|
||||||
webhook_action = WorkflowActionWebhook.objects.create(
|
webhook_action = WorkflowActionWebhook.objects.create(
|
||||||
use_params=False,
|
use_params=False,
|
||||||
body="Test message: {doc_url}",
|
body="Test message: {{doc_url}}",
|
||||||
url="http://paperless-ngx.com",
|
url="http://paperless-ngx.com",
|
||||||
include_document=True,
|
include_document=True,
|
||||||
)
|
)
|
||||||
@@ -3130,3 +3169,234 @@ class TestWebhookSecurity:
|
|||||||
req = httpx_mock.get_request()
|
req = httpx_mock.get_request()
|
||||||
assert req.headers["Host"] == "paperless-ngx.com"
|
assert req.headers["Host"] == "paperless-ngx.com"
|
||||||
assert "evil.test" not in req.headers.get("Host", "")
|
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
|
||||||
|
Reference in New Issue
Block a user