-
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (testLoading) {
+
+ } @else if (testResult) {
+
{{testResult}}
+ } @else if (testFailed) {
+
Path test failed
+ } @else {
+
No document selected
+ }
+
+
+
+
+
+ Loading...
+
+
+ {{document.title}} ({{document.created | customDate:'shortDate'}})
+
+
+
+
+
+
+
+
+
+
@if (patternRequired) {
diff --git a/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.scss b/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.scss
index e69de29bb..3e16b3d52 100644
--- a/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.scss
+++ b/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.scss
@@ -0,0 +1,4 @@
+.accordion {
+ --bs-accordion-btn-padding-x: 0.75rem;
+ --bs-accordion-btn-padding-y: 0.375rem;
+ }
diff --git a/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts
index 051d21527..174397981 100644
--- a/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts
+++ b/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.spec.ts
@@ -1,7 +1,11 @@
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
-import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
+import {
+ NgbAccordionButton,
+ NgbActiveModal,
+ NgbModule,
+} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -14,10 +18,16 @@ import { TextAreaComponent } from '../../input/textarea/textarea.component'
import { EditDialogMode } from '../edit-dialog.component'
import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { StoragePathService } from 'src/app/services/rest/storage-path.service'
+import { DocumentService } from 'src/app/services/rest/document.service'
+import { of, throwError } from 'rxjs'
+import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
+import { By } from '@angular/platform-browser'
describe('StoragePathEditDialogComponent', () => {
let component: StoragePathEditDialogComponent
let settingsService: SettingsService
+ let documentService: DocumentService
let fixture: ComponentFixture
beforeEach(async () => {
@@ -40,6 +50,7 @@ describe('StoragePathEditDialogComponent', () => {
],
}).compileComponents()
+ documentService = TestBed.inject(DocumentService)
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
@@ -59,4 +70,87 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
+
+ it('should support test path', () => {
+ const testSpy = jest.spyOn(
+ component['service'] as StoragePathService,
+ 'testPath'
+ )
+ testSpy.mockReturnValueOnce(of('test/abc123'))
+ component.objectForm.patchValue({ path: 'test/{{title}}' })
+ fixture.detectChanges()
+ component.testPath({ id: 1 })
+ expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
+ expect(component.testResult).toBe('test/abc123')
+ expect(component.testFailed).toBeFalsy()
+
+ // test failed
+ testSpy.mockReturnValueOnce(of(''))
+ component.testPath({ id: 1 })
+ expect(component.testResult).toBeNull()
+ expect(component.testFailed).toBeTruthy()
+
+ component.testPath(null)
+ expect(component.testResult).toBeNull()
+ })
+
+ it('should compare two documents by id', () => {
+ const doc1 = { id: 1 }
+ const doc2 = { id: 2 }
+ expect(component.compareDocuments(doc1, doc1)).toBeTruthy()
+ expect(component.compareDocuments(doc1, doc2)).toBeFalsy()
+ })
+
+ it('should use id as trackBy', () => {
+ expect(component.trackByFn({ id: 1 })).toBe(1)
+ })
+
+ it('should search on select text input', () => {
+ fixture.debugElement
+ .query(By.directive(NgbAccordionButton))
+ .triggerEventHandler('click', null)
+ fixture.detectChanges()
+ const documents = [
+ { id: 1, title: 'foo' },
+ { id: 2, title: 'bar' },
+ ]
+ const listSpy = jest.spyOn(documentService, 'listFiltered')
+ listSpy.mockReturnValueOnce(
+ of({
+ count: 1,
+ results: documents[0],
+ all: [1],
+ } as any)
+ )
+ component.documentsInput$.next('bar')
+ expect(listSpy).toHaveBeenCalledWith(
+ 1,
+ null,
+ 'created',
+ true,
+ [{ rule_type: FILTER_TITLE, value: 'bar' }],
+ { truncate_content: true }
+ )
+ listSpy.mockReturnValueOnce(
+ of({
+ count: 2,
+ results: [...documents],
+ all: [1, 2],
+ } as any)
+ )
+ component.documentsInput$.next('ba')
+ listSpy.mockReturnValueOnce(throwError(() => new Error()))
+ component.documentsInput$.next('foo')
+ })
+
+ it('should run path test on path change', () => {
+ const testSpy = jest.spyOn(component, 'testPath')
+ component['testDocument'] = { id: 1 } as any
+ component.objectForm.patchValue(
+ { path: 'test/{{title}}' },
+ { emitEvent: true }
+ )
+ fixture.detectChanges()
+ expect(testSpy).toHaveBeenCalled()
+ })
})
diff --git a/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts
index 0f9cc9711..a530502dc 100644
--- a/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts
@@ -1,9 +1,25 @@
-import { Component } from '@angular/core'
+import { Component, OnDestroy } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import {
+ Subject,
+ Observable,
+ concat,
+ of,
+ distinctUntilChanged,
+ takeUntil,
+ tap,
+ switchMap,
+ map,
+ catchError,
+ filter,
+} from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
+import { Document } from 'src/app/data/document'
+import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { StoragePath } from 'src/app/data/storage-path'
+import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -13,24 +29,34 @@ import { SettingsService } from 'src/app/services/settings.service'
templateUrl: './storage-path-edit-dialog.component.html',
styleUrls: ['./storage-path-edit-dialog.component.scss'],
})
-export class StoragePathEditDialogComponent extends EditDialogComponent {
+export class StoragePathEditDialogComponent
+ extends EditDialogComponent
+ implements OnDestroy
+{
+ public documentsInput$ = new Subject()
+ public foundDocuments$: Observable
+ private testDocument: Document
+ public testResult: string
+ public testFailed: boolean = false
+ public loading = false
+ public testLoading = false
+
+ private unsubscribeNotifier: Subject = new Subject()
+
constructor(
service: StoragePathService,
activeModal: NgbActiveModal,
userService: UserService,
- settingsService: SettingsService
+ settingsService: SettingsService,
+ private documentsService: DocumentService
) {
super(service, activeModal, userService, settingsService)
+ this.initPathObservables()
}
- get pathHint() {
- return (
- $localize`e.g.` +
- ' {{ created_year }}-{{ title }}
' +
- $localize`or use slashes to add directories e.g.` +
- ' {{ created_year }}/{{ title }}
. ' +
- $localize`See documentation for full list.`
- )
+ ngOnDestroy(): void {
+ this.unsubscribeNotifier.next(this)
+ this.unsubscribeNotifier.complete()
}
getCreateTitle() {
@@ -51,4 +77,71 @@ export class StoragePathEditDialogComponent extends EditDialogComponent {
+ if (result?.length) {
+ this.testResult = result
+ this.testFailed = false
+ } else {
+ this.testResult = null
+ this.testFailed = true
+ }
+ this.testLoading = false
+ })
+ }
+
+ compareDocuments(document: Document, selectedDocument: Document) {
+ return document.id === selectedDocument.id
+ }
+
+ private initPathObservables() {
+ this.objectForm
+ .get('path')
+ .valueChanges.pipe(
+ takeUntil(this.unsubscribeNotifier),
+ filter((path) => path && !!this.testDocument)
+ )
+ .subscribe(() => {
+ this.testPath(this.testDocument)
+ })
+
+ this.foundDocuments$ = concat(
+ of([]), // default items
+ this.documentsInput$.pipe(
+ tap(() => console.log('searching')),
+ distinctUntilChanged(),
+ takeUntil(this.unsubscribeNotifier),
+ tap(() => (this.loading = true)),
+ switchMap((title) =>
+ this.documentsService
+ .listFiltered(
+ 1,
+ null,
+ 'created',
+ true,
+ [{ rule_type: FILTER_TITLE, value: title }],
+ { truncate_content: true }
+ )
+ .pipe(
+ map((result) => result.results),
+ catchError(() => of([])), // empty on error
+ tap(() => (this.loading = false))
+ )
+ )
+ )
+ )
+ }
+
+ trackByFn(item: Document) {
+ return item.id
+ }
}
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.scss b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.scss
index ad12f4a97..6cfcf86b4 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.scss
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.scss
@@ -3,3 +3,7 @@
color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;
}
}
+
+.accordion-button {
+ font-size: 1rem;
+}
diff --git a/src-ui/src/app/components/common/input/textarea/textarea.component.html b/src-ui/src/app/components/common/input/textarea/textarea.component.html
index b92bef476..d92a8aa4f 100644
--- a/src-ui/src/app/components/common/input/textarea/textarea.component.html
+++ b/src-ui/src/app/components/common/input/textarea/textarea.component.html
@@ -20,7 +20,7 @@
(change)="onChange(value)"
[disabled]="disabled"
[placeholder]="placeholder"
- rows="6">
+ rows="4">
@if (hint) {
diff --git a/src-ui/src/app/services/rest/storage-path.service.spec.ts b/src-ui/src/app/services/rest/storage-path.service.spec.ts
index f365f6aa1..8b67a125b 100644
--- a/src-ui/src/app/services/rest/storage-path.service.spec.ts
+++ b/src-ui/src/app/services/rest/storage-path.service.spec.ts
@@ -1,7 +1,35 @@
import { StoragePathService } from './storage-path.service'
import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
+import { Subscription } from 'rxjs'
+import { HttpTestingController } from '@angular/common/http/testing'
+import { TestBed } from '@angular/core/testing'
+import { environment } from 'src/environments/environment'
+
+let httpTestingController: HttpTestingController
+let service: StoragePathService
+let subscription: Subscription
+const endpoint = 'storage_paths'
commonAbstractNameFilterPaperlessServiceTests(
'storage_paths',
StoragePathService
)
+
+describe(`Additional service tests for StoragePathservice`, () => {
+ beforeEach(() => {
+ httpTestingController = TestBed.inject(HttpTestingController)
+ service = TestBed.inject(StoragePathService)
+ })
+
+ afterEach(() => {
+ subscription?.unsubscribe()
+ httpTestingController.verify()
+ })
+
+ it('should support testing path', () => {
+ subscription = service.testPath('path', 11).subscribe()
+ httpTestingController
+ .expectOne(`${environment.apiBaseUrl}${endpoint}/test/`)
+ .flush('ok')
+ })
+})
diff --git a/src-ui/src/app/services/rest/storage-path.service.ts b/src-ui/src/app/services/rest/storage-path.service.ts
index 52997c7a0..1ac7c82d7 100644
--- a/src-ui/src/app/services/rest/storage-path.service.ts
+++ b/src-ui/src/app/services/rest/storage-path.service.ts
@@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { StoragePath } from 'src/app/data/storage-path'
import { AbstractNameFilterService } from './abstract-name-filter-service'
+import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
@@ -10,4 +11,11 @@ export class StoragePathService extends AbstractNameFilterService {
constructor(http: HttpClient) {
super(http, 'storage_paths')
}
+
+ public testPath(path: string, documentID: number): Observable {
+ return this.http.post(`${this.getResourceUrl()}test/`, {
+ path,
+ document: documentID,
+ })
+ }
}
diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss
index ef856fbc7..aadc1d4a9 100644
--- a/src-ui/src/styles.scss
+++ b/src-ui/src/styles.scss
@@ -21,7 +21,8 @@ $form-file-button-hover-bg: var(--pngx-bg-alt);
// Paperless-ngx styles
body {
- font-size: 0.875rem;
+ --pngx-body-font-size: 0.875rem;
+ font-size: var(--pngx-body-font-size);
height: 100vh;
}
@@ -653,6 +654,10 @@ code {
filter: invert(0.5) saturate(0);
}
+.accordion-button {
+ font-size: var(--pngx-body-font-size);
+}
+
.me-1px {
margin-right: 1px !important;
}
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 7c6e5a3ff..6f7dc8be0 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -2010,3 +2010,18 @@ class TrashSerializer(SerializerWithPerms):
"Some documents in the list have not yet been deleted.",
)
return documents
+
+
+class StoragePathTestSerializer(SerializerWithPerms):
+ path = serializers.CharField(
+ required=True,
+ label="Path",
+ write_only=True,
+ )
+
+ document = serializers.PrimaryKeyRelatedField(
+ queryset=Document.objects.all(),
+ required=True,
+ label="Document",
+ write_only=True,
+ )
diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py
index ec902bf54..54ceb30a8 100644
--- a/src/documents/templating/filepath.py
+++ b/src/documents/templating/filepath.py
@@ -237,7 +237,6 @@ def get_custom_fields_context(
)
# String types need to be sanitized
if field_instance.field.data_type in {
- CustomField.FieldDataType.DOCUMENTLINK,
CustomField.FieldDataType.MONETARY,
CustomField.FieldDataType.STRING,
CustomField.FieldDataType.URL,
diff --git a/src/documents/tests/test_api_objects.py b/src/documents/tests/test_api_objects.py
index c74248b9a..d4d3c729e 100644
--- a/src/documents/tests/test_api_objects.py
+++ b/src/documents/tests/test_api_objects.py
@@ -306,6 +306,35 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
# only called once
bulk_update_mock.assert_called_once_with([document.pk])
+ def test_test_storage_path(self):
+ """
+ GIVEN:
+ - API request to test a storage path
+ WHEN:
+ - API is called
+ THEN:
+ - Correct HTTP response
+ - Correct response data
+ """
+ document = Document.objects.create(
+ mime_type="application/pdf",
+ storage_path=self.sp1,
+ title="Something",
+ checksum="123",
+ )
+ response = self.client.post(
+ f"{self.ENDPOINT}test/",
+ json.dumps(
+ {
+ "document": document.id,
+ "path": "path/{{ title }}",
+ },
+ ),
+ content_type="application/json",
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data, "path/Something")
+
class TestBulkEditObjects(APITestCase):
# See test_api_permissions.py for bulk tests on permissions
diff --git a/src/documents/views.py b/src/documents/views.py
index 94674a83f..a3e19aba1 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -140,6 +140,7 @@ from documents.serialisers import SavedViewSerializer
from documents.serialisers import SearchResultSerializer
from documents.serialisers import ShareLinkSerializer
from documents.serialisers import StoragePathSerializer
+from documents.serialisers import StoragePathTestSerializer
from documents.serialisers import TagSerializer
from documents.serialisers import TagSerializerVersion1
from documents.serialisers import TasksViewSerializer
@@ -151,6 +152,7 @@ from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_updated
from documents.tasks import consume_file
from documents.tasks import empty_trash
+from documents.templating.filepath import validate_filepath_template_and_render
from paperless import version
from paperless.celery import app as celery_app
from paperless.config import GeneralConfig
@@ -1549,6 +1551,25 @@ class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
return response
+class StoragePathTestView(GenericAPIView):
+ """
+ Test storage path against a document
+ """
+
+ permission_classes = [IsAuthenticated]
+ serializer_class = StoragePathTestSerializer
+
+ def post(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ document = serializer.validated_data.get("document")
+ path = serializer.validated_data.get("path")
+
+ result = validate_filepath_template_and_render(path, document)
+ return Response(result)
+
+
class UiSettingsView(GenericAPIView):
queryset = UiSettings.objects.all()
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
diff --git a/src/paperless/urls.py b/src/paperless/urls.py
index 4de9f3662..1b9ab5053 100644
--- a/src/paperless/urls.py
+++ b/src/paperless/urls.py
@@ -32,6 +32,7 @@ from documents.views import SelectionDataView
from documents.views import SharedLinkView
from documents.views import ShareLinkViewSet
from documents.views import StatisticsView
+from documents.views import StoragePathTestView
from documents.views import StoragePathViewSet
from documents.views import SystemStatusView
from documents.views import TagViewSet
@@ -165,6 +166,11 @@ urlpatterns = [
TrashView.as_view(),
name="trash",
),
+ re_path(
+ "^storage_paths/test/",
+ StoragePathTestView.as_view(),
+ name="storage_paths_test",
+ ),
*api_router.urls,
],
),
From dcc8d4046ac7ca28d406bf4b5faa8930f17e5b16 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Thu, 10 Oct 2024 13:28:44 -0700
Subject: [PATCH 4/4] Chore: Unify workflow logic (#7880)
---
src/documents/apps.py | 8 +-
src/documents/consumer.py | 215 +------------
src/documents/signals/handlers.py | 504 ++++++++++++++++++------------
src/documents/templating/title.py | 46 +++
4 files changed, 372 insertions(+), 401 deletions(-)
create mode 100644 src/documents/templating/title.py
diff --git a/src/documents/apps.py b/src/documents/apps.py
index 7ed006d06..c00b23ff2 100644
--- a/src/documents/apps.py
+++ b/src/documents/apps.py
@@ -12,8 +12,8 @@ class DocumentsConfig(AppConfig):
from documents.signals import document_updated
from documents.signals.handlers import add_inbox_tags
from documents.signals.handlers import add_to_index
- from documents.signals.handlers import run_workflow_added
- from documents.signals.handlers import run_workflow_updated
+ from documents.signals.handlers import run_workflows_added
+ from documents.signals.handlers import run_workflows_updated
from documents.signals.handlers import set_correspondent
from documents.signals.handlers import set_document_type
from documents.signals.handlers import set_log_entry
@@ -27,7 +27,7 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(set_storage_path)
document_consumption_finished.connect(set_log_entry)
document_consumption_finished.connect(add_to_index)
- document_consumption_finished.connect(run_workflow_added)
- document_updated.connect(run_workflow_updated)
+ document_consumption_finished.connect(run_workflows_added)
+ document_updated.connect(run_workflows_updated)
AppConfig.ready(self)
diff --git a/src/documents/consumer.py b/src/documents/consumer.py
index f79d3f9c3..05e21671b 100644
--- a/src/documents/consumer.py
+++ b/src/documents/consumer.py
@@ -4,7 +4,6 @@ import os
import tempfile
from enum import Enum
from pathlib import Path
-from typing import TYPE_CHECKING
import magic
from django.conf import settings
@@ -21,7 +20,6 @@ from documents.data_models import DocumentMetadataOverrides
from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename
from documents.loggers import LoggingMixin
-from documents.matching import document_matches_workflow
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -30,8 +28,6 @@ from documents.models import DocumentType
from documents.models import FileInfo
from documents.models import StoragePath
from documents.models import Tag
-from documents.models import Workflow
-from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.parsers import DocumentParser
from documents.parsers import ParseError
@@ -46,6 +42,8 @@ from documents.plugins.helpers import ProgressManager
from documents.plugins.helpers import ProgressStatusOptions
from documents.signals import document_consumption_finished
from documents.signals import document_consumption_started
+from documents.signals.handlers import run_workflows
+from documents.templating.title import parse_doc_title_w_placeholders
from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats
from documents.utils import run_subprocess
@@ -63,163 +61,14 @@ class WorkflowTriggerPlugin(
"""
Get overrides from matching workflows
"""
- msg = ""
- overrides = DocumentMetadataOverrides()
- for workflow in (
- Workflow.objects.filter(enabled=True)
- .prefetch_related("actions")
- .prefetch_related("actions__assign_view_users")
- .prefetch_related("actions__assign_view_groups")
- .prefetch_related("actions__assign_change_users")
- .prefetch_related("actions__assign_change_groups")
- .prefetch_related("actions__assign_custom_fields")
- .prefetch_related("actions__remove_tags")
- .prefetch_related("actions__remove_correspondents")
- .prefetch_related("actions__remove_document_types")
- .prefetch_related("actions__remove_storage_paths")
- .prefetch_related("actions__remove_custom_fields")
- .prefetch_related("actions__remove_owners")
- .prefetch_related("triggers")
- .order_by("order")
- ):
- action_overrides = DocumentMetadataOverrides()
-
- if document_matches_workflow(
- self.input_doc,
- workflow,
- WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
- ):
- for action in workflow.actions.all():
- if TYPE_CHECKING:
- assert isinstance(action, WorkflowAction)
- msg += f"Applying {action} from {workflow}\n"
- if action.type == WorkflowAction.WorkflowActionType.ASSIGNMENT:
- if action.assign_title is not None:
- action_overrides.title = action.assign_title
- if action.assign_tags is not None:
- action_overrides.tag_ids = list(
- action.assign_tags.values_list("pk", flat=True),
- )
-
- if action.assign_correspondent is not None:
- action_overrides.correspondent_id = (
- action.assign_correspondent.pk
- )
- if action.assign_document_type is not None:
- action_overrides.document_type_id = (
- action.assign_document_type.pk
- )
- if action.assign_storage_path is not None:
- action_overrides.storage_path_id = (
- action.assign_storage_path.pk
- )
- if action.assign_owner is not None:
- action_overrides.owner_id = action.assign_owner.pk
- if action.assign_view_users is not None:
- action_overrides.view_users = list(
- action.assign_view_users.values_list("pk", flat=True),
- )
- if action.assign_view_groups is not None:
- action_overrides.view_groups = list(
- action.assign_view_groups.values_list("pk", flat=True),
- )
- if action.assign_change_users is not None:
- action_overrides.change_users = list(
- action.assign_change_users.values_list("pk", flat=True),
- )
- if action.assign_change_groups is not None:
- action_overrides.change_groups = list(
- action.assign_change_groups.values_list(
- "pk",
- flat=True,
- ),
- )
- if action.assign_custom_fields is not None:
- action_overrides.custom_field_ids = list(
- action.assign_custom_fields.values_list(
- "pk",
- flat=True,
- ),
- )
- overrides.update(action_overrides)
- elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
- # Removal actions overwrite the current overrides
- if action.remove_all_tags:
- overrides.tag_ids = []
- elif overrides.tag_ids:
- for tag in action.remove_custom_fields.filter(
- pk__in=overrides.tag_ids,
- ):
- overrides.tag_ids.remove(tag.pk)
-
- if action.remove_all_correspondents or (
- overrides.correspondent_id is not None
- and action.remove_correspondents.filter(
- pk=overrides.correspondent_id,
- ).exists()
- ):
- overrides.correspondent_id = None
-
- if action.remove_all_document_types or (
- overrides.document_type_id is not None
- and action.remove_document_types.filter(
- pk=overrides.document_type_id,
- ).exists()
- ):
- overrides.document_type_id = None
-
- if action.remove_all_storage_paths or (
- overrides.storage_path_id is not None
- and action.remove_storage_paths.filter(
- pk=overrides.storage_path_id,
- ).exists()
- ):
- overrides.storage_path_id = None
-
- if action.remove_all_custom_fields:
- overrides.custom_field_ids = []
- elif overrides.custom_field_ids:
- for field in action.remove_custom_fields.filter(
- pk__in=overrides.custom_field_ids,
- ):
- overrides.custom_field_ids.remove(field.pk)
-
- if action.remove_all_owners or (
- overrides.owner_id is not None
- and action.remove_owners.filter(
- pk=overrides.owner_id,
- ).exists()
- ):
- overrides.owner_id = None
-
- if action.remove_all_permissions:
- overrides.view_users = []
- overrides.view_groups = []
- overrides.change_users = []
- overrides.change_groups = []
- else:
- if overrides.view_users:
- for user in action.remove_view_users.filter(
- pk__in=overrides.view_users,
- ):
- overrides.view_users.remove(user.pk)
- if overrides.change_users:
- for user in action.remove_change_users.filter(
- pk__in=overrides.change_users,
- ):
- overrides.change_users.remove(user.pk)
- if overrides.view_groups:
- for user in action.remove_view_groups.filter(
- pk__in=overrides.view_groups,
- ):
- overrides.view_groups.remove(user.pk)
- if overrides.change_groups:
- for user in action.remove_change_groups.filter(
- pk__in=overrides.change_groups,
- ):
- overrides.change_groups.remove(user.pk)
-
- self.metadata.update(overrides)
+ overrides, msg = run_workflows(
+ WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+ self.input_doc,
+ None,
+ DocumentMetadataOverrides(),
+ )
+ if overrides:
+ self.metadata.update(overrides)
return msg
@@ -948,47 +797,3 @@ class ConsumerPlugin(
copy_basic_file_stats(source, target)
except Exception: # pragma: no cover
pass
-
-
-def parse_doc_title_w_placeholders(
- title: str,
- correspondent_name: str,
- doc_type_name: str,
- owner_username: str,
- local_added: datetime.datetime,
- original_filename: str,
- created: datetime.datetime | None = None,
-) -> str:
- """
- Available title placeholders for Workflows depend on what has already been assigned,
- e.g. for pre-consumption triggers created will not have been parsed yet, but it will
- for added / updated triggers
- """
- formatting = {
- "correspondent": correspondent_name,
- "document_type": doc_type_name,
- "added": local_added.isoformat(),
- "added_year": local_added.strftime("%Y"),
- "added_year_short": local_added.strftime("%y"),
- "added_month": local_added.strftime("%m"),
- "added_month_name": local_added.strftime("%B"),
- "added_month_name_short": local_added.strftime("%b"),
- "added_day": local_added.strftime("%d"),
- "added_time": local_added.strftime("%H:%M"),
- "owner_username": owner_username,
- "original_filename": Path(original_filename).stem,
- }
- if created is not None:
- formatting.update(
- {
- "created": created.isoformat(),
- "created_year": created.strftime("%Y"),
- "created_year_short": created.strftime("%y"),
- "created_month": created.strftime("%m"),
- "created_month_name": created.strftime("%B"),
- "created_month_name_short": created.strftime("%b"),
- "created_day": created.strftime("%d"),
- "created_time": created.strftime("%H:%M"),
- },
- )
- return title.format(**formatting).strip()
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index cf6733dd5..f18cf1c9b 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -24,7 +24,8 @@ from guardian.shortcuts import remove_perm
from documents import matching
from documents.caching import clear_document_caches
from documents.classifier import DocumentClassifier
-from documents.consumer import parse_doc_title_w_placeholders
+from documents.data_models import ConsumableDocument
+from documents.data_models import DocumentMetadataOverrides
from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_unique_filename
@@ -38,6 +39,7 @@ from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import set_permissions_for_object
+from documents.templating.title import parse_doc_title_w_placeholders
logger = logging.getLogger("paperless.handlers")
@@ -511,265 +513,383 @@ def add_to_index(sender, document, **kwargs):
index.add_or_update_document(document)
-def run_workflow_added(sender, document: Document, logging_group=None, **kwargs):
- run_workflow(
+def run_workflows_added(sender, document: Document, logging_group=None, **kwargs):
+ run_workflows(
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
document,
logging_group,
)
-def run_workflow_updated(sender, document: Document, logging_group=None, **kwargs):
- run_workflow(
+def run_workflows_updated(sender, document: Document, logging_group=None, **kwargs):
+ run_workflows(
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
document,
logging_group,
)
-def run_workflow(
+def run_workflows(
trigger_type: WorkflowTrigger.WorkflowTriggerType,
- document: Document,
+ document: Document | ConsumableDocument,
logging_group=None,
-):
+ overrides: DocumentMetadataOverrides | None = None,
+) -> tuple[DocumentMetadataOverrides, str] | None:
+ """Run workflows which match a Document (or ConsumableDocument) for a specific trigger type.
+
+ Assignment or removal actions are either applied directly to the document or an overrides object. If an overrides
+ object is provided, the function returns the object with the applied changes or None if no actions were applied and a string
+ of messages for each action. If no overrides object is provided, the changes are applied directly to the document and the
+ function returns None.
+ """
+
def assignment_action():
- if action.assign_tags.all().count() > 0:
- doc_tag_ids.extend(
- list(action.assign_tags.all().values_list("pk", flat=True)),
- )
-
- if action.assign_correspondent is not None:
- document.correspondent = action.assign_correspondent
-
- if action.assign_document_type is not None:
- document.document_type = action.assign_document_type
-
- if action.assign_storage_path is not None:
- document.storage_path = action.assign_storage_path
-
- if action.assign_owner is not None:
- document.owner = action.assign_owner
-
- if action.assign_title is not None:
- 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 ""),
- timezone.localtime(document.added),
- (
- document.original_filename
- if document.original_filename is not None
- else ""
- ),
- timezone.localtime(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_tags.exists():
+ if not use_overrides:
+ doc_tag_ids.extend(action.assign_tags.values_list("pk", flat=True))
+ else:
+ if overrides.tag_ids is None:
+ overrides.tag_ids = []
+ overrides.tag_ids.extend(
+ action.assign_tags.values_list("pk", flat=True),
)
- if (
- (
- action.assign_view_users is not None
- and action.assign_view_users.count() > 0
- )
- or (
- action.assign_view_groups is not None
- and action.assign_view_groups.count() > 0
- )
- or (
- action.assign_change_users is not None
- and action.assign_change_users.count() > 0
- )
- or (
- action.assign_change_groups is not None
- and action.assign_change_groups.count() > 0
- )
+ if action.assign_correspondent:
+ if not use_overrides:
+ document.correspondent = action.assign_correspondent
+ else:
+ overrides.correspondent_id = action.assign_correspondent.pk
+
+ if action.assign_document_type:
+ if not use_overrides:
+ document.document_type = action.assign_document_type
+ else:
+ overrides.document_type_id = action.assign_document_type.pk
+
+ if action.assign_storage_path:
+ if not use_overrides:
+ document.storage_path = action.assign_storage_path
+ else:
+ overrides.storage_path_id = action.assign_storage_path.pk
+
+ if action.assign_owner:
+ if not use_overrides:
+ document.owner = action.assign_owner
+ else:
+ overrides.owner_id = action.assign_owner.pk
+
+ if action.assign_title:
+ if not use_overrides:
+ try:
+ document.title = parse_doc_title_w_placeholders(
+ action.assign_title,
+ document.correspondent.name if document.correspondent else "",
+ document.document_type.name if document.document_type else "",
+ document.owner.username if document.owner else "",
+ timezone.localtime(document.added),
+ document.original_filename or "",
+ timezone.localtime(document.created),
+ )
+ except Exception:
+ logger.exception(
+ f"Error occurred parsing title assignment '{action.assign_title}', falling back to original",
+ extra={"group": logging_group},
+ )
+ else:
+ overrides.title = action.assign_title
+
+ if any(
+ [
+ action.assign_view_users.exists(),
+ action.assign_view_groups.exists(),
+ action.assign_change_users.exists(),
+ action.assign_change_groups.exists(),
+ ],
):
permissions = {
"view": {
- "users": action.assign_view_users.all().values_list(
- "id",
- )
- or [],
- "groups": action.assign_view_groups.all().values_list(
- "id",
- )
- or [],
+ "users": action.assign_view_users.values_list("id", flat=True),
+ "groups": action.assign_view_groups.values_list("id", flat=True),
},
"change": {
- "users": action.assign_change_users.all().values_list(
- "id",
- )
- or [],
- "groups": action.assign_change_groups.all().values_list(
- "id",
- )
- or [],
+ "users": action.assign_change_users.values_list("id", flat=True),
+ "groups": action.assign_change_groups.values_list("id", flat=True),
},
}
- set_permissions_for_object(
- permissions=permissions,
- object=document,
- merge=True,
- )
+ if not use_overrides:
+ set_permissions_for_object(
+ permissions=permissions,
+ object=document,
+ merge=True,
+ )
+ else:
+ overrides.view_users = list(
+ set(
+ (overrides.view_users or [])
+ + list(permissions["view"]["users"]),
+ ),
+ )
+ overrides.view_groups = list(
+ set(
+ (overrides.view_groups or [])
+ + list(permissions["view"]["groups"]),
+ ),
+ )
+ overrides.change_users = list(
+ set(
+ (overrides.change_users or [])
+ + list(permissions["change"]["users"]),
+ ),
+ )
+ overrides.change_groups = list(
+ set(
+ (overrides.change_groups or [])
+ + list(permissions["change"]["groups"]),
+ ),
+ )
- if action.assign_custom_fields is not None:
- for field in action.assign_custom_fields.all():
- if (
- CustomFieldInstance.objects.filter(
+ if action.assign_custom_fields.exists():
+ if not use_overrides:
+ for field in action.assign_custom_fields.all():
+ if not CustomFieldInstance.objects.filter(
field=field,
document=document,
- ).count()
- == 0
- ):
- # can be triggered on existing docs, so only add the field if it doesn't already exist
- CustomFieldInstance.objects.create(
- field=field,
- document=document,
- )
+ ).exists():
+ # can be triggered on existing docs, so only add the field if it doesn't already exist
+ CustomFieldInstance.objects.create(
+ field=field,
+ document=document,
+ )
+ else:
+ overrides.custom_field_ids = list(
+ set(
+ (overrides.custom_field_ids or [])
+ + list(
+ action.assign_custom_fields.values_list("pk", flat=True),
+ ),
+ ),
+ )
def removal_action():
if action.remove_all_tags:
- doc_tag_ids.clear()
+ if not use_overrides:
+ doc_tag_ids.clear()
+ else:
+ overrides.tag_ids = None
else:
- for tag in action.remove_tags.filter(
- pk__in=list(document.tags.values_list("pk", flat=True)),
- ).all():
- doc_tag_ids.remove(tag.pk)
+ if not use_overrides:
+ for tag in action.remove_tags.filter(
+ pk__in=document.tags.values_list("pk", flat=True),
+ ):
+ doc_tag_ids.remove(tag.pk)
+ elif overrides.tag_ids:
+ for tag in action.remove_tags.filter(pk__in=overrides.tag_ids):
+ overrides.tag_ids.remove(tag.pk)
- if action.remove_all_correspondents or (
- document.correspondent
- and (
- action.remove_correspondents.filter(
+ if not use_overrides and (
+ action.remove_all_correspondents
+ or (
+ document.correspondent
+ and action.remove_correspondents.filter(
pk=document.correspondent.pk,
).exists()
)
):
document.correspondent = None
+ elif use_overrides and (
+ action.remove_all_correspondents
+ or (
+ overrides.correspondent_id
+ and action.remove_correspondents.filter(
+ pk=overrides.correspondent_id,
+ ).exists()
+ )
+ ):
+ overrides.correspondent_id = None
- if action.remove_all_document_types or (
- document.document_type
- and (
- action.remove_document_types.filter(
+ if not use_overrides and (
+ action.remove_all_document_types
+ or (
+ document.document_type
+ and action.remove_document_types.filter(
pk=document.document_type.pk,
).exists()
)
):
document.document_type = None
+ elif use_overrides and (
+ action.remove_all_document_types
+ or (
+ overrides.document_type_id
+ and action.remove_document_types.filter(
+ pk=overrides.document_type_id,
+ ).exists()
+ )
+ ):
+ overrides.document_type_id = None
- if action.remove_all_storage_paths or (
- document.storage_path
- and (
- action.remove_storage_paths.filter(
+ if not use_overrides and (
+ action.remove_all_storage_paths
+ or (
+ document.storage_path
+ and action.remove_storage_paths.filter(
pk=document.storage_path.pk,
).exists()
)
):
document.storage_path = None
+ elif use_overrides and (
+ action.remove_all_storage_paths
+ or (
+ overrides.storage_path_id
+ and action.remove_storage_paths.filter(
+ pk=overrides.storage_path_id,
+ ).exists()
+ )
+ ):
+ overrides.storage_path_id = None
- if action.remove_all_owners or (
- document.owner
- and (action.remove_owners.filter(pk=document.owner.pk).exists())
+ if not use_overrides and (
+ action.remove_all_owners
+ or (
+ document.owner
+ and action.remove_owners.filter(pk=document.owner.pk).exists()
+ )
):
document.owner = None
+ elif use_overrides and (
+ action.remove_all_owners
+ or (
+ overrides.owner_id
+ and action.remove_owners.filter(pk=overrides.owner_id).exists()
+ )
+ ):
+ overrides.owner_id = None
if action.remove_all_permissions:
- permissions = {
- "view": {
- "users": [],
- "groups": [],
- },
- "change": {
- "users": [],
- "groups": [],
- },
- }
- set_permissions_for_object(
- permissions=permissions,
- object=document,
- merge=False,
- )
- elif (
- (action.remove_view_users.all().count() > 0)
- or (action.remove_view_groups.all().count() > 0)
- or (action.remove_change_users.all().count() > 0)
- or (action.remove_change_groups.all().count() > 0)
+ if not use_overrides:
+ permissions = {
+ "view": {"users": [], "groups": []},
+ "change": {"users": [], "groups": []},
+ }
+ set_permissions_for_object(
+ permissions=permissions,
+ object=document,
+ merge=False,
+ )
+ else:
+ overrides.view_users = None
+ overrides.view_groups = None
+ overrides.change_users = None
+ overrides.change_groups = None
+ elif any(
+ [
+ action.remove_view_users.exists(),
+ action.remove_view_groups.exists(),
+ action.remove_change_users.exists(),
+ action.remove_change_groups.exists(),
+ ],
):
- for user in action.remove_view_users.all():
- remove_perm("view_document", user, document)
- for user in action.remove_change_users.all():
- remove_perm("change_document", user, document)
- for group in action.remove_view_groups.all():
- remove_perm("view_document", group, document)
- for group in action.remove_change_groups.all():
- remove_perm("change_document", group, document)
+ if not use_overrides:
+ for user in action.remove_view_users.all():
+ remove_perm("view_document", user, document)
+ for user in action.remove_change_users.all():
+ remove_perm("change_document", user, document)
+ for group in action.remove_view_groups.all():
+ remove_perm("view_document", group, document)
+ for group in action.remove_change_groups.all():
+ remove_perm("change_document", group, document)
+ else:
+ if overrides.view_users:
+ for user in action.remove_view_users.filter(
+ pk__in=overrides.view_users,
+ ):
+ overrides.view_users.remove(user.pk)
+ if overrides.change_users:
+ for user in action.remove_change_users.filter(
+ pk__in=overrides.change_users,
+ ):
+ overrides.change_users.remove(user.pk)
+ if overrides.view_groups:
+ for group in action.remove_view_groups.filter(
+ pk__in=overrides.view_groups,
+ ):
+ overrides.view_groups.remove(group.pk)
+ if overrides.change_groups:
+ for group in action.remove_change_groups.filter(
+ pk__in=overrides.change_groups,
+ ):
+ overrides.change_groups.remove(group.pk)
if action.remove_all_custom_fields:
- CustomFieldInstance.objects.filter(document=document).delete()
- elif action.remove_custom_fields.all().count() > 0:
- CustomFieldInstance.objects.filter(
- field__in=action.remove_custom_fields.all(),
- document=document,
- ).delete()
+ if not use_overrides:
+ CustomFieldInstance.objects.filter(document=document).delete()
+ else:
+ overrides.custom_field_ids = None
+ elif action.remove_custom_fields.exists():
+ if not use_overrides:
+ CustomFieldInstance.objects.filter(
+ field__in=action.remove_custom_fields.all(),
+ document=document,
+ ).delete()
+ elif overrides.custom_field_ids:
+ for field in action.remove_custom_fields.filter(
+ pk__in=overrides.custom_field_ids,
+ ):
+ overrides.custom_field_ids.remove(field.pk)
- for workflow in (
- Workflow.objects.filter(
- enabled=True,
- triggers__type=trigger_type,
+ use_overrides = overrides is not None
+ messages = []
+
+ workflows = (
+ Workflow.objects.filter(enabled=True, triggers__type=trigger_type)
+ .prefetch_related(
+ "actions",
+ "actions__assign_view_users",
+ "actions__assign_view_groups",
+ "actions__assign_change_users",
+ "actions__assign_change_groups",
+ "actions__assign_custom_fields",
+ "actions__remove_tags",
+ "actions__remove_correspondents",
+ "actions__remove_document_types",
+ "actions__remove_storage_paths",
+ "actions__remove_custom_fields",
+ "actions__remove_owners",
+ "triggers",
)
- .prefetch_related("actions")
- .prefetch_related("actions__assign_view_users")
- .prefetch_related("actions__assign_view_groups")
- .prefetch_related("actions__assign_change_users")
- .prefetch_related("actions__assign_change_groups")
- .prefetch_related("actions__assign_custom_fields")
- .prefetch_related("actions__remove_tags")
- .prefetch_related("actions__remove_correspondents")
- .prefetch_related("actions__remove_document_types")
- .prefetch_related("actions__remove_storage_paths")
- .prefetch_related("actions__remove_custom_fields")
- .prefetch_related("actions__remove_owners")
- .prefetch_related("triggers")
.order_by("order")
- ):
- # This can be called from bulk_update_documents, which may be running multiple times
- # Refresh this so the matching data is fresh and instance fields are re-freshed
- # Otherwise, this instance might be behind and overwrite the work another process did
- document.refresh_from_db()
- doc_tag_ids = list(document.tags.all().values_list("pk", flat=True))
- if matching.document_matches_workflow(
- document,
- workflow,
- trigger_type,
- ):
+ )
+
+ for workflow in workflows:
+ if not use_overrides:
+ # This can be called from bulk_update_documents, which may be running multiple times
+ # Refresh this so the matching data is fresh and instance fields are re-freshed
+ # Otherwise, this instance might be behind and overwrite the work another process did
+ document.refresh_from_db()
+ doc_tag_ids = list(document.tags.values_list("pk", flat=True))
+
+ if matching.document_matches_workflow(document, workflow, trigger_type):
action: WorkflowAction
for action in workflow.actions.all():
- logger.info(
- f"Applying {action} from {workflow}",
- extra={"group": logging_group},
- )
+ message = f"Applying {action} from {workflow}"
+ if not use_overrides:
+ logger.info(message, extra={"group": logging_group})
+ else:
+ messages.append(message)
if action.type == WorkflowAction.WorkflowActionType.ASSIGNMENT:
assignment_action()
-
elif action.type == WorkflowAction.WorkflowActionType.REMOVAL:
removal_action()
- # save first before setting tags
- document.save()
- document.tags.set(doc_tag_ids)
+ if not use_overrides:
+ # save first before setting tags
+ document.save()
+ document.tags.set(doc_tag_ids)
+
+ if use_overrides:
+ return overrides, "\n".join(messages)
@before_task_publish.connect
diff --git a/src/documents/templating/title.py b/src/documents/templating/title.py
new file mode 100644
index 000000000..1dc668c27
--- /dev/null
+++ b/src/documents/templating/title.py
@@ -0,0 +1,46 @@
+from datetime import datetime
+from pathlib import Path
+
+
+def parse_doc_title_w_placeholders(
+ title: str,
+ correspondent_name: str,
+ doc_type_name: str,
+ owner_username: str,
+ local_added: datetime,
+ original_filename: str,
+ created: datetime | None = None,
+) -> str:
+ """
+ Available title placeholders for Workflows depend on what has already been assigned,
+ e.g. for pre-consumption triggers created will not have been parsed yet, but it will
+ for added / updated triggers
+ """
+ formatting = {
+ "correspondent": correspondent_name,
+ "document_type": doc_type_name,
+ "added": local_added.isoformat(),
+ "added_year": local_added.strftime("%Y"),
+ "added_year_short": local_added.strftime("%y"),
+ "added_month": local_added.strftime("%m"),
+ "added_month_name": local_added.strftime("%B"),
+ "added_month_name_short": local_added.strftime("%b"),
+ "added_day": local_added.strftime("%d"),
+ "added_time": local_added.strftime("%H:%M"),
+ "owner_username": owner_username,
+ "original_filename": Path(original_filename).stem,
+ }
+ if created is not None:
+ formatting.update(
+ {
+ "created": created.isoformat(),
+ "created_year": created.strftime("%Y"),
+ "created_year_short": created.strftime("%y"),
+ "created_month": created.strftime("%m"),
+ "created_month_name": created.strftime("%B"),
+ "created_month_name_short": created.strftime("%b"),
+ "created_day": created.strftime("%d"),
+ "created_time": created.strftime("%H:%M"),
+ },
+ )
+ return title.format(**formatting).strip()