Repository: paperless-ngx/paperless-ngx

Issue:
[BUG] Workflows: spaces inside placeholder tags break consumption
### Description

Using e.g. `{ added_month }` instead of `{added_month}` inside a workflow action breaks the document consumption. Not a big issue, but maybe the placeholder name could be trimmed for more resilience.

### Steps to reproduce

1. Create a workflow like this:

![grafik](https://github.com/paperless-ngx/paperless-ngx/assets/34041551/f06bf983-88f0-47a6-9dbf-0cac8cae8982)

2. Trigger the workflow

### Webserver logs

```bash
[2024-01-08 16:48:49,231] [ERROR] [celery.app.trace] Task documents.tasks.consume_file[3cdf6c1b-eb66-47bf-9cf8-3d9959ff970c] raised unexpecte │
│ Traceback (most recent call last):                                                                                                            │
│   File "/usr/local/lib/python3.11/site-packages/asgiref/sync.py", line 349, in main_wrap                                                      │
│     raise exc_info[1]                                                                                                                         │
│   File "/usr/src/paperless/src/documents/consumer.py", line 514, in try_consume_file                                                          │
│     document_consumption_finished.send(                                                                                                       │
│   File "/usr/local/lib/python3.11/site-packages/django/dispatch/dispatcher.py", line 176, in send                                             │
│     return [                                                                                                                                  │
│            ^                                                                                                                                  │
│   File "/usr/local/lib/python3.11/site-packages/django/dispatch/dispatcher.py", line 177, in <listcomp>                                       │
│     (receiver, receiver(signal=self, sender=sender, **named))                                                                                 │
│                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                  │
│   File "/usr/src/paperless/src/documents/signals/handlers.py", line 523, in run_workflow_added                                                │
│     run_workflow(                                                                                                                             │
│   File "/usr/src/paperless/src/documents/signals/handlers.py", line 573, in run_workflow                                                      │
│     document.title = parse_doc_title_w_placeholders(                                                                                          │
│                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                          │
│   File "/usr/src/paperless/src/documents/consumer.py", line 891, in parse_doc_title_w_placeholders                                            │
│     return title.format(**formatting).strip()                                                                                                 │
│            ^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                                         │
│ KeyError: ' added_month '                                                                                                                     │
│                                                                                                                                               │
│ The above exception was the direct cause of the following exception:                                                                          │
│                                                                                                                                               │
│ Traceback (most recent call last):                                                                                                            │
│   File "/usr/local/lib/python3.11/site-packages/celery/app/trace.py", line 477, in trace_task                                                 │
│     R = retval = fun(*args, **kwargs)                                                                                                         │
│                  ^^^^^^^^^^^^^^^^^^^^                                                                                                         │
│   File "/usr/local/lib/python3.11/site-packages/celery/app/trace.py", line 760, in __protected_call__                                         │
│     return self.run(*args, **kwargs)                                                                                                          │
│            ^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                                          │
│   File "/usr/src/paperless/src/documents/tasks.py", line 171, in consume_file                                                                 │
│     document = Consumer().try_consume_file(                                                                                                   │
│                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                                   │
│   File "/usr/src/paperless/src/documents/consumer.py", line 577, in try_consume_file                                                          │
│     self._fail(                                                                                                                               │
│   File "/usr/src/paperless/src/documents/consumer.py", line 116, in _fail                                                                     │
│     raise ConsumerError(f"{self.filename}: {log_message or message}") from exception                                                          │
│ documents.consumer.ConsumerError: Test.pdf: The following error occurred while storing document Test.pdf after parsing: ' added_month '       │
```


### Browser logs

_No response_

### Paperless-ngx version

2.3.2

### Host OS

Docker

### Installation method

Docker - official image

### Browser

Firefox

### Configuration changes

_No response_

### Other

_No response_

### Please confirm the following

- [X] I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
- [X] I have already searched for relevant existing issues and discussions before opening this report.
- [X] I have updated the title field above with a concise description.


Discussion:

Resolving patch:
diff --git a/src/documents/consumer.py b/src/documents/consumer.py
--- a/src/documents/consumer.py
+++ b/src/documents/consumer.py
@@ -726,12 +726,17 @@ def _store(
 
         storage_type = Document.STORAGE_TYPE_UNENCRYPTED
 
+        title = file_info.title[:127]
+        if self.override_title is not None:
+            try:
+                title = self._parse_title_placeholders(self.override_title)
+            except Exception as e:
+                self.log.error(
+                    f"Error occurred parsing title override '{self.override_title}', falling back to original. Exception: {e}",
+                )
+
         document = Document.objects.create(
-            title=(
-                self._parse_title_placeholders(self.override_title)
-                if self.override_title is not None
-                else file_info.title
-            )[:127],
+            title=title,
             content=text,
             mime_type=mime_type,
             checksum=hashlib.md5(self.working_copy.read_bytes()).hexdigest(),
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -1386,12 +1386,38 @@ class Meta:
 
     def validate(self, attrs):
         # Empty strings treated as None to avoid unexpected behavior
-        if (
-            "assign_title" in attrs
-            and attrs["assign_title"] is not None
-            and len(attrs["assign_title"]) == 0
-        ):
-            attrs["assign_title"] = None
+        if "assign_title" in attrs:
+            if attrs["assign_title"] is not None and len(attrs["assign_title"]) == 0:
+                attrs["assign_title"] = None
+            else:
+                try:
+                    # test against all placeholders, see consumer.py `parse_doc_title_w_placeholders`
+                    attrs["assign_title"].format(
+                        correspondent="",
+                        document_type="",
+                        added="",
+                        added_year="",
+                        added_year_short="",
+                        added_month="",
+                        added_month_name="",
+                        added_month_name_short="",
+                        added_day="",
+                        added_time="",
+                        owner_username="",
+                        original_filename="",
+                        created="",
+                        created_year="",
+                        created_year_short="",
+                        created_month="",
+                        created_month_name="",
+                        created_month_name_short="",
+                        created_day="",
+                        created_time="",
+                    )
+                except (ValueError, KeyError) as e:
+                    raise serializers.ValidationError(
+                        {"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
+                    )
 
         return attrs
 
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -570,19 +570,27 @@ def run_workflow(
                     document.owner = action.assign_owner
 
                 if action.assign_title is not None:
-                    document.title = parse_doc_title_w_placeholders(
-                        action.assign_title,
-                        document.correspondent.name
-                        if document.correspondent is not None
-                        else "",
-                        document.document_type.name
-                        if document.document_type is not None
-                        else "",
-                        document.owner.username if document.owner is not None else "",
-                        timezone.localtime(document.added),
-                        document.original_filename,
-                        timezone.localtime(document.created),
-                    )
+                    try:
+                        document.title = parse_doc_title_w_placeholders(
+                            action.assign_title,
+                            document.correspondent.name
+                            if document.correspondent is not None
+                            else "",
+                            document.document_type.name
+                            if document.document_type is not None
+                            else "",
+                            document.owner.username
+                            if document.owner is not None
+                            else "",
+                            document.added,
+                            document.original_filename,
+                            document.created,
+                        )
+                    except Exception:
+                        logger.exception(
+                            f"Error occurred parsing title assignment '{action.assign_title}', falling back to original",
+                            extra={"group": logging_group},
+                        )
 
                 if (
                     action.assign_view_users is not None


Test patch:
diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py
--- a/src/documents/tests/test_api_workflows.py
+++ b/src/documents/tests/test_api_workflows.py
@@ -248,6 +248,45 @@ def test_api_create_invalid_workflow_trigger(self):
 
         self.assertEqual(WorkflowTrigger.objects.count(), 1)
 
+    def test_api_create_invalid_assign_title(self):
+        """
+        GIVEN:
+            - API request to create a workflow
+            - Invalid f-string for assign_title
+        WHEN:
+            - API is called
+        THEN:
+            - Correct HTTP 400 response
+            - No objects are created
+        """
+        response = self.client.post(
+            self.ENDPOINT,
+            json.dumps(
+                {
+                    "name": "Workflow 1",
+                    "order": 1,
+                    "triggers": [
+                        {
+                            "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+                        },
+                    ],
+                    "actions": [
+                        {
+                            "assign_title": "{created_year]",
+                        },
+                    ],
+                },
+            ),
+            content_type="application/json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertIn(
+            "Invalid f-string detected",
+            response.data["actions"][0]["assign_title"][0],
+        )
+
+        self.assertEqual(Workflow.objects.count(), 1)
+
     def test_api_create_workflow_trigger_action_empty_fields(self):
         """
         GIVEN:
diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py
--- a/src/documents/tests/test_consumer.py
+++ b/src/documents/tests/test_consumer.py
@@ -423,6 +423,16 @@ def testOverrideTitle(self):
         self.assertEqual(document.title, "Override Title")
         self._assert_first_last_send_progress()
 
+    def testOverrideTitleInvalidPlaceholders(self):
+        with self.assertLogs("paperless.consumer", level="ERROR") as cm:
+            document = self.consumer.try_consume_file(
+                self.get_test_file(),
+                override_title="Override {correspondent]",
+            )
+            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):
         c = Correspondent.objects.create(name="test")
 
diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py
--- a/src/documents/tests/test_workflows.py
+++ b/src/documents/tests/test_workflows.py
@@ -966,6 +966,50 @@ def test_document_added_no_match_correspondent(self):
             expected_str = f"Document correspondent {doc.correspondent} does not match {trigger.filter_has_correspondent}"
             self.assertIn(expected_str, cm.output[1])
 
+    def test_document_added_invalid_title_placeholders(self):
+        """
+        GIVEN:
+            - Existing workflow with added trigger type
+            - Assign title field has an error
+        WHEN:
+            - File that matches is added
+        THEN:
+            - Title is not updated, error is output
+        """
+        trigger = WorkflowTrigger.objects.create(
+            type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
+            filter_filename="*sample*",
+        )
+        action = WorkflowAction.objects.create(
+            assign_title="Doc {created_year]",
+        )
+        w = Workflow.objects.create(
+            name="Workflow 1",
+            order=0,
+        )
+        w.triggers.add(trigger)
+        w.actions.add(action)
+        w.save()
+
+        now = timezone.localtime(timezone.now())
+        created = now - timedelta(weeks=520)
+        doc = Document.objects.create(
+            original_filename="sample.pdf",
+            title="sample test",
+            content="Hello world bar",
+            created=created,
+        )
+
+        with self.assertLogs("paperless.handlers", level="ERROR") as cm:
+            document_consumption_finished.send(
+                sender=self.__class__,
+                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")
+
     def test_document_updated_workflow(self):
         trigger = WorkflowTrigger.objects.create(
             type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,