mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-04 22:09:24 +00:00
A tag PATCH moved files from inside the tags.set() transaction while holding the document row lock. A concurrent save holding media.lock and waiting on that row deadlocked with it, and every later save on the instance queued behind media.lock until the webserver restarted. FileMoveMiddleware opens a file move scope around every request, in sync or async mode, so each changed document is moved once after the view returns and never while a transaction holds row locks.
754 lines
25 KiB
Python
754 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
import datetime
|
|
import hashlib
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
from asgiref.sync import async_to_sync
|
|
from asgiref.sync import sync_to_async
|
|
from django.db import transaction
|
|
from django.http import HttpRequest
|
|
from django.http import HttpResponse
|
|
from django.test import RequestFactory
|
|
from django.utils import timezone
|
|
from filelock import Timeout
|
|
from rest_framework import status
|
|
|
|
from documents.file_moves import _active_collector
|
|
from documents.file_moves import defer_file_moves
|
|
from documents.file_moves import flush_pending_file_moves
|
|
from documents.file_moves import move_files_for_document
|
|
from documents.file_moves import request_file_move
|
|
from documents.models import CustomField
|
|
from documents.models import Document
|
|
from paperless.middleware import FileMoveMiddleware
|
|
from paperless_testing.factories import CorrespondentFactory
|
|
from paperless_testing.factories import CustomFieldFactory
|
|
from paperless_testing.factories import CustomFieldInstanceFactory
|
|
from paperless_testing.factories import DocumentFactory
|
|
from paperless_testing.factories import StoragePathFactory
|
|
from paperless_testing.factories import TagFactory
|
|
from paperless_testing.locks import record_media_lock_acquisitions
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from contextlib import AbstractContextManager
|
|
|
|
from pytest_django.fixtures import Settings
|
|
from pytest_mock import MockerFixture
|
|
from rest_framework.test import APIClient
|
|
|
|
from paperless_testing.dirs import PaperlessDirs
|
|
|
|
OnCommitCapture = Callable[..., AbstractContextManager[list[Callable[[], object]]]]
|
|
|
|
CONTENT = b"original content"
|
|
|
|
|
|
@pytest.fixture
|
|
def title_format(settings: Settings) -> None:
|
|
settings.FILENAME_FORMAT = "{title}"
|
|
|
|
|
|
@pytest.fixture
|
|
def document(paperless_dirs: PaperlessDirs, title_format: None) -> Document:
|
|
"""A document named after its title, with its original on disk."""
|
|
(paperless_dirs.originals_dir / "original.pdf").write_bytes(CONTENT)
|
|
return DocumentFactory(
|
|
title="original",
|
|
filename="original.pdf",
|
|
mime_type="application/pdf",
|
|
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestMoveFilesForDocument:
|
|
def test_moves_using_fresh_db_state(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document whose title changed in the DB without any signal
|
|
WHEN:
|
|
- move_files_for_document is called with its pk
|
|
THEN:
|
|
- The file and the DB filename follow the DB title
|
|
- The returned instance carries the final filename
|
|
"""
|
|
Document.objects.filter(pk=document.pk).update(title="renamed")
|
|
|
|
moved = move_files_for_document(document.pk)
|
|
|
|
assert moved is not None
|
|
assert moved.filename == "renamed.pdf"
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
assert not (paperless_dirs.originals_dir / "original.pdf").exists()
|
|
document.refresh_from_db()
|
|
assert document.filename == "renamed.pdf"
|
|
|
|
def test_missing_document_returns_none(self, paperless_dirs: PaperlessDirs) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A pk with no document row
|
|
WHEN:
|
|
- move_files_for_document is called
|
|
THEN:
|
|
- It returns None without raising
|
|
"""
|
|
assert move_files_for_document(999_999) is None
|
|
|
|
def test_document_without_filename_skips_lock(
|
|
self,
|
|
paperless_dirs: PaperlessDirs,
|
|
title_format: None,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document still being consumed (no filename yet)
|
|
WHEN:
|
|
- move_files_for_document is called
|
|
THEN:
|
|
- It returns None, writes nothing, and never takes media.lock
|
|
"""
|
|
doc = DocumentFactory(filename=None, mime_type="application/pdf")
|
|
|
|
with record_media_lock_acquisitions() as acquisitions:
|
|
assert move_files_for_document(doc.pk) is None
|
|
|
|
assert acquisitions == []
|
|
doc.refresh_from_db()
|
|
assert doc.filename is None
|
|
|
|
def test_no_change_does_not_write_modified(
|
|
self,
|
|
document: Document,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document already at its generated path, modified in the past
|
|
WHEN:
|
|
- move_files_for_document is called
|
|
THEN:
|
|
- modified is not written
|
|
"""
|
|
past = timezone.now() - datetime.timedelta(days=1)
|
|
Document.objects.filter(pk=document.pk).update(modified=past)
|
|
|
|
move_files_for_document(document.pk)
|
|
|
|
document.refresh_from_db()
|
|
assert document.modified == past
|
|
|
|
def test_trashed_already_moved_document_gets_db_path_fixed(
|
|
self,
|
|
paperless_dirs: PaperlessDirs,
|
|
title_format: None,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A trashed document whose DB filename is stale
|
|
- Its file sits at the generated path (checksum matches), placed
|
|
after the creation-time move already failed
|
|
WHEN:
|
|
- move_files_for_document is called
|
|
THEN:
|
|
- The DB filename is updated to where the file is
|
|
"""
|
|
doc = DocumentFactory(
|
|
title="current",
|
|
filename="stale.pdf",
|
|
mime_type="application/pdf",
|
|
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
|
)
|
|
assert Document.objects.get(pk=doc.pk).filename == "stale.pdf"
|
|
doc.delete()
|
|
(paperless_dirs.originals_dir / "current.pdf").write_bytes(CONTENT)
|
|
|
|
move_files_for_document(doc.pk)
|
|
|
|
assert Document.global_objects.get(pk=doc.pk).filename == "current.pdf"
|
|
|
|
def test_lock_timeout_leaves_consistent_state(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
mocker: MockerFixture,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document whose title changed in the DB
|
|
- media.lock cannot be acquired
|
|
WHEN:
|
|
- move_files_for_document is called
|
|
THEN:
|
|
- An error is logged and None is returned
|
|
- The lock was requested with the 300 second timeout
|
|
- The DB filename and the file on disk are unchanged
|
|
"""
|
|
Document.objects.filter(pk=document.pk).update(title="renamed")
|
|
lock = mocker.patch("documents.file_moves.media_lock")
|
|
lock.return_value.__enter__.side_effect = Timeout("media.lock")
|
|
|
|
with caplog.at_level(logging.ERROR, logger="paperless.handlers"):
|
|
assert move_files_for_document(document.pk) is None
|
|
|
|
assert "Timed out" in caplog.text
|
|
lock.assert_called_once_with(timeout=300)
|
|
document.refresh_from_db()
|
|
assert document.filename == "original.pdf"
|
|
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestRequestFileMoveWithoutScope:
|
|
def test_moves_immediately_and_syncs_only_file_columns(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- No file move scope is open
|
|
- A caller instance with an unsaved in-memory change
|
|
WHEN:
|
|
- request_file_move is called after the title changed in the DB
|
|
THEN:
|
|
- The file moves immediately
|
|
- The caller's filename is updated
|
|
- The caller's unsaved change is kept
|
|
"""
|
|
Document.objects.filter(pk=document.pk).update(title="renamed")
|
|
unsaved = CorrespondentFactory()
|
|
document.correspondent = unsaved
|
|
|
|
request_file_move(document)
|
|
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
assert document.filename == "renamed.pdf"
|
|
assert document.correspondent == unsaved
|
|
assert document.title == "original"
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestReceiver:
|
|
def test_reverse_m2m_does_not_request_a_move(
|
|
self,
|
|
document: Document,
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A tag
|
|
WHEN:
|
|
- A document is added through the tag's reverse relation
|
|
THEN:
|
|
- No move is requested with the tag as the instance
|
|
"""
|
|
tag = TagFactory()
|
|
request = mocker.patch("documents.signals.handlers.request_file_move")
|
|
|
|
tag.documents.add(document)
|
|
|
|
request.assert_not_called()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestDeferFileMoves:
|
|
def test_moves_once_on_exit(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An open file move scope
|
|
WHEN:
|
|
- The document is saved with a new title inside the scope
|
|
THEN:
|
|
- Nothing moves while the scope is open
|
|
- On exit the file and DB filename follow the final title
|
|
"""
|
|
with defer_file_moves():
|
|
document.title = "renamed"
|
|
document.save()
|
|
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
|
assert Document.objects.get(pk=document.pk).filename == "original.pdf"
|
|
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
assert not (paperless_dirs.originals_dir / "original.pdf").exists()
|
|
assert Document.objects.get(pk=document.pk).filename == "renamed.pdf"
|
|
|
|
def test_nested_scope_defers_to_outer(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A scope nested inside another
|
|
WHEN:
|
|
- The document is saved inside the inner scope and the inner scope exits
|
|
THEN:
|
|
- Nothing moves until the outer scope exits
|
|
"""
|
|
with defer_file_moves():
|
|
with defer_file_moves():
|
|
document.title = "renamed"
|
|
document.save()
|
|
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
|
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
|
|
def test_closed_collector_moves_immediately(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A collector that has already been closed (work outliving its scope)
|
|
WHEN:
|
|
- A save asks for a move while that collector is still current
|
|
THEN:
|
|
- The move happens immediately instead of being lost
|
|
"""
|
|
with defer_file_moves():
|
|
collector = _active_collector.get()
|
|
assert collector is not None
|
|
token = _active_collector.set(collector)
|
|
try:
|
|
document.title = "renamed"
|
|
document.save()
|
|
finally:
|
|
_active_collector.reset(token)
|
|
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
|
|
def test_scope_closing_inside_atomic_flushes_on_commit(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
django_capture_on_commit_callbacks: OnCommitCapture,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A scope opened and closed inside an atomic block
|
|
WHEN:
|
|
- The atomic block commits
|
|
THEN:
|
|
- The move runs only once the commit callbacks run
|
|
"""
|
|
with django_capture_on_commit_callbacks(execute=True):
|
|
with transaction.atomic(), defer_file_moves():
|
|
document.title = "renamed"
|
|
document.save()
|
|
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
|
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
|
|
def test_scope_closing_inside_rolled_back_atomic_moves_nothing(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
django_capture_on_commit_callbacks: OnCommitCapture,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A scope opened and closed inside an atomic block
|
|
WHEN:
|
|
- The atomic block rolls back
|
|
THEN:
|
|
- The title change is gone and the file is not moved
|
|
"""
|
|
|
|
class Rollback(Exception):
|
|
pass
|
|
|
|
with django_capture_on_commit_callbacks(execute=True):
|
|
with pytest.raises(Rollback):
|
|
with transaction.atomic(), defer_file_moves():
|
|
document.title = "renamed"
|
|
document.save()
|
|
raise Rollback
|
|
|
|
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
|
assert not (paperless_dirs.originals_dir / "renamed.pdf").exists()
|
|
assert Document.objects.get(pk=document.pk).title == "original"
|
|
|
|
def test_flush_pending_moves_early(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An open scope with a pending move
|
|
WHEN:
|
|
- flush_pending_file_moves is called inside the scope
|
|
THEN:
|
|
- The move happens before the scope exits
|
|
"""
|
|
with defer_file_moves():
|
|
document.title = "renamed"
|
|
document.save()
|
|
flush_pending_file_moves()
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
|
|
def test_document_without_filename_in_scope_is_noop(
|
|
self,
|
|
paperless_dirs: PaperlessDirs,
|
|
title_format: None,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document still being consumed (no filename yet)
|
|
WHEN:
|
|
- It is saved inside a scope and the scope exits
|
|
THEN:
|
|
- Nothing is written, media.lock is never taken, nothing raises
|
|
"""
|
|
doc = DocumentFactory(filename=None, mime_type="application/pdf")
|
|
|
|
with record_media_lock_acquisitions() as acquisitions:
|
|
with defer_file_moves():
|
|
doc.title = "renamed"
|
|
doc.save()
|
|
|
|
assert acquisitions == []
|
|
doc.refresh_from_db()
|
|
assert doc.filename is None
|
|
|
|
def test_mover_keeps_metadata_changed_after_save(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
settings: Settings,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A filename format using correspondent and title
|
|
- A scope in which the title is saved
|
|
WHEN:
|
|
- Another writer changes the correspondent before the scope exits
|
|
THEN:
|
|
- The correspondent change survives the flush
|
|
- The final path reflects both changes
|
|
"""
|
|
settings.FILENAME_FORMAT = "{correspondent}/{title}"
|
|
correspondent = CorrespondentFactory(name="acme")
|
|
|
|
with defer_file_moves():
|
|
document.title = "renamed"
|
|
document.save()
|
|
Document.objects.filter(pk=document.pk).update(correspondent=correspondent)
|
|
|
|
document.refresh_from_db()
|
|
assert document.correspondent == correspondent
|
|
assert document.filename == "acme/renamed.pdf"
|
|
assert (paperless_dirs.originals_dir / "acme" / "renamed.pdf").is_file()
|
|
|
|
def test_one_failure_does_not_stop_the_flush(
|
|
self,
|
|
paperless_dirs: PaperlessDirs,
|
|
title_format: None,
|
|
mocker: MockerFixture,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Two documents saved in one scope
|
|
- Moving the first raises an unexpected error
|
|
WHEN:
|
|
- The scope exits
|
|
THEN:
|
|
- The scope does not raise
|
|
- The second document is still moved
|
|
"""
|
|
(paperless_dirs.originals_dir / "a.pdf").write_bytes(CONTENT)
|
|
first = DocumentFactory(
|
|
filename="a.pdf",
|
|
title="a",
|
|
mime_type="application/pdf",
|
|
)
|
|
(paperless_dirs.originals_dir / "b.pdf").write_bytes(CONTENT)
|
|
second = DocumentFactory(
|
|
filename="b.pdf",
|
|
title="b",
|
|
mime_type="application/pdf",
|
|
)
|
|
|
|
real_move = move_files_for_document
|
|
|
|
def failing_for_first(pk: int) -> Document | None:
|
|
if pk == first.pk:
|
|
raise RuntimeError("boom")
|
|
return real_move(pk)
|
|
|
|
mocker.patch(
|
|
"documents.file_moves.move_files_for_document",
|
|
side_effect=failing_for_first,
|
|
)
|
|
|
|
with defer_file_moves():
|
|
first.title = "a2"
|
|
first.save()
|
|
second.title = "b2"
|
|
second.save()
|
|
|
|
assert (paperless_dirs.originals_dir / "b2.pdf").is_file()
|
|
assert (paperless_dirs.originals_dir / "a.pdf").is_file()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestFileMoveMiddleware:
|
|
def test_sync_mode_flushes_before_returning(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- The middleware wrapping a sync view that renames the document
|
|
WHEN:
|
|
- A request passes through it
|
|
THEN:
|
|
- Nothing moves while the view runs
|
|
- The file is at its final path when the middleware returns
|
|
"""
|
|
|
|
def view(request: HttpRequest) -> HttpResponse:
|
|
document.title = "renamed"
|
|
document.save()
|
|
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
|
return HttpResponse()
|
|
|
|
middleware = FileMoveMiddleware(view)
|
|
assert middleware.async_mode is False
|
|
|
|
middleware(RequestFactory().get("/"))
|
|
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
|
|
def test_async_mode_flushes_after_await(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- The middleware wrapping an async view that saves through
|
|
sync_to_async
|
|
WHEN:
|
|
- A request passes through it
|
|
THEN:
|
|
- Nothing moves while the view runs
|
|
- The file is at its final path after the await, so the collector
|
|
survived the context copy into the sync thread
|
|
"""
|
|
|
|
def rename() -> None:
|
|
document.title = "renamed"
|
|
document.save()
|
|
|
|
async def view(request: HttpRequest) -> HttpResponse:
|
|
await sync_to_async(rename)()
|
|
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
|
return HttpResponse()
|
|
|
|
middleware = FileMoveMiddleware(view)
|
|
assert middleware.async_mode is True
|
|
|
|
async_to_sync(middleware)(RequestFactory().get("/"))
|
|
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
|
|
def test_view_raising_after_save_still_moves(
|
|
self,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A view that saves a rename and then raises
|
|
WHEN:
|
|
- A request passes through the middleware
|
|
THEN:
|
|
- The exception propagates
|
|
- The committed rename is still moved
|
|
"""
|
|
|
|
def view(request: HttpRequest) -> HttpResponse:
|
|
document.title = "renamed"
|
|
document.save()
|
|
raise RuntimeError("view failed")
|
|
|
|
with pytest.raises(RuntimeError, match="view failed"):
|
|
FileMoveMiddleware(view)(RequestFactory().get("/"))
|
|
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestFileMovesThroughApi:
|
|
def test_media_lock_never_taken_inside_a_transaction(
|
|
self,
|
|
admin_client: APIClient,
|
|
document: Document,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document and a tag
|
|
WHEN:
|
|
- A tag + title PATCH and a note POST are made through the API
|
|
THEN:
|
|
- media.lock is acquired at least once
|
|
- media.lock is never first acquired while a transaction is open,
|
|
which is the lock ordering that let two such requests deadlock
|
|
"""
|
|
tag = TagFactory()
|
|
|
|
with record_media_lock_acquisitions() as acquisitions:
|
|
response = admin_client.patch(
|
|
f"/api/documents/{document.pk}/",
|
|
{"tags": [tag.pk], "title": "renamed"},
|
|
format="json",
|
|
)
|
|
assert response.status_code == status.HTTP_200_OK
|
|
response = admin_client.post(
|
|
f"/api/documents/{document.pk}/notes/",
|
|
{"note": "a note"},
|
|
format="json",
|
|
)
|
|
assert response.status_code == status.HTTP_200_OK
|
|
|
|
assert acquisitions
|
|
assert not any(a.in_user_transaction for a in acquisitions)
|
|
|
|
def test_patch_moves_to_final_path(
|
|
self,
|
|
admin_client: APIClient,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document named after its title
|
|
WHEN:
|
|
- Its title and tags are changed through the API
|
|
THEN:
|
|
- The file is at the final path when the response returns
|
|
"""
|
|
response = admin_client.patch(
|
|
f"/api/documents/{document.pk}/",
|
|
{"tags": [TagFactory().pk], "title": "renamed"},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_200_OK
|
|
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
|
assert Document.objects.get(pk=document.pk).filename == "renamed.pdf"
|
|
|
|
def test_trash_restore_keeps_db_and_disk_consistent(
|
|
self,
|
|
admin_client: APIClient,
|
|
document: Document,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document moved to the trash
|
|
WHEN:
|
|
- It is restored through the trash API
|
|
THEN:
|
|
- Its DB filename points at a file that exists
|
|
"""
|
|
admin_client.delete(f"/api/documents/{document.pk}/")
|
|
|
|
response = admin_client.post(
|
|
"/api/trash/",
|
|
{"action": "restore", "documents": [document.pk]},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_200_OK
|
|
restored = Document.objects.get(pk=document.pk)
|
|
assert restored.source_path.is_file()
|
|
|
|
def test_root_storage_path_change_moves_versions(
|
|
self,
|
|
admin_client: APIClient,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A root document with one version, both with files on disk
|
|
WHEN:
|
|
- The root's storage path is changed through the API
|
|
THEN:
|
|
- The root and version files both follow the storage path
|
|
"""
|
|
(paperless_dirs.originals_dir / "version.pdf").write_bytes(CONTENT)
|
|
version = DocumentFactory(
|
|
root_document=document,
|
|
title="version",
|
|
filename="version.pdf",
|
|
mime_type="application/pdf",
|
|
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
|
)
|
|
storage_path = StoragePathFactory(path="stored/{{ title }}")
|
|
|
|
response = admin_client.patch(
|
|
f"/api/documents/{document.pk}/",
|
|
{"storage_path": storage_path.pk},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_200_OK
|
|
document.refresh_from_db()
|
|
version.refresh_from_db()
|
|
assert document.filename.startswith("stored/")
|
|
assert version.filename.startswith("stored/")
|
|
assert document.source_path.is_file()
|
|
assert version.source_path.is_file()
|
|
|
|
def test_custom_field_change_moves_to_final_path(
|
|
self,
|
|
admin_client: APIClient,
|
|
document: Document,
|
|
paperless_dirs: PaperlessDirs,
|
|
settings: Settings,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A filename format that uses a text custom field
|
|
- A document with that field set
|
|
WHEN:
|
|
- The field value is changed through the API
|
|
THEN:
|
|
- The file is at the path for the new value
|
|
"""
|
|
settings.FILENAME_FORMAT = (
|
|
"{{ custom_fields|get_cf_value('flavor') }}/{{ title }}"
|
|
)
|
|
field = CustomFieldFactory(
|
|
name="flavor",
|
|
data_type=CustomField.FieldDataType.STRING,
|
|
)
|
|
CustomFieldInstanceFactory(field=field, document=document, value_text="apple")
|
|
|
|
response = admin_client.patch(
|
|
f"/api/documents/{document.pk}/",
|
|
{"custom_fields": [{"field": field.pk, "value": "cherry"}]},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == status.HTTP_200_OK
|
|
document.refresh_from_db()
|
|
assert document.filename == "cherry/original.pdf"
|
|
assert (paperless_dirs.originals_dir / "cherry" / "original.pdf").is_file()
|