Compare commits

...

3 Commits

Author SHA1 Message Date
shamoon
23f6a8b708 version_index wf/file placeholder 2026-03-01 12:23:51 -08:00
shamoon
9b469c4f4f tests 2026-03-01 12:23:45 -08:00
shamoon
af0212ff9e index prop 2026-03-01 12:23:44 -08:00
12 changed files with 208 additions and 6 deletions

View File

@@ -715,6 +715,17 @@ class ConsumerPlugin(
else None
)
version_index = (
0
if self.input_doc.root_document_id is None
else (
Document.objects.filter(
root_document_id=self.input_doc.root_document_id,
).count()
+ 1
)
)
return parse_w_workflow_placeholders(
title,
correspondent_name,
@@ -724,6 +735,7 @@ class ConsumerPlugin(
self.filename,
self.filename,
version_label=self.metadata.version_label,
version_index=version_index,
)
def _store(

View File

@@ -427,6 +427,16 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
def created_date(self):
return self.created
@property
def version_index(self):
if self.root_document_id is None or self.pk is None:
return 0
return Document.objects.filter(
root_document_id=self.root_document_id,
id__lte=self.id,
).count()
def add_nested_tags(self, tags) -> None:
tag_ids = set()
for tag in tags:

View File

@@ -2749,6 +2749,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
created_month_name_short="",
created_day="",
created_time="",
version_index="",
)
except (ValueError, KeyError) as e:
raise serializers.ValidationError(

View File

@@ -6,6 +6,7 @@ from pathlib import PurePath
import pathvalidate
from django.utils import timezone
from django.utils.functional import SimpleLazyObject
from django.utils.text import slugify as django_slugify
from jinja2 import StrictUndefined
from jinja2 import Template
@@ -156,14 +157,15 @@ def get_basic_metadata_context(
document: Document,
*,
no_value_default: str = NO_VALUE_PLACEHOLDER,
) -> dict[str, str]:
version_index: object | None = None,
) -> dict[str, object]:
"""
Given a Document, constructs some basic information about it. If certain values are not set,
they will be replaced with the no_value_default.
Regardless of set or not, the values will be sanitized
"""
return {
context: dict[str, object] = {
"title": pathvalidate.sanitize_filename(
document.title,
replacement_text="-",
@@ -198,15 +200,22 @@ def get_basic_metadata_context(
else no_value_default,
}
if version_index is not None:
context["version_index"] = version_index
return context
def get_safe_document_context(
document: Document,
tags: Iterable[Tag],
*,
version_index: object | None = None,
) -> dict[str, object]:
"""
Build a document context object to avoid supplying entire model instance.
"""
return {
context: dict[str, object] = {
"id": document.pk,
"pk": document.pk,
"title": document.title,
@@ -244,6 +253,11 @@ def get_safe_document_context(
else None,
}
if version_index is not None:
context["version_index"] = version_index
return context
def get_tags_context(tags: Iterable[Tag]) -> dict[str, str | list[str]]:
"""
@@ -354,9 +368,20 @@ def validate_filepath_template_and_render(
custom_fields = CustomFieldInstance.global_objects.filter(document=document)
# Build the context dictionary
lazy_version_index = SimpleLazyObject(lambda: document.version_index)
context = (
{"document": get_safe_document_context(document, tags=tags_list)}
| get_basic_metadata_context(document, no_value_default=NO_VALUE_PLACEHOLDER)
{
"document": get_safe_document_context(
document,
tags=tags_list,
version_index=lazy_version_index,
),
}
| get_basic_metadata_context(
document,
no_value_default=NO_VALUE_PLACEHOLDER,
version_index=lazy_version_index,
)
| get_creation_date_context(document)
| get_added_date_context(document)
| get_tags_context(tags_list)

View File

@@ -42,6 +42,7 @@ def parse_w_workflow_placeholders(
doc_url: str | None = None,
doc_id: int | None = None,
version_label: str | None = None,
version_index: int | None = None,
) -> str:
"""
Available title placeholders for Workflows depend on what has already been assigned,
@@ -64,6 +65,7 @@ def parse_w_workflow_placeholders(
"original_filename": Path(original_filename).stem,
"filename": Path(filename).stem,
"version_label": version_label or "",
"version_index": version_index or "1",
}
if created is not None:
formatting.update(

View File

@@ -267,7 +267,7 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
"/{created_month_name_short}/{created_day}/{added}/{added_year}"
"/{added_year_short}/{added_month}/{added_month_name}"
"/{added_month_name_short}/{added_day}/{asn}"
"/{tag_list}/{owner_username}/{original_name}/{doc_pk}/",
"/{tag_list}/{owner_username}/{original_name}/{doc_pk}/{version_index}/",
},
),
content_type="application/json",

View File

@@ -355,6 +355,35 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(Workflow.objects.count(), 1)
def test_api_create_assign_title_accepts_version_index(self) -> None:
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow with version index",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
},
],
"actions": [
{
"assign_title": "Version {version_index}",
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
workflow = Workflow.objects.get(name="Workflow with version index")
self.assertEqual(
workflow.actions.first().assign_title,
"Version {version_index}",
)
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
"""
GIVEN:

View File

@@ -156,6 +156,54 @@ class TestDocument(TestCase):
)
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
def test_version_index_for_root_is_zero(self) -> None:
root = Document.objects.create(
title="Root",
content="content",
checksum="checksum-root",
mime_type="application/pdf",
created=date(2025, 1, 1),
)
self.assertEqual(root.version_index, 0)
def test_version_index_uses_id_ordering(self) -> None:
root = Document.objects.create(
title="Root",
content="content",
checksum="checksum-root",
mime_type="application/pdf",
created=date(2025, 1, 1),
)
v1 = Document.objects.create(
root_document=root,
title="V1",
content="content",
checksum="checksum-v1",
mime_type="application/pdf",
created=date(2025, 1, 3),
)
v2 = Document.objects.create(
root_document=root,
title="V2",
content="content",
checksum="checksum-v2",
mime_type="application/pdf",
created=date(2025, 1, 2),
)
v3 = Document.objects.create(
root_document=root,
title="V3",
content="content",
checksum="checksum-v3",
mime_type="application/pdf",
created=date(2025, 1, 1),
)
self.assertEqual(v1.version_index, 1)
self.assertEqual(v2.version_index, 2)
self.assertEqual(v3.version_index, 3)
def test_suggestion_content() -> None:
"""

View File

@@ -409,6 +409,21 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertEqual(generate_filename(document), Path("0013579.pdf"))
@override_settings(FILENAME_FORMAT="{version_index}")
def test_format_version_index(self) -> None:
root = Document.objects.create(
checksum="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
mime_type="application/pdf",
)
version = Document.objects.create(
root_document=root,
checksum="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
mime_type="application/pdf",
)
self.assertEqual(generate_filename(root), Path("0.pdf"))
self.assertEqual(generate_filename(version), Path("1.pdf"))
@override_settings(FILENAME_FORMAT=None)
def test_format_none(self) -> None:
document = Document()

View File

@@ -951,6 +951,38 @@ class TestWorkflows(
self.assertEqual(doc.correspondent, self.c2)
self.assertEqual(doc.title, f"Doc created in {created.year}")
def test_document_updated_workflow_version_index_placeholder(self) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
assign_title="Version {{ version_index }}",
)
workflow = Workflow.objects.create(
name="Workflow version index",
order=0,
)
workflow.triggers.add(trigger)
workflow.actions.add(action)
workflow.save()
root = Document.objects.create(
title="root",
checksum="cccccccccccccccccccccccccccccccc",
mime_type="application/pdf",
)
version = Document.objects.create(
title="v1",
checksum="dddddddddddddddddddddddddddddddd",
mime_type="application/pdf",
root_document=root,
)
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, version)
version.refresh_from_db()
self.assertEqual(version.title, "Version 1")
def test_document_added_no_match_filename(self) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from django.conf import settings
from django.contrib.auth.models import User
from django.utils import timezone
from django.utils.functional import SimpleLazyObject
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
@@ -24,6 +25,16 @@ from documents.workflows.webhooks import send_webhook
logger = logging.getLogger("paperless.workflows.actions")
def _get_consumable_version_index(document: ConsumableDocument) -> int:
if document.root_document_id is None:
return 0
# The document is not yet saved, so use the next index in the version chain.
return (
Document.objects.filter(root_document_id=document.root_document_id).count() + 1
)
def build_workflow_action_context(
document: Document | ConsumableDocument,
overrides: DocumentMetadataOverrides | None,
@@ -34,6 +45,11 @@ def build_workflow_action_context(
use_overrides = overrides is not None
if not use_overrides:
version_index = (
document.version_index
if isinstance(document, Document)
else _get_consumable_version_index(document)
)
return {
"title": document.title,
"doc_url": f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/",
@@ -50,6 +66,7 @@ def build_workflow_action_context(
"created": document.created,
"id": document.pk,
"version_label": document.version_label,
"version_index": version_index,
}
correspondent_obj = (
@@ -69,6 +86,11 @@ def build_workflow_action_context(
)
filename = document.original_file if document.original_file else ""
version_index = (
SimpleLazyObject(lambda: _get_consumable_version_index(document))
if isinstance(document, ConsumableDocument)
else SimpleLazyObject(lambda: document.version_index)
)
return {
"title": overrides.title
if overrides and overrides.title
@@ -83,6 +105,7 @@ def build_workflow_action_context(
"created": overrides.created if overrides else None,
"id": "",
"version_label": overrides.version_label if overrides else None,
"version_index": version_index,
}
@@ -119,6 +142,7 @@ def execute_email_action(
context["doc_url"],
context["id"],
context["version_label"],
context["version_index"],
)
if action.email.subject
else ""
@@ -137,6 +161,7 @@ def execute_email_action(
context["doc_url"],
context["id"],
context["version_label"],
context["version_index"],
)
if action.email.body
else ""
@@ -217,6 +242,7 @@ def execute_webhook_action(
context["doc_url"],
context["id"],
context["version_label"],
context["version_index"],
)
except Exception as e:
logger.error(
@@ -237,6 +263,7 @@ def execute_webhook_action(
context["doc_url"],
context["id"],
context["version_label"],
context["version_index"],
)
headers = {}
if action.webhook.headers:

View File

@@ -59,6 +59,7 @@ def apply_assignment_to_document(
"", # no urls in titles
document.pk,
document.version_label,
document.version_index,
)
except Exception: # pragma: no cover
logger.exception(