mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-05 04:05:51 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c322a3fe31 | ||
|
|
e6a7b92a57 | ||
|
|
49bb1b7768 | ||
|
|
ee7a000304 | ||
|
|
57c6fb5cfa | ||
|
|
0770b4fcc2 | ||
|
|
7d5e7b1625 | ||
|
|
61b9bc3367 | ||
|
|
505df89660 | ||
|
|
54a8198167 | ||
|
|
1f3b6db58d | ||
|
|
5bcde3205f | ||
|
|
8f873604d3 | ||
|
|
9a2b878859 | ||
|
|
3591c39c5f | ||
|
|
08748baf54 | ||
|
|
fc29f3511f |
No files matched your search
@@ -1,5 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.4.2
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: mobile save button display obsessing [@shamoon](https://github.com/shamoon) ([#14676](https://github.com/paperless-ngx/paperless-ngx/pull/14676))
|
||||
- Fix: make index and llmindex init wait for tesseract language install [@stumpylog](https://github.com/stumpylog) ([#14670](https://github.com/paperless-ngx/paperless-ngx/pull/14670))
|
||||
- Fix: prevent loss of save view name on save + close [@shamoon](https://github.com/shamoon) ([#14667](https://github.com/paperless-ngx/paperless-ngx/pull/14667))
|
||||
- Fixhancement: doc detail mobile ux tweaks [@shamoon](https://github.com/shamoon) ([#14664](https://github.com/paperless-ngx/paperless-ngx/pull/14664))
|
||||
- Fix: Bumps filelock to 4.2.6 to pick up ACL related fixes [@stumpylog](https://github.com/stumpylog) ([#14656](https://github.com/paperless-ngx/paperless-ngx/pull/14656))
|
||||
- Fix: ensure import bulk\_edit tasks in Celery workers [@shamoon](https://github.com/shamoon) ([#14653](https://github.com/paperless-ngx/paperless-ngx/pull/14653))
|
||||
- Fixhancement: add tag names to document history list [@shamoon](https://github.com/shamoon) ([#14625](https://github.com/paperless-ngx/paperless-ngx/pull/14625))
|
||||
- Fix: add button type to zoom buttons to prevent form submission [@shamoon](https://github.com/shamoon) ([#14624](https://github.com/paperless-ngx/paperless-ngx/pull/14624))
|
||||
- Fix: move document list preview button to own column [@shamoon](https://github.com/shamoon) ([#14610](https://github.com/paperless-ngx/paperless-ngx/pull/14610))
|
||||
- Fix: Various small bugs found during typing updates [@stumpylog](https://github.com/stumpylog) ([#14593](https://github.com/paperless-ngx/paperless-ngx/pull/14593))
|
||||
- Fix: fix custom fields dropdown text size [@shamoon](https://github.com/shamoon) ([#14606](https://github.com/paperless-ngx/paperless-ngx/pull/14606))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>14 changes</summary>
|
||||
|
||||
- Chore: ditch the abandoned ngx-bootstrap-icons, roll our own [@shamoon](https://github.com/shamoon) ([#14665](https://github.com/paperless-ngx/paperless-ngx/pull/14665))
|
||||
- chore(deps): bump turbohtml to 1.15.3 [@stumpylog](https://github.com/stumpylog) ([#14662](https://github.com/paperless-ngx/paperless-ngx/pull/14662))
|
||||
- docker-compose(deps): bump gotenberg/gotenberg from 8.37 to 8.38 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14634](https://github.com/paperless-ngx/paperless-ngx/pull/14634))
|
||||
- chore(deps): bump Django, django-guardian, django-filter, drf-spectacular-sidecar [@stumpylog](https://github.com/stumpylog) ([#14661](https://github.com/paperless-ngx/paperless-ngx/pull/14661))
|
||||
- docker-compose(deps): bump greenmail/standalone from 2.1.14 to 2.1.15 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14631](https://github.com/paperless-ngx/paperless-ngx/pull/14631))
|
||||
- docker-compose(deps): Bump nginx from 1.31.6-alpine to 1.31.7-alpine in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#14633](https://github.com/paperless-ngx/paperless-ngx/pull/14633))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14637](https://github.com/paperless-ngx/paperless-ngx/pull/14637))
|
||||
- Chore(deps-dev): Bump @playwright/test from 1.62.1 to 1.64.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14641](https://github.com/paperless-ngx/paperless-ngx/pull/14641))
|
||||
- Chore(deps): Bump pdfjs-dist from 6.3.289 to 6.4.299 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14644](https://github.com/paperless-ngx/paperless-ngx/pull/14644))
|
||||
- Chore(deps): Bump uuid from 14.0.2 to 14.0.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14640](https://github.com/paperless-ngx/paperless-ngx/pull/14640))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14638](https://github.com/paperless-ngx/paperless-ngx/pull/14638))
|
||||
- Chore(deps): Bump marked from 18.0.14 to 18.1.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14643](https://github.com/paperless-ngx/paperless-ngx/pull/14643))
|
||||
- Chore(deps-dev): Bump prettier from 3.9.9 to 3.9.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14645](https://github.com/paperless-ngx/paperless-ngx/pull/14645))
|
||||
- Chore(deps-dev): Bump @types/node from 26.6.2 to 26.6.5 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14639](https://github.com/paperless-ngx/paperless-ngx/pull/14639))
|
||||
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>21 changes</summary>
|
||||
|
||||
- Fix: mobile save button display obsessing [@shamoon](https://github.com/shamoon) ([#14676](https://github.com/paperless-ngx/paperless-ngx/pull/14676))
|
||||
- Fix: prevent loss of save view name on save + close [@shamoon](https://github.com/shamoon) ([#14667](https://github.com/paperless-ngx/paperless-ngx/pull/14667))
|
||||
- Chore: ditch the abandoned ngx-bootstrap-icons, roll our own [@shamoon](https://github.com/shamoon) ([#14665](https://github.com/paperless-ngx/paperless-ngx/pull/14665))
|
||||
- Fixhancement: doc detail mobile ux tweaks [@shamoon](https://github.com/shamoon) ([#14664](https://github.com/paperless-ngx/paperless-ngx/pull/14664))
|
||||
- chore(deps): bump turbohtml to 1.15.3 [@stumpylog](https://github.com/stumpylog) ([#14662](https://github.com/paperless-ngx/paperless-ngx/pull/14662))
|
||||
- chore(deps): bump Django, django-guardian, django-filter, drf-spectacular-sidecar [@stumpylog](https://github.com/stumpylog) ([#14661](https://github.com/paperless-ngx/paperless-ngx/pull/14661))
|
||||
- Fix: Bumps filelock to 4.2.6 to pick up ACL related fixes [@stumpylog](https://github.com/stumpylog) ([#14656](https://github.com/paperless-ngx/paperless-ngx/pull/14656))
|
||||
- Fix: ensure import bulk\_edit tasks in Celery workers [@shamoon](https://github.com/shamoon) ([#14653](https://github.com/paperless-ngx/paperless-ngx/pull/14653))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14637](https://github.com/paperless-ngx/paperless-ngx/pull/14637))
|
||||
- Chore(deps-dev): Bump @playwright/test from 1.62.1 to 1.64.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14641](https://github.com/paperless-ngx/paperless-ngx/pull/14641))
|
||||
- Chore(deps): Bump pdfjs-dist from 6.3.289 to 6.4.299 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14644](https://github.com/paperless-ngx/paperless-ngx/pull/14644))
|
||||
- Chore(deps): Bump uuid from 14.0.2 to 14.0.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14640](https://github.com/paperless-ngx/paperless-ngx/pull/14640))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14638](https://github.com/paperless-ngx/paperless-ngx/pull/14638))
|
||||
- Chore(deps): Bump marked from 18.0.14 to 18.1.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14643](https://github.com/paperless-ngx/paperless-ngx/pull/14643))
|
||||
- Chore(deps-dev): Bump prettier from 3.9.9 to 3.9.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14645](https://github.com/paperless-ngx/paperless-ngx/pull/14645))
|
||||
- Chore(deps-dev): Bump @types/node from 26.6.2 to 26.6.5 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#14639](https://github.com/paperless-ngx/paperless-ngx/pull/14639))
|
||||
- Fixhancement: add tag names to document history list [@shamoon](https://github.com/shamoon) ([#14625](https://github.com/paperless-ngx/paperless-ngx/pull/14625))
|
||||
- Fix: add button type to zoom buttons to prevent form submission [@shamoon](https://github.com/shamoon) ([#14624](https://github.com/paperless-ngx/paperless-ngx/pull/14624))
|
||||
- Fix: move document list preview button to own column [@shamoon](https://github.com/shamoon) ([#14610](https://github.com/paperless-ngx/paperless-ngx/pull/14610))
|
||||
- Fix: Various small bugs found during typing updates [@stumpylog](https://github.com/stumpylog) ([#14593](https://github.com/paperless-ngx/paperless-ngx/pull/14593))
|
||||
- Fix: fix custom fields dropdown text size [@shamoon](https://github.com/shamoon) ([#14606](https://github.com/paperless-ngx/paperless-ngx/pull/14606))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.4.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -15,7 +15,6 @@ from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from documents.barcodes import read_barcode_values
|
||||
@@ -27,7 +26,10 @@ from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.file_handling import validate_path_in_root
|
||||
from documents.file_moves import defer_file_moves
|
||||
from documents.file_moves import flush_pending_file_moves
|
||||
from documents.loggers import LoggingMixin
|
||||
from documents.media_lock import media_lock
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -605,7 +607,13 @@ class ConsumerPlugin(
|
||||
# now that everything is done, we can start to store the document
|
||||
# in the system. This will be a transaction and reasonably fast.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# File moves requested in here run after commit and after
|
||||
# media.lock is released. media.lock is taken before the
|
||||
# transaction: the version path locks the root row with
|
||||
# select_for_update, and workflow email actions and file
|
||||
# placement below re-enter media.lock instead of waiting
|
||||
# on it while rows are locked.
|
||||
with defer_file_moves(), media_lock(), transaction.atomic():
|
||||
# store the document.
|
||||
if self.input_doc.root_document_id:
|
||||
# If this is a new version of an existing document, we need
|
||||
@@ -695,7 +703,7 @@ class ConsumerPlugin(
|
||||
|
||||
# After everything is in the database, copy the files into
|
||||
# place. If this fails, we'll also rollback the transaction.
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
with media_lock():
|
||||
generated_filename = generate_unique_filename(document)
|
||||
if (
|
||||
len(str(generated_filename))
|
||||
@@ -759,10 +767,9 @@ class ConsumerPlugin(
|
||||
document.archive_path,
|
||||
)
|
||||
|
||||
# Don't save with the lock active. Saving will cause the file
|
||||
# renaming logic to acquire the lock as well.
|
||||
# This triggers things like file renaming
|
||||
document.save()
|
||||
# Any filename follow-up from this save is deferred by
|
||||
# the scope above until after commit.
|
||||
document.save(write_file_paths=True)
|
||||
|
||||
if document.root_document_id:
|
||||
document_updated.send(
|
||||
@@ -803,6 +810,10 @@ class ConsumerPlugin(
|
||||
exception=e,
|
||||
)
|
||||
|
||||
# The storage scope above nests under the consume task's scope, so its
|
||||
# moves are still pending; place the file before the script reads its path.
|
||||
flush_pending_file_moves()
|
||||
|
||||
self.run_post_consume_script(document)
|
||||
|
||||
self.log.info(f"Document {document} consumption finished")
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
Document file moves, coalesced to once per unit of work.
|
||||
|
||||
Every save that can change a document's generated filename asks for a file
|
||||
move through request_file_move(). Inside a scope opened by defer_file_moves()
|
||||
(a request, a Celery task, the consumer, or a per-item command step) the
|
||||
request is only recorded, and the outermost scope moves each recorded document
|
||||
once, after its own writes have committed. Outside any scope the move happens
|
||||
immediately.
|
||||
|
||||
The mover always takes media.lock first and then reads the document fresh, so
|
||||
it starts from the latest committed state and never waits on media.lock while
|
||||
its own transaction holds document rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Final
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from filelock import Timeout
|
||||
|
||||
from documents.caching import clear_document_caches
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.media_lock import media_lock
|
||||
from documents.models import Document
|
||||
from documents.utils import compute_checksum
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import Iterator
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
|
||||
MEDIA_LOCK_TIMEOUT_SECONDS: Final[int] = 300
|
||||
|
||||
|
||||
class CannotMoveFilesException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def in_user_transaction() -> bool:
|
||||
"""
|
||||
True when the default connection is inside an atomic block that was not
|
||||
created by Django's test case machinery.
|
||||
|
||||
Django marks the atomic blocks TestCase (and pytest-django) wrap around
|
||||
each test with _from_testcase. Ignoring them keeps tests on the same path
|
||||
as production.
|
||||
"""
|
||||
connection = transaction.get_connection()
|
||||
return any(not block._from_testcase for block in connection.atomic_blocks)
|
||||
|
||||
|
||||
class FileMoveCollector:
|
||||
"""Pending document pks for one file move scope."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: set[int] = set()
|
||||
self._lock = threading.Lock()
|
||||
self.closed = False
|
||||
|
||||
def add(self, pk: int) -> bool:
|
||||
"""Record a pending move. Returns False if the collector is closed."""
|
||||
with self._lock:
|
||||
if self.closed:
|
||||
return False
|
||||
self._pending.add(pk)
|
||||
return True
|
||||
|
||||
def take_pending(self) -> list[int]:
|
||||
"""Remove and return the pending pks, sorted, leaving the collector open."""
|
||||
with self._lock:
|
||||
pending = sorted(self._pending)
|
||||
self._pending.clear()
|
||||
return pending
|
||||
|
||||
def close(self) -> list[int]:
|
||||
"""Close the collector and return what was still pending, sorted."""
|
||||
with self._lock:
|
||||
self.closed = True
|
||||
pending = sorted(self._pending)
|
||||
self._pending.clear()
|
||||
return pending
|
||||
|
||||
|
||||
# Only the scope context managers call set() / reset(). Everything else reads
|
||||
# it and mutates the collector, so adds made in a copied context (thread or
|
||||
# task hops) are visible to the scope that flushes.
|
||||
_active_collector: ContextVar[FileMoveCollector | None] = ContextVar(
|
||||
"paperless_file_move_collector",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def _path_matches_checksum(path: Path, checksum: str | None) -> bool:
|
||||
if checksum is None or not path.is_file():
|
||||
return False
|
||||
|
||||
return compute_checksum(path) == checksum
|
||||
|
||||
|
||||
def move_files_for_document(pk: int) -> Document | None:
|
||||
"""
|
||||
Move the document's original and archive to the paths its current
|
||||
committed state generates, then do the same for its versions.
|
||||
|
||||
Returns the fresh instance with its final filename columns, or None when
|
||||
the document does not exist, has no filename yet, or the lock timed out.
|
||||
"""
|
||||
# Cheap check without the lock: most saves during consumption happen
|
||||
# before the file is placed. filename is never cleared once set, so this
|
||||
# stays true under the lock. values_list() keeps the check off
|
||||
# Document.global_objects.filter(), which tests patch to inject database
|
||||
# errors into and observe the mover's own writes.
|
||||
filename = (
|
||||
Document.global_objects.values_list("filename", flat=True).filter(pk=pk).first()
|
||||
)
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
try:
|
||||
with media_lock(timeout=MEDIA_LOCK_TIMEOUT_SECONDS):
|
||||
try:
|
||||
document = Document.global_objects.get(pk=pk)
|
||||
except Document.DoesNotExist:
|
||||
logger.debug(f"Document {pk} no longer exists, skipping file move")
|
||||
return None
|
||||
_move_document_files(document)
|
||||
except Timeout:
|
||||
logger.error(
|
||||
f"Timed out after {MEDIA_LOCK_TIMEOUT_SECONDS}s waiting for "
|
||||
f"{settings.MEDIA_LOCK} to move files of document {pk}; the file "
|
||||
f"keeps its current name and will be renamed on its next save",
|
||||
)
|
||||
return None
|
||||
|
||||
# Keep version files in sync with root
|
||||
if document.root_document_id is None:
|
||||
for version_pk in Document.objects.filter(root_document_id=pk).values_list(
|
||||
"pk",
|
||||
flat=True,
|
||||
):
|
||||
move_files_for_document(version_pk)
|
||||
|
||||
return document
|
||||
|
||||
|
||||
def _flush(pks: list[int]) -> None:
|
||||
"""Move each document; one failure never stops the rest or raises."""
|
||||
for pk in pks:
|
||||
try:
|
||||
move_files_for_document(pk)
|
||||
except Exception:
|
||||
logger.exception(f"Unexpected error moving files of document {pk}")
|
||||
|
||||
|
||||
def _flush_after_commit(pks: list[int]) -> None:
|
||||
if not pks:
|
||||
return
|
||||
if in_user_transaction():
|
||||
transaction.on_commit(lambda: _flush(pks))
|
||||
else:
|
||||
_flush(pks)
|
||||
|
||||
|
||||
def _close_and_flush(collector: FileMoveCollector) -> None:
|
||||
_flush_after_commit(collector.close())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def defer_file_moves() -> Iterator[None]:
|
||||
"""
|
||||
Collect file move requests and perform each once when the outermost scope
|
||||
exits, after its own writes have committed.
|
||||
"""
|
||||
current = _active_collector.get()
|
||||
if current is not None and not current.closed:
|
||||
yield
|
||||
return
|
||||
|
||||
collector = FileMoveCollector()
|
||||
token = _active_collector.set(collector)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_active_collector.reset(token)
|
||||
_close_and_flush(collector)
|
||||
|
||||
|
||||
def flush_pending_file_moves() -> None:
|
||||
"""
|
||||
Perform the current scope's pending moves now, keeping the scope open.
|
||||
|
||||
From an eager task inside a request this flushes the request's collector
|
||||
early, which is harmless: moves still run after commit, on fresh state.
|
||||
"""
|
||||
collector = _active_collector.get()
|
||||
if collector is None:
|
||||
return
|
||||
_flush_after_commit(collector.take_pending())
|
||||
|
||||
|
||||
def _validate_move(
|
||||
document: Document,
|
||||
old_path: Path,
|
||||
new_path: Path,
|
||||
root: Path,
|
||||
) -> None:
|
||||
if not new_path.is_relative_to(root):
|
||||
msg = (
|
||||
f"Document {document!s}: Refusing to move file outside root {root}: "
|
||||
f"{new_path}."
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if not old_path.is_file():
|
||||
# Can't do anything if the old file does not exist anymore.
|
||||
msg = f"Document {document!s}: File {old_path} doesn't exist."
|
||||
logger.fatal(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if new_path.is_file():
|
||||
# Can't do anything if the new file already exists. Skip updating file.
|
||||
msg = f"Document {document!s}: Cannot rename file since target path {new_path} already exists."
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
|
||||
def _move_document_files(document: Document) -> None:
|
||||
try:
|
||||
old_filename = document.filename
|
||||
old_source_path = document.source_path
|
||||
move_original = False
|
||||
original_already_moved = False
|
||||
|
||||
old_archive_filename = document.archive_filename
|
||||
old_archive_path = document.archive_path
|
||||
move_archive = False
|
||||
archive_already_moved = False
|
||||
|
||||
candidate_filename = generate_filename(document)
|
||||
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {document!s}: Generated filename exceeds db path "
|
||||
f"limit ({len(str(candidate_filename))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
candidate_source_path = (settings.ORIGINALS_DIR / candidate_filename).resolve()
|
||||
if candidate_filename == Path(old_filename):
|
||||
new_filename = Path(old_filename)
|
||||
elif (
|
||||
candidate_source_path.exists() and candidate_source_path != old_source_path
|
||||
):
|
||||
if not old_source_path.is_file() and _path_matches_checksum(
|
||||
candidate_source_path,
|
||||
document.checksum,
|
||||
):
|
||||
new_filename = candidate_filename
|
||||
original_already_moved = True
|
||||
else:
|
||||
# Only fall back to unique search when there is an actual conflict
|
||||
new_filename = generate_unique_filename(document)
|
||||
else:
|
||||
new_filename = candidate_filename
|
||||
|
||||
# Need to convert to string to be able to save it to the db
|
||||
document.filename = str(new_filename)
|
||||
move_original = old_filename != document.filename and not original_already_moved
|
||||
|
||||
if document.has_archive_version:
|
||||
archive_candidate = generate_filename(document, archive_filename=True)
|
||||
if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {document!s}: Generated archive filename exceeds "
|
||||
f"db path limit ({len(str(archive_candidate))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
archive_candidate_path = (
|
||||
settings.ARCHIVE_DIR / archive_candidate
|
||||
).resolve()
|
||||
if archive_candidate == Path(old_archive_filename):
|
||||
new_archive_filename = Path(old_archive_filename)
|
||||
elif (
|
||||
archive_candidate_path.exists()
|
||||
and archive_candidate_path != old_archive_path
|
||||
):
|
||||
if not old_archive_path.is_file() and _path_matches_checksum(
|
||||
archive_candidate_path,
|
||||
document.archive_checksum,
|
||||
):
|
||||
new_archive_filename = archive_candidate
|
||||
archive_already_moved = True
|
||||
else:
|
||||
new_archive_filename = generate_unique_filename(
|
||||
document,
|
||||
archive_filename=True,
|
||||
)
|
||||
else:
|
||||
new_archive_filename = archive_candidate
|
||||
|
||||
document.archive_filename = str(new_archive_filename)
|
||||
|
||||
move_archive = (
|
||||
old_archive_filename != document.archive_filename
|
||||
and not archive_already_moved
|
||||
)
|
||||
else:
|
||||
move_archive = False
|
||||
|
||||
if not move_original and not move_archive:
|
||||
updates: dict[str, object] = {}
|
||||
if old_filename != document.filename:
|
||||
updates["filename"] = document.filename
|
||||
if old_archive_filename != document.archive_filename:
|
||||
updates["archive_filename"] = document.archive_filename
|
||||
if updates:
|
||||
# global_objects so a trashed document's path is fixed too.
|
||||
updates["modified"] = timezone.now()
|
||||
# Don't save() here to prevent infinite recursion.
|
||||
Document.global_objects.filter(pk=document.pk).update(**updates)
|
||||
return
|
||||
|
||||
if move_original:
|
||||
_validate_move(
|
||||
document,
|
||||
old_source_path,
|
||||
document.source_path,
|
||||
settings.ORIGINALS_DIR,
|
||||
)
|
||||
create_source_path_directory(document.source_path)
|
||||
shutil.move(old_source_path, document.source_path)
|
||||
|
||||
if move_archive:
|
||||
_validate_move(
|
||||
document,
|
||||
old_archive_path,
|
||||
document.archive_path,
|
||||
settings.ARCHIVE_DIR,
|
||||
)
|
||||
create_source_path_directory(document.archive_path)
|
||||
shutil.move(old_archive_path, document.archive_path)
|
||||
|
||||
# Don't save() here to prevent infinite recursion.
|
||||
Document.global_objects.filter(pk=document.pk).update(
|
||||
filename=document.filename,
|
||||
archive_filename=document.archive_filename,
|
||||
modified=timezone.now(),
|
||||
)
|
||||
# Clear any caching for this document. Slightly overkill, but not terrible
|
||||
clear_document_caches(document.pk)
|
||||
|
||||
except (OSError, DatabaseError, CannotMoveFilesException) as e:
|
||||
logger.warning(f"Exception during file handling: {e}")
|
||||
# This happens when either:
|
||||
# - moving the files failed due to file system errors
|
||||
# - saving to the database failed due to database errors
|
||||
# In both cases, we need to revert to the original state.
|
||||
|
||||
# Try to move files to their original location.
|
||||
try:
|
||||
if move_original and document.source_path.is_file():
|
||||
logger.info("Restoring previous original path")
|
||||
shutil.move(document.source_path, old_source_path)
|
||||
|
||||
if move_archive and document.archive_path.is_file():
|
||||
logger.info("Restoring previous archive path")
|
||||
shutil.move(document.archive_path, old_archive_path)
|
||||
|
||||
except Exception:
|
||||
# This is fine, since:
|
||||
# A: if we managed to move source from A to B, we will also
|
||||
# manage to move it from B to A. If not, we have a serious
|
||||
# issue that's going to get caught by the santiy checker.
|
||||
# All files remain in place and will never be overwritten,
|
||||
# so this is not the end of the world.
|
||||
# B: if moving the original file failed, nothing has changed
|
||||
# anyway.
|
||||
pass
|
||||
|
||||
# restore old values on the document
|
||||
document.filename = old_filename
|
||||
document.archive_filename = old_archive_filename
|
||||
|
||||
# finally, remove any empty sub folders. This will do nothing if
|
||||
# something has failed above.
|
||||
if not old_source_path.is_file():
|
||||
delete_empty_directories(
|
||||
Path(old_source_path).parent,
|
||||
root=settings.ORIGINALS_DIR,
|
||||
)
|
||||
|
||||
if document.has_archive_version and not old_archive_path.is_file():
|
||||
delete_empty_directories(
|
||||
Path(old_archive_path).parent,
|
||||
root=settings.ARCHIVE_DIR,
|
||||
)
|
||||
|
||||
|
||||
def request_file_move(document: Document) -> None:
|
||||
"""
|
||||
Ask for the document's files to follow its committed state.
|
||||
|
||||
Inside an open scope the request is recorded and performed when the scope
|
||||
ends. Otherwise the move happens now and only the final filename columns
|
||||
are copied onto the caller's instance, so source_path is current after
|
||||
save() while other in-memory fields are left alone.
|
||||
"""
|
||||
collector = _active_collector.get()
|
||||
if collector is not None and collector.add(document.pk):
|
||||
return
|
||||
|
||||
moved = move_files_for_document(document.pk)
|
||||
if moved is not None:
|
||||
document.filename = moved.filename
|
||||
document.archive_filename = moved.archive_filename
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def adefer_file_moves() -> AsyncIterator[None]:
|
||||
"""
|
||||
Async twin of defer_file_moves().
|
||||
|
||||
When something was collected, the flush runs through sync_to_async: its
|
||||
transaction check needs the thread owning the DB connection, and the
|
||||
blocking media.lock must never run on the event loop.
|
||||
"""
|
||||
current = _active_collector.get()
|
||||
if current is not None and not current.closed:
|
||||
yield
|
||||
return
|
||||
|
||||
collector = FileMoveCollector()
|
||||
token = _active_collector.set(collector)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_active_collector.reset(token)
|
||||
pks = collector.close()
|
||||
if pks:
|
||||
await sync_to_async(_flush_after_commit, thread_sensitive=True)(pks)
|
||||
@@ -4,9 +4,9 @@ from dataclasses import dataclass
|
||||
from email import message_from_bytes
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
from filelock import FileLock
|
||||
|
||||
from documents.media_lock import media_lock
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -48,7 +48,7 @@ def send_email(
|
||||
used_filenames: set[str] = set()
|
||||
|
||||
# Something could be renaming the file concurrently so it can't be attached
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
with media_lock():
|
||||
for attachment in attachments:
|
||||
filename = _get_unique_filename(
|
||||
attachment.friendly_name,
|
||||
|
||||
@@ -18,10 +18,11 @@ from django.core import serializers
|
||||
from django.core.management.base import CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
|
||||
from documents.media_lock import media_lock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
@@ -328,7 +329,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
)
|
||||
|
||||
# Prevent any ongoing changes in the documents while exporting
|
||||
with FileLock(settings.MEDIA_LOCK), sink:
|
||||
with media_lock(), sink:
|
||||
self.dump(sink)
|
||||
|
||||
def dump(self, sink: ExportSink) -> None:
|
||||
|
||||
@@ -29,7 +29,6 @@ from django.db.models import GeneratedField
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.db.models.signals import post_save
|
||||
from filelock import FileLock
|
||||
from guardian.shortcuts import clear_ct_cache
|
||||
|
||||
from documents.export.compression import compress_type_readable
|
||||
@@ -37,6 +36,7 @@ from documents.export.compression import unreadable_method_names
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.management.commands.mixins import CryptMixin
|
||||
from documents.media_lock import media_lock
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -644,7 +644,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
else:
|
||||
archive_path = None
|
||||
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
with media_lock():
|
||||
if Path(document.source_path).is_file():
|
||||
raise FileExistsError(document.source_path)
|
||||
|
||||
@@ -678,7 +678,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
f"Share link bundle {bundle.pk} does not have a valid file path.",
|
||||
)
|
||||
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
with media_lock():
|
||||
bundle_target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
copy_file_with_basic_stats(
|
||||
bundle_source_path,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
from documents.file_moves import move_files_for_document
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.models import Document
|
||||
|
||||
@@ -11,5 +10,8 @@ class Command(PaperlessCommand):
|
||||
supports_multiprocessing = False
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for document in self.track(Document.objects.all(), description="Renaming..."):
|
||||
post_save.send(Document, instance=document, created=False)
|
||||
for document in self.track(
|
||||
Document.objects.only("pk"),
|
||||
description="Renaming...",
|
||||
):
|
||||
move_files_for_document(document.pk)
|
||||
@@ -9,6 +9,7 @@ from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from documents.classifier import load_classifier
|
||||
from documents.file_moves import defer_file_moves
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.models import Document
|
||||
from documents.signals.handlers import set_correspondent
|
||||
@@ -273,61 +274,63 @@ class Command(PaperlessCommand):
|
||||
description="Retagging...",
|
||||
stats_renderer=render_stats,
|
||||
):
|
||||
suggestion = DocumentSuggestion(document=document)
|
||||
# One file move per document, after all of its assignments.
|
||||
with defer_file_moves():
|
||||
suggestion = DocumentSuggestion(document=document)
|
||||
|
||||
if do_correspondent:
|
||||
correspondent = set_correspondent(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if correspondent is not None:
|
||||
stats.correspondents += 1
|
||||
suggestion.correspondent = correspondent
|
||||
if do_correspondent:
|
||||
correspondent = set_correspondent(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if correspondent is not None:
|
||||
stats.correspondents += 1
|
||||
suggestion.correspondent = correspondent
|
||||
|
||||
if do_document_type:
|
||||
document_type = set_document_type(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if document_type is not None:
|
||||
stats.document_types += 1
|
||||
suggestion.document_type = document_type
|
||||
if do_document_type:
|
||||
document_type = set_document_type(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if document_type is not None:
|
||||
stats.document_types += 1
|
||||
suggestion.document_type = document_type
|
||||
|
||||
if do_tags:
|
||||
tags_to_add, tags_to_remove = set_tags(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
dry_run=suggest,
|
||||
)
|
||||
stats.tags_added += len(tags_to_add)
|
||||
stats.tags_removed += len(tags_to_remove)
|
||||
suggestion.tags_to_add = frozenset(tags_to_add)
|
||||
suggestion.tags_to_remove = frozenset(tags_to_remove)
|
||||
if do_tags:
|
||||
tags_to_add, tags_to_remove = set_tags(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
dry_run=suggest,
|
||||
)
|
||||
stats.tags_added += len(tags_to_add)
|
||||
stats.tags_removed += len(tags_to_remove)
|
||||
suggestion.tags_to_add = frozenset(tags_to_add)
|
||||
suggestion.tags_to_remove = frozenset(tags_to_remove)
|
||||
|
||||
if do_storage_path:
|
||||
storage_path = set_storage_path(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if storage_path is not None:
|
||||
stats.storage_paths += 1
|
||||
suggestion.storage_path = storage_path
|
||||
if do_storage_path:
|
||||
storage_path = set_storage_path(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if storage_path is not None:
|
||||
stats.storage_paths += 1
|
||||
suggestion.storage_path = storage_path
|
||||
|
||||
stats.documents_processed += 1
|
||||
stats.documents_processed += 1
|
||||
|
||||
if suggest:
|
||||
suggestions.append(suggestion)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
The global media lock, reentrant within a thread.
|
||||
|
||||
Code that both needs media.lock and opens a transaction that locks document
|
||||
rows must take media.lock first, so no transaction ever waits on it while
|
||||
holding rows. Reentrancy lets code already holding the lock (for example the
|
||||
consumer) call into code that takes it again (for example a workflow email
|
||||
action attaching files) without blocking itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from filelock import FileLock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@contextmanager
|
||||
def media_lock(timeout: float = -1) -> Iterator[None]:
|
||||
"""
|
||||
Hold settings.MEDIA_LOCK for the block.
|
||||
|
||||
is_singleton returns one FileLock instance per path in this process, and
|
||||
the default thread_local=True keeps the hold count per thread, so the
|
||||
same thread re-enters while other threads and processes are excluded.
|
||||
The timeout goes to acquire() because a singleton rejects construction
|
||||
with different parameters. A negative timeout waits forever.
|
||||
"""
|
||||
lock = FileLock(settings.MEDIA_LOCK, is_singleton=True)
|
||||
with lock.acquire(timeout=timeout):
|
||||
yield
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
import pathvalidate
|
||||
@@ -157,6 +158,12 @@ class StoragePath(MatchingModel):
|
||||
|
||||
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
||||
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
||||
# Written only by the file mover, the consumer's file placement and the
|
||||
# archiver. A full save() of a stale instance must never write them, or
|
||||
# the DB can point at a path the file has already been moved away from.
|
||||
FILE_PATH_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{"filename", "archive_filename"},
|
||||
)
|
||||
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
|
||||
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
|
||||
|
||||
@@ -368,6 +375,31 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
res += f" {self.title}"
|
||||
return res
|
||||
|
||||
def save(self, *, write_file_paths: bool = False, **kwargs: Any) -> None:
|
||||
"""
|
||||
Save the document without writing filename / archive_filename on an
|
||||
existing row, unless write_file_paths=True or update_fields names them.
|
||||
|
||||
This mirrors how Django narrows a save on a deferred instance: only the
|
||||
loaded, concrete, non-generated, non-primary-key fields are written.
|
||||
"""
|
||||
if (
|
||||
not write_file_paths
|
||||
and not self._state.adding
|
||||
and kwargs.get("update_fields") is None
|
||||
and not kwargs.get("force_insert", False)
|
||||
):
|
||||
deferred = self.get_deferred_fields()
|
||||
kwargs["update_fields"] = [
|
||||
field.attname
|
||||
for field in self._meta.concrete_fields
|
||||
if not field.primary_key
|
||||
and not field.generated
|
||||
and field.name not in self.FILE_PATH_FIELDS
|
||||
and field.attname not in deferred
|
||||
]
|
||||
super().save(**kwargs)
|
||||
|
||||
def get_effective_barcodes(self) -> list["DocumentBarcode"]:
|
||||
"""
|
||||
Returns the stored barcodes for the document, like
|
||||
|
||||
@@ -19,25 +19,22 @@ from celery.signals import worker_process_shutdown
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import DatabaseError
|
||||
from django.db import close_old_connections
|
||||
from django.db import connections
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents import matching
|
||||
from documents.caching import clear_document_caches
|
||||
from documents.caching import invalidate_llm_suggestions_cache
|
||||
from documents.caching import invalidate_suggestions_cache
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.file_moves import move_files_for_document
|
||||
from documents.file_moves import request_file_move
|
||||
from documents.media_lock import media_lock
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -55,7 +52,6 @@ from documents.models import WorkflowTrigger
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.utils import compute_checksum
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
from documents.workflows.actions import build_workflow_action_context
|
||||
from documents.workflows.actions import execute_email_action
|
||||
@@ -339,7 +335,7 @@ def set_storage_path(
|
||||
|
||||
# see empty_trash in documents/tasks.py for signal handling
|
||||
def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
with media_lock():
|
||||
if settings.EMPTY_TRASH_DIR:
|
||||
# Find a non-conflicting filename in case a document with the same
|
||||
# name was moved to trash earlier
|
||||
@@ -405,17 +401,6 @@ def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
||||
)
|
||||
|
||||
|
||||
class CannotMoveFilesException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _path_matches_checksum(path: Path, checksum: str | None) -> bool:
|
||||
if checksum is None or not path.is_file():
|
||||
return False
|
||||
|
||||
return compute_checksum(path) == checksum
|
||||
|
||||
|
||||
def _filename_template_uses_custom_fields(doc: Document) -> bool:
|
||||
template = None
|
||||
if doc.storage_path is not None:
|
||||
@@ -438,235 +423,21 @@ def update_filename_and_move_files(
|
||||
instance: Document | CustomFieldInstance,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if kwargs.get("reverse"):
|
||||
# tag.documents.add(doc): instance is the Tag, not a Document.
|
||||
return
|
||||
action = kwargs.get("action")
|
||||
if action is not None and action not in {"post_add", "post_remove", "post_clear"}:
|
||||
# m2m_changed fires pre_* and post_* actions; only post_* describe the
|
||||
# relation state the filename should follow.
|
||||
return
|
||||
|
||||
if isinstance(instance, CustomFieldInstance):
|
||||
if not _filename_template_uses_custom_fields(instance.document):
|
||||
return
|
||||
instance = instance.document
|
||||
|
||||
def validate_move(instance, old_path: Path, new_path: Path, root: Path) -> None:
|
||||
if not new_path.is_relative_to(root):
|
||||
msg = (
|
||||
f"Document {instance!s}: Refusing to move file outside root {root}: "
|
||||
f"{new_path}."
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if not old_path.is_file():
|
||||
# Can't do anything if the old file does not exist anymore.
|
||||
msg = f"Document {instance!s}: File {old_path} doesn't exist."
|
||||
logger.fatal(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if new_path.is_file():
|
||||
# Can't do anything if the new file already exists. Skip updating file.
|
||||
msg = f"Document {instance!s}: Cannot rename file since target path {new_path} already exists."
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if not instance.filename:
|
||||
# Can't update the filename if there is no filename to begin with
|
||||
# This happens when the consumer creates a new document.
|
||||
# The document is modified and saved multiple times, and only after
|
||||
# everything is done (i.e., the generated filename is final),
|
||||
# filename will be set to the location where the consumer has put
|
||||
# the file.
|
||||
#
|
||||
# This will in turn cause this logic to move the file where it belongs.
|
||||
return
|
||||
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
# If this was waiting for the lock, the filename or archive_filename
|
||||
# of this document may have been updated. This happens if multiple updates
|
||||
# get queued from the UI for the same document
|
||||
# So freshen up the data before doing anything
|
||||
instance.refresh_from_db()
|
||||
|
||||
try:
|
||||
old_filename = instance.filename
|
||||
old_source_path = instance.source_path
|
||||
move_original = False
|
||||
original_already_moved = False
|
||||
|
||||
old_archive_filename = instance.archive_filename
|
||||
old_archive_path = instance.archive_path
|
||||
move_archive = False
|
||||
archive_already_moved = False
|
||||
|
||||
candidate_filename = generate_filename(instance)
|
||||
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {instance!s}: Generated filename exceeds db path "
|
||||
f"limit ({len(str(candidate_filename))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
candidate_source_path = (
|
||||
settings.ORIGINALS_DIR / candidate_filename
|
||||
).resolve()
|
||||
if candidate_filename == Path(old_filename):
|
||||
new_filename = Path(old_filename)
|
||||
elif (
|
||||
candidate_source_path.exists()
|
||||
and candidate_source_path != old_source_path
|
||||
):
|
||||
if not old_source_path.is_file() and _path_matches_checksum(
|
||||
candidate_source_path,
|
||||
instance.checksum,
|
||||
):
|
||||
new_filename = candidate_filename
|
||||
original_already_moved = True
|
||||
else:
|
||||
# Only fall back to unique search when there is an actual conflict
|
||||
new_filename = generate_unique_filename(instance)
|
||||
else:
|
||||
new_filename = candidate_filename
|
||||
|
||||
# Need to convert to string to be able to save it to the db
|
||||
instance.filename = str(new_filename)
|
||||
move_original = (
|
||||
old_filename != instance.filename and not original_already_moved
|
||||
)
|
||||
|
||||
if instance.has_archive_version:
|
||||
archive_candidate = generate_filename(instance, archive_filename=True)
|
||||
if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {instance!s}: Generated archive filename exceeds "
|
||||
f"db path limit ({len(str(archive_candidate))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
archive_candidate_path = (
|
||||
settings.ARCHIVE_DIR / archive_candidate
|
||||
).resolve()
|
||||
if archive_candidate == Path(old_archive_filename):
|
||||
new_archive_filename = Path(old_archive_filename)
|
||||
elif (
|
||||
archive_candidate_path.exists()
|
||||
and archive_candidate_path != old_archive_path
|
||||
):
|
||||
if not old_archive_path.is_file() and _path_matches_checksum(
|
||||
archive_candidate_path,
|
||||
instance.archive_checksum,
|
||||
):
|
||||
new_archive_filename = archive_candidate
|
||||
archive_already_moved = True
|
||||
else:
|
||||
new_archive_filename = generate_unique_filename(
|
||||
instance,
|
||||
archive_filename=True,
|
||||
)
|
||||
else:
|
||||
new_archive_filename = archive_candidate
|
||||
|
||||
instance.archive_filename = str(new_archive_filename)
|
||||
|
||||
move_archive = (
|
||||
old_archive_filename != instance.archive_filename
|
||||
and not archive_already_moved
|
||||
)
|
||||
else:
|
||||
move_archive = False
|
||||
|
||||
if not move_original and not move_archive:
|
||||
updates = {"modified": timezone.now()}
|
||||
if old_filename != instance.filename:
|
||||
updates["filename"] = instance.filename
|
||||
if old_archive_filename != instance.archive_filename:
|
||||
updates["archive_filename"] = instance.archive_filename
|
||||
|
||||
# Don't save() here to prevent infinite recursion.
|
||||
Document.objects.filter(pk=instance.pk).update(**updates)
|
||||
return
|
||||
|
||||
if move_original:
|
||||
validate_move(
|
||||
instance,
|
||||
old_source_path,
|
||||
instance.source_path,
|
||||
settings.ORIGINALS_DIR,
|
||||
)
|
||||
create_source_path_directory(instance.source_path)
|
||||
shutil.move(old_source_path, instance.source_path)
|
||||
|
||||
if move_archive:
|
||||
validate_move(
|
||||
instance,
|
||||
old_archive_path,
|
||||
instance.archive_path,
|
||||
settings.ARCHIVE_DIR,
|
||||
)
|
||||
create_source_path_directory(instance.archive_path)
|
||||
shutil.move(old_archive_path, instance.archive_path)
|
||||
|
||||
# Don't save() here to prevent infinite recursion.
|
||||
Document.global_objects.filter(pk=instance.pk).update(
|
||||
filename=instance.filename,
|
||||
archive_filename=instance.archive_filename,
|
||||
modified=timezone.now(),
|
||||
)
|
||||
# Clear any caching for this document. Slightly overkill, but not terrible
|
||||
clear_document_caches(instance.pk)
|
||||
|
||||
except (OSError, DatabaseError, CannotMoveFilesException) as e:
|
||||
logger.warning(f"Exception during file handling: {e}")
|
||||
# This happens when either:
|
||||
# - moving the files failed due to file system errors
|
||||
# - saving to the database failed due to database errors
|
||||
# In both cases, we need to revert to the original state.
|
||||
|
||||
# Try to move files to their original location.
|
||||
try:
|
||||
if move_original and instance.source_path.is_file():
|
||||
logger.info("Restoring previous original path")
|
||||
shutil.move(instance.source_path, old_source_path)
|
||||
|
||||
if move_archive and instance.archive_path.is_file():
|
||||
logger.info("Restoring previous archive path")
|
||||
shutil.move(instance.archive_path, old_archive_path)
|
||||
|
||||
except Exception:
|
||||
# This is fine, since:
|
||||
# A: if we managed to move source from A to B, we will also
|
||||
# manage to move it from B to A. If not, we have a serious
|
||||
# issue that's going to get caught by the santiy checker.
|
||||
# All files remain in place and will never be overwritten,
|
||||
# so this is not the end of the world.
|
||||
# B: if moving the original file failed, nothing has changed
|
||||
# anyway.
|
||||
pass
|
||||
|
||||
# restore old values on the instance
|
||||
instance.filename = old_filename
|
||||
instance.archive_filename = old_archive_filename
|
||||
|
||||
# finally, remove any empty sub folders. This will do nothing if
|
||||
# something has failed above.
|
||||
if not old_source_path.is_file():
|
||||
delete_empty_directories(
|
||||
Path(old_source_path).parent,
|
||||
root=settings.ORIGINALS_DIR,
|
||||
)
|
||||
|
||||
if instance.has_archive_version and not old_archive_path.is_file():
|
||||
delete_empty_directories(
|
||||
Path(old_archive_path).parent,
|
||||
root=settings.ARCHIVE_DIR,
|
||||
)
|
||||
|
||||
# Keep version files in sync with root
|
||||
if instance.root_document_id is None:
|
||||
for version_doc in Document.objects.filter(root_document_id=instance.pk).only(
|
||||
"pk",
|
||||
):
|
||||
update_filename_and_move_files(
|
||||
Document,
|
||||
version_doc,
|
||||
)
|
||||
request_file_move(instance)
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -691,7 +462,8 @@ def process_cf_select_update(custom_field: CustomField) -> None:
|
||||
|
||||
for cf_instance in custom_field.fields.select_related("document").iterator():
|
||||
# Update the filename and move files if necessary
|
||||
update_filename_and_move_files(CustomFieldInstance, cf_instance)
|
||||
if _filename_template_uses_custom_fields(cf_instance.document):
|
||||
move_files_for_document(cf_instance.document_id)
|
||||
|
||||
|
||||
# should be disabled in /src/documents/management/commands/document_importer.py handle
|
||||
|
||||
+26
-8
@@ -16,7 +16,6 @@ from django.db import models
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_save
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
|
||||
from documents import sanity_checker
|
||||
from documents.barcodes import BarcodePlugin
|
||||
@@ -42,6 +41,7 @@ from documents.double_sided import CollatePlugin
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.matching import prefilter_documents_by_workflowtrigger
|
||||
from documents.media_lock import media_lock
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
@@ -310,6 +310,7 @@ def sanity_check(*, raise_on_error: bool = True) -> str:
|
||||
|
||||
@shared_task
|
||||
def bulk_update_documents(document_ids) -> None:
|
||||
from documents.file_moves import flush_pending_file_moves
|
||||
from documents.search import get_backend
|
||||
|
||||
document_ids = list(document_ids)
|
||||
@@ -330,6 +331,9 @@ def bulk_update_documents(document_ids) -> None:
|
||||
skip_ai_index=True, # bulk path calls update_llm_index once below
|
||||
)
|
||||
post_save.send(Document, instance=doc, created=False)
|
||||
# Move this document's files now rather than at the end of the task,
|
||||
# so a long bulk edit makes steady progress.
|
||||
flush_pending_file_moves()
|
||||
|
||||
with get_backend().batch_update() as batch:
|
||||
batch.add_or_update_ids(document_ids)
|
||||
@@ -417,7 +421,13 @@ def update_document_content_maybe_archive_file(
|
||||
|
||||
thumbnail = parser.get_thumbnail(document.source_path, mime_type)
|
||||
|
||||
with transaction.atomic():
|
||||
# media.lock first, then the transaction: never wait on media.lock
|
||||
# while this transaction holds the document row. Also keeps the
|
||||
# unique archive filename generation under the lock.
|
||||
with media_lock(), transaction.atomic():
|
||||
# Loaded before the (long) parse; read current metadata now
|
||||
# that the lock is held, for the archive filename.
|
||||
document.refresh_from_db()
|
||||
oldDocument = Document.objects.get(pk=document.pk)
|
||||
if parser.get_archive_path():
|
||||
checksum = compute_checksum(parser.get_archive_path())
|
||||
@@ -482,11 +492,10 @@ def update_document_content_maybe_archive_file(
|
||||
modified=timezone.now(),
|
||||
)
|
||||
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
if parser.get_archive_path():
|
||||
create_source_path_directory(document.archive_path)
|
||||
shutil.move(parser.get_archive_path(), document.archive_path)
|
||||
shutil.move(thumbnail, document.thumbnail_path)
|
||||
if parser.get_archive_path():
|
||||
create_source_path_directory(document.archive_path)
|
||||
shutil.move(parser.get_archive_path(), document.archive_path)
|
||||
shutil.move(thumbnail, document.thumbnail_path)
|
||||
|
||||
document.refresh_from_db()
|
||||
logger.info(
|
||||
@@ -531,7 +540,11 @@ def empty_trash(doc_ids=None) -> None:
|
||||
deleted_document_ids = list(documents.values_list("id", flat=True))
|
||||
# Temporarily connect the cleanup handler
|
||||
models.signals.post_delete.connect(cleanup_document_deletion, sender=Document)
|
||||
documents.delete() # this is effectively a hard delete
|
||||
# media.lock before Django's delete transaction: post_delete runs
|
||||
# cleanup_document_deletion inside it while the rows are locked, and
|
||||
# that handler re-enters the lock instead of waiting.
|
||||
with media_lock():
|
||||
documents.delete() # this is effectively a hard delete
|
||||
logger.info(f"Deleted {len(deleted_document_ids)} documents from trash")
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
@@ -561,6 +574,8 @@ def check_scheduled_workflows() -> None:
|
||||
|
||||
Once a document satisfies this condition, and recurring/non-recurring constraints are met, the workflow is run.
|
||||
"""
|
||||
from documents.file_moves import flush_pending_file_moves
|
||||
|
||||
scheduled_workflows = get_workflows_for_trigger(
|
||||
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||
)
|
||||
@@ -688,6 +703,9 @@ def check_scheduled_workflows() -> None:
|
||||
sender=None,
|
||||
document=document,
|
||||
)
|
||||
# Move this document's files now rather than at the end
|
||||
# of the task, so a long run makes steady progress.
|
||||
flush_pending_file_moves()
|
||||
|
||||
|
||||
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
||||
|
||||
@@ -739,7 +739,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
self.doc1.archive_filename = sample1_archive
|
||||
self.doc1.save()
|
||||
self.doc1.save(write_file_paths=True)
|
||||
self.doc2 = Document.objects.create(
|
||||
checksum="B",
|
||||
title="B",
|
||||
@@ -749,7 +749,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
created=date(2023, 1, 2),
|
||||
)
|
||||
self.doc2.archive_filename = sample2_archive
|
||||
self.doc2.save()
|
||||
self.doc2.save(write_file_paths=True)
|
||||
self.doc3 = Document.objects.create(
|
||||
checksum="C",
|
||||
title="C",
|
||||
@@ -774,7 +774,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
created=date(2023, 1, 3),
|
||||
)
|
||||
self.img_doc.archive_filename = img_doc_archive
|
||||
self.img_doc.save()
|
||||
self.img_doc.save(write_file_paths=True)
|
||||
|
||||
@staticmethod
|
||||
def fake_decrypt(
|
||||
|
||||
@@ -27,6 +27,10 @@ from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowActionEmail
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import ParseError
|
||||
from documents.plugins.helpers import ProgressStatusOptions
|
||||
from documents.tasks import sanity_check
|
||||
@@ -38,6 +42,7 @@ from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
from paperless_testing.locks import record_media_lock_acquisitions
|
||||
from paperless_testing.permissions import grant_object
|
||||
from paperless_testing.samples import MULTI_PAGE_DIGITAL_PDF
|
||||
from paperless_testing.samples import MULTI_PAGE_IMAGES_PDF
|
||||
@@ -221,6 +226,160 @@ class TestConsumer(
|
||||
shutil.copy(src, dst)
|
||||
return dst
|
||||
|
||||
@mock.patch("documents.consumer.load_classifier")
|
||||
def test_consume_takes_media_lock_outside_transaction(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A new document to consume
|
||||
WHEN:
|
||||
- It is consumed
|
||||
THEN:
|
||||
- media.lock is acquired
|
||||
- media.lock is never first acquired inside a transaction
|
||||
- The document's file is in place
|
||||
"""
|
||||
m.return_value = MagicMock()
|
||||
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
|
||||
self.assertTrue(acquisitions)
|
||||
self.assertFalse(any(a.in_user_transaction for a in acquisitions))
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
self.assertIsFile(document.source_path)
|
||||
|
||||
@mock.patch("documents.consumer.load_classifier")
|
||||
def test_consume_places_file_before_post_consume_script(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A new document to consume
|
||||
WHEN:
|
||||
- It is consumed
|
||||
THEN:
|
||||
- Pending file moves are flushed before the post-consume script runs
|
||||
"""
|
||||
m.return_value = MagicMock()
|
||||
order = MagicMock()
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"documents.consumer.flush_pending_file_moves",
|
||||
order.flush,
|
||||
),
|
||||
mock.patch.object(
|
||||
ConsumerPlugin,
|
||||
"run_post_consume_script",
|
||||
order.script,
|
||||
),
|
||||
):
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
|
||||
self.assertEqual(
|
||||
[c[0] for c in order.mock_calls],
|
||||
["flush", "script"],
|
||||
)
|
||||
|
||||
@mock.patch("documents.consumer.load_classifier")
|
||||
def test_consume_version_takes_media_lock_outside_transaction(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An existing root document
|
||||
WHEN:
|
||||
- A new version of it is consumed (the root row is locked with
|
||||
select_for_update inside the consumer's transaction)
|
||||
THEN:
|
||||
- media.lock is never first acquired inside a transaction
|
||||
- The root's modified is bumped
|
||||
"""
|
||||
m.return_value = MagicMock()
|
||||
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
root_doc = Document.objects.first()
|
||||
assert root_doc is not None
|
||||
original_modified = timezone.now() - datetime.timedelta(days=1)
|
||||
Document.objects.filter(pk=root_doc.pk).update(modified=original_modified)
|
||||
|
||||
version_file = self.get_test_file2()
|
||||
status = FakeProgressManager(version_file.name, None)
|
||||
overrides = DocumentMetadataOverrides(version_label="v2")
|
||||
doc = ConsumableDocument(
|
||||
DocumentSource.ApiUpload,
|
||||
original_file=version_file,
|
||||
root_document_id=root_doc.pk,
|
||||
)
|
||||
preflight = ConsumerPreflightPlugin(
|
||||
doc,
|
||||
overrides,
|
||||
status, # type: ignore[arg-type]
|
||||
self.dirs.scratch_dir,
|
||||
"task-id",
|
||||
)
|
||||
preflight.setup()
|
||||
preflight.run()
|
||||
consumer = ConsumerPlugin(
|
||||
doc,
|
||||
overrides,
|
||||
status, # type: ignore[arg-type]
|
||||
self.dirs.scratch_dir,
|
||||
"task-id",
|
||||
)
|
||||
consumer.setup()
|
||||
|
||||
try:
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
consumer.run()
|
||||
finally:
|
||||
consumer.cleanup()
|
||||
|
||||
self.assertTrue(acquisitions)
|
||||
self.assertFalse(any(a.in_user_transaction for a in acquisitions))
|
||||
root_doc.refresh_from_db()
|
||||
self.assertGreater(root_doc.modified, original_modified)
|
||||
|
||||
@override_settings(
|
||||
PAPERLESS_EMAIL_HOST="localhost",
|
||||
EMAIL_ENABLED=True,
|
||||
PAPERLESS_URL="http://localhost:8000",
|
||||
)
|
||||
@mock.patch("documents.consumer.load_classifier")
|
||||
@mock.patch("django.core.mail.message.EmailMessage.send", return_value=1)
|
||||
def test_consumption_workflow_email_runs_under_held_lock(self, send, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document added workflow with an email action
|
||||
WHEN:
|
||||
- A document is consumed (the consumer holds media.lock while
|
||||
workflows run)
|
||||
THEN:
|
||||
- The email action re-enters media.lock and sends without blocking
|
||||
"""
|
||||
m.return_value = MagicMock()
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
)
|
||||
email = WorkflowActionEmail.objects.create(
|
||||
subject="added",
|
||||
body="body",
|
||||
to="someone@example.com",
|
||||
include_document=True,
|
||||
)
|
||||
action = WorkflowAction.objects.create(
|
||||
type=WorkflowAction.WorkflowActionType.EMAIL,
|
||||
email=email,
|
||||
)
|
||||
workflow = Workflow.objects.create(name="email on add")
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(action)
|
||||
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
|
||||
send.assert_called_once()
|
||||
|
||||
@override_settings(
|
||||
FILENAME_FORMAT=None,
|
||||
TIME_ZONE="America/Chicago",
|
||||
@@ -750,7 +909,7 @@ class TestConsumer(
|
||||
FILENAME_FORMAT="{correspondent}/{title}",
|
||||
ARCHIVE_FILE_GENERATION="always",
|
||||
)
|
||||
@mock.patch("documents.signals.handlers.generate_unique_filename")
|
||||
@mock.patch("documents.file_moves.generate_unique_filename")
|
||||
def testFilenameHandlingUnstableFormat(self, m) -> None:
|
||||
filenames = ["this", "that", "now this", "i cannot decide"]
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
# Ensure that filename is properly generated
|
||||
self.assertEqual(document.filename, Path("none/none.pdf"))
|
||||
|
||||
document.save()
|
||||
document.save(write_file_paths=True)
|
||||
|
||||
# test that creating dirs for the source_path creates the correct directory
|
||||
create_source_path_directory(document.source_path)
|
||||
@@ -190,7 +190,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Set a correspondent and save the document
|
||||
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
|
||||
document.save()
|
||||
document.save(write_file_paths=True)
|
||||
|
||||
# Check proper handling of files
|
||||
self.assertIsFile(
|
||||
@@ -232,7 +232,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
disable_auditlog(),
|
||||
):
|
||||
m.side_effect = DatabaseError()
|
||||
document.save()
|
||||
document.save(write_file_paths=True)
|
||||
|
||||
# Check proper handling of files
|
||||
self.assertIsFile(document.source_path)
|
||||
@@ -336,7 +336,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Ensure that filename is properly generated
|
||||
document.filename = generate_filename(document)
|
||||
document.save()
|
||||
document.save(write_file_paths=True)
|
||||
self.assertEqual(document.filename, "none/none.pdf")
|
||||
|
||||
create_source_path_directory(document.source_path)
|
||||
@@ -362,7 +362,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Ensure that filename is properly generated
|
||||
document.filename = generate_filename(document)
|
||||
document.save()
|
||||
document.save(write_file_paths=True)
|
||||
self.assertEqual(document.filename, "none/none.pdf")
|
||||
|
||||
create_source_path_directory(document.source_path)
|
||||
@@ -385,7 +385,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
document.save()
|
||||
document.filename = generate_filename(document)
|
||||
document.save()
|
||||
document.save(write_file_paths=True)
|
||||
create_source_path_directory(document.source_path)
|
||||
Path(document.source_path).touch()
|
||||
document.delete()
|
||||
@@ -421,7 +421,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Set a correspondent and save the document
|
||||
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
|
||||
document.save()
|
||||
document.save(write_file_paths=True)
|
||||
|
||||
# Check proper handling of files
|
||||
self.assertIsDir(settings.ORIGINALS_DIR / "test")
|
||||
@@ -476,7 +476,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
def test_filename_relative(self) -> None:
|
||||
doc = Document.objects.create(title="doc1", mime_type="application/pdf")
|
||||
doc.filename = generate_filename(doc)
|
||||
doc.save()
|
||||
doc.save(write_file_paths=True)
|
||||
|
||||
self.assertEqual(
|
||||
doc.source_path,
|
||||
@@ -532,7 +532,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Ensure that filename is properly generated
|
||||
document.filename = generate_filename(document)
|
||||
document.save()
|
||||
document.save(write_file_paths=True)
|
||||
self.assertEqual(document.filename, "none/none/none.pdf")
|
||||
create_source_path_directory(document.source_path)
|
||||
Path(document.source_path).touch()
|
||||
@@ -620,13 +620,13 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
Path(document.source_path).touch()
|
||||
Path(document2.source_path).touch()
|
||||
document.filename = "0000001.pdf"
|
||||
document.save()
|
||||
document.save(write_file_paths=True)
|
||||
|
||||
self.assertIsFile(document.source_path)
|
||||
self.assertEqual(document.filename, "qwe.pdf")
|
||||
|
||||
document2.filename = "0000002.pdf"
|
||||
document2.save()
|
||||
document2.save(write_file_paths=True)
|
||||
|
||||
self.assertIsFile(document.source_path)
|
||||
self.assertEqual(document2.filename, "qwe_01.pdf")
|
||||
@@ -656,19 +656,17 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(document2.filename, "qwe.pdf")
|
||||
|
||||
@override_settings(FILENAME_FORMAT="{title}")
|
||||
@mock.patch("documents.signals.handlers.Document.objects.filter")
|
||||
@mock.patch("documents.signals.handlers.shutil.move")
|
||||
@mock.patch("documents.file_moves.Document.global_objects.filter")
|
||||
@mock.patch("documents.file_moves.shutil.move")
|
||||
def test_no_move_only_save(self, mock_move, mock_filter) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document with a filename
|
||||
- The document is saved
|
||||
- The filename is not changed
|
||||
WHEN:
|
||||
- The document is saved
|
||||
- The document is saved without a change that affects its path
|
||||
THEN:
|
||||
- The document modified date is updated
|
||||
- The document is not moved
|
||||
- The document modified date is updated by the save itself
|
||||
- The file is not moved and the mover writes nothing
|
||||
"""
|
||||
with disable_auditlog():
|
||||
doc = Document.objects.create(
|
||||
@@ -686,7 +684,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
doc.save()
|
||||
doc.refresh_from_db()
|
||||
|
||||
mock_filter.assert_called()
|
||||
mock_filter.assert_not_called()
|
||||
self.assertNotEqual(original_modified, doc.modified)
|
||||
mock_move.assert_not_called()
|
||||
|
||||
@@ -694,7 +692,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
FILENAME_FORMAT="{{title}}_{{custom_fields|get_cf_value('test')}}",
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
)
|
||||
@mock.patch("documents.signals.handlers.update_filename_and_move_files")
|
||||
@mock.patch("documents.signals.handlers.move_files_for_document")
|
||||
def test_select_cf_updated(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -2045,7 +2043,7 @@ class TestCustomFieldFilenameUpdates(
|
||||
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(self.doc.source_path).touch()
|
||||
|
||||
with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
|
||||
with mock.patch("documents.file_moves.generate_unique_filename") as m:
|
||||
m.side_effect = generate_unique_filename
|
||||
self.cfi.value_text = "updated"
|
||||
self.cfi.save()
|
||||
@@ -2067,7 +2065,7 @@ class TestCustomFieldFilenameUpdates(
|
||||
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(self.doc.source_path).touch()
|
||||
|
||||
with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
|
||||
with mock.patch("documents.file_moves.generate_unique_filename") as m:
|
||||
m.side_effect = generate_unique_filename
|
||||
self.cfi.value_text = "updated"
|
||||
self.cfi.save()
|
||||
@@ -2145,7 +2143,7 @@ def test_refresh_failure_surfaces_database_error(
|
||||
GIVEN:
|
||||
- A document with a stored filename
|
||||
WHEN:
|
||||
- Saving it triggers a filename update and refreshing it from the database fails
|
||||
- Saving it triggers a filename update and loading it from the database fails
|
||||
THEN:
|
||||
- The database error is raised, not an unbound variable error from the rollback
|
||||
"""
|
||||
@@ -2153,8 +2151,8 @@ def test_refresh_failure_surfaces_database_error(
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
Document,
|
||||
"refresh_from_db",
|
||||
Document.global_objects,
|
||||
"get",
|
||||
side_effect=DatabaseError("database is locked"),
|
||||
),
|
||||
pytest.raises(DatabaseError, match="database is locked"),
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from asgiref.sync import async_to_sync
|
||||
from asgiref.sync import sync_to_async
|
||||
from celery.contrib.django.task import DjangoTask
|
||||
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 django.utils.dateparse import parse_datetime
|
||||
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 documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.tasks import bulk_update_documents
|
||||
from documents.tasks import check_scheduled_workflows
|
||||
from documents.tasks import empty_trash
|
||||
from paperless.celery import app as celery_app
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(name="documents.tests.test_file_moves.rename_then_fail")
|
||||
def rename_then_fail(pk: int, originals_dir: str) -> None:
|
||||
"""Test-only task: rename the document, check nothing moved yet, then fail."""
|
||||
doc = Document.objects.get(pk=pk)
|
||||
doc.title = "renamed"
|
||||
doc.save()
|
||||
assert (Path(originals_dir) / "original.pdf").is_file()
|
||||
raise RuntimeError("task failed after saving")
|
||||
|
||||
|
||||
@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 already sits at the generated path (checksum matches)
|
||||
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
|
||||
"""
|
||||
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_patch_response_reports_post_move_modified(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document named after its title
|
||||
WHEN:
|
||||
- Its title is changed through the API, causing a rename
|
||||
THEN:
|
||||
- The modified time in the response equals the stored value,
|
||||
which the file move bumped after the edit was saved
|
||||
"""
|
||||
response = admin_client.patch(
|
||||
f"/api/documents/{document.pk}/",
|
||||
{"title": "renamed"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
stored = Document.objects.get(pk=document.pk)
|
||||
assert stored.filename == "renamed.pdf"
|
||||
assert parse_datetime(response.json()["modified"]) == stored.modified
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCeleryTaskScope:
|
||||
def test_task_class_opens_scope(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The paperless Celery app
|
||||
WHEN:
|
||||
- A registered task is inspected
|
||||
THEN:
|
||||
- It runs through FileMoveScopedTask
|
||||
- It keeps Celery's DjangoTask API, such as delay_on_commit
|
||||
"""
|
||||
from paperless.celery import FileMoveScopedTask
|
||||
|
||||
assert isinstance(bulk_update_documents, FileMoveScopedTask)
|
||||
assert isinstance(bulk_update_documents, DjangoTask)
|
||||
|
||||
def test_failing_task_still_moves_committed_change(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A task that renames a document, then raises
|
||||
WHEN:
|
||||
- The task is applied
|
||||
THEN:
|
||||
- The task fails with its own error
|
||||
- Nothing moved while the task body ran
|
||||
- The committed rename is moved when the task ends
|
||||
"""
|
||||
result = rename_then_fail.apply(
|
||||
args=[document.pk, str(paperless_dirs.originals_dir)],
|
||||
)
|
||||
|
||||
assert result.failed()
|
||||
assert isinstance(result.result, RuntimeError)
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
|
||||
def test_bulk_update_moves_each_document(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents whose titles changed in the DB without signals
|
||||
WHEN:
|
||||
- bulk_update_documents runs for both
|
||||
THEN:
|
||||
- Each document's file follows its new title
|
||||
"""
|
||||
mocker.patch("documents.search.get_backend")
|
||||
(paperless_dirs.originals_dir / "second.pdf").write_bytes(CONTENT)
|
||||
second = DocumentFactory(
|
||||
title="second",
|
||||
filename="second.pdf",
|
||||
mime_type="application/pdf",
|
||||
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
||||
)
|
||||
Document.objects.filter(pk=document.pk).update(title="first-renamed")
|
||||
Document.objects.filter(pk=second.pk).update(title="second-renamed")
|
||||
|
||||
bulk_update_documents.apply(args=[[document.pk, second.pk]])
|
||||
|
||||
assert (paperless_dirs.originals_dir / "first-renamed.pdf").is_file()
|
||||
assert (paperless_dirs.originals_dir / "second-renamed.pdf").is_file()
|
||||
|
||||
def test_scheduled_workflows_move_each_document(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents matched by a scheduled workflow, where running the
|
||||
workflow renames the document
|
||||
WHEN:
|
||||
- check_scheduled_workflows runs
|
||||
THEN:
|
||||
- The first document's file is already moved when the workflow
|
||||
runs for the second, not only when the task ends
|
||||
"""
|
||||
(paperless_dirs.originals_dir / "second.pdf").write_bytes(CONTENT)
|
||||
second = DocumentFactory(
|
||||
title="second",
|
||||
filename="second.pdf",
|
||||
mime_type="application/pdf",
|
||||
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
||||
)
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||
schedule_offset_days=0,
|
||||
schedule_date_field=WorkflowTrigger.ScheduleDateField.ADDED,
|
||||
)
|
||||
workflow = Workflow.objects.create(name="scheduled", order=0)
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(WorkflowAction.objects.create(assign_title="unused"))
|
||||
|
||||
seen: dict[int, bool] = {}
|
||||
|
||||
def rename(*, document: Document, **kwargs: object) -> None:
|
||||
seen[document.pk] = (paperless_dirs.originals_dir / "first.pdf").is_file()
|
||||
document.title = "first" if document.pk == first_pk else "other"
|
||||
document.save()
|
||||
|
||||
first_pk = min(document.pk, second.pk)
|
||||
mocker.patch("documents.tasks.run_workflows", side_effect=rename)
|
||||
mocker.patch("documents.tasks.send_websocket_document_updated")
|
||||
|
||||
check_scheduled_workflows.apply()
|
||||
|
||||
second_pk = max(document.pk, second.pk)
|
||||
assert seen[first_pk] is False
|
||||
assert seen[second_pk] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEmptyTrashLockOrdering:
|
||||
def test_hard_delete_takes_media_lock_outside_transaction(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A trashed document with its file on disk
|
||||
WHEN:
|
||||
- The trash is emptied for it
|
||||
THEN:
|
||||
- media.lock is never first acquired inside a transaction
|
||||
- The document row and its file are gone
|
||||
"""
|
||||
document.delete()
|
||||
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
empty_trash([document.pk])
|
||||
|
||||
assert acquisitions
|
||||
assert not any(a.in_user_transaction for a in acquisitions)
|
||||
assert not Document.global_objects.filter(pk=document.pk).exists()
|
||||
assert not (paperless_dirs.originals_dir / "original.pdf").exists()
|
||||
@@ -18,11 +18,13 @@ if TYPE_CHECKING:
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_moves import move_files_for_document
|
||||
from documents.models import Document
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.locks import record_media_lock_acquisitions
|
||||
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
|
||||
|
||||
sample_file: Path = SIMPLE_DIGITAL_PDF
|
||||
@@ -63,6 +65,28 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertTrue(filecmp.cmp(sample_file, doc.source_path))
|
||||
self.assertEqual(doc.archive_filename, "none/A.pdf")
|
||||
|
||||
def test_handle_document_takes_media_lock_outside_transaction(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose archive version is being generated
|
||||
WHEN:
|
||||
- update_document_content_maybe_archive_file runs
|
||||
THEN:
|
||||
- media.lock is acquired
|
||||
- media.lock is never first acquired while a transaction holds the
|
||||
document row
|
||||
"""
|
||||
doc = self.make_models()
|
||||
shutil.copy(sample_file, Path(self.dirs.originals_dir) / f"{doc.id:07}.pdf")
|
||||
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
update_document_content_maybe_archive_file(doc.pk)
|
||||
|
||||
self.assertTrue(acquisitions)
|
||||
self.assertFalse(any(a.in_user_transaction for a in acquisitions))
|
||||
doc.refresh_from_db()
|
||||
self.assertIsFile(doc.archive_path)
|
||||
|
||||
def test_unknown_mime_type(self) -> None:
|
||||
doc = self.make_models()
|
||||
doc.mime_type = "sdgfh"
|
||||
@@ -236,7 +260,7 @@ class TestRenamer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
doc = Document.objects.create(title="test", mime_type="image/jpeg")
|
||||
doc.filename = generate_filename(doc)
|
||||
doc.archive_filename = generate_filename(doc, archive_filename=True)
|
||||
doc.save()
|
||||
doc.save(write_file_paths=True)
|
||||
|
||||
Path(doc.source_path).touch()
|
||||
Path(doc.archive_path).touch()
|
||||
@@ -253,6 +277,42 @@ class TestRenamer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertIsFile(doc2.source_path)
|
||||
self.assertIsFile(doc2.archive_path)
|
||||
|
||||
@override_settings(FILENAME_FORMAT="")
|
||||
def test_rename_moves_each_document_by_direct_call(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents whose filename format changed
|
||||
WHEN:
|
||||
- document_renamer runs
|
||||
THEN:
|
||||
- move_files_for_document is called once per document
|
||||
- Both files are at their new paths
|
||||
"""
|
||||
docs = []
|
||||
for title in ("first", "second"):
|
||||
doc = Document.objects.create(title=title, mime_type="image/jpeg")
|
||||
doc.filename = generate_filename(doc)
|
||||
doc.save(write_file_paths=True)
|
||||
Path(doc.source_path).touch()
|
||||
docs.append(doc)
|
||||
|
||||
with (
|
||||
override_settings(FILENAME_FORMAT="{title}"),
|
||||
mock.patch(
|
||||
"documents.management.commands.document_renamer.move_files_for_document",
|
||||
wraps=move_files_for_document,
|
||||
) as mover,
|
||||
):
|
||||
call_command("document_renamer", skip_checks=True)
|
||||
|
||||
self.assertCountEqual(
|
||||
[c.args[0] for c in mover.call_args_list],
|
||||
[d.pk for d in docs],
|
||||
)
|
||||
for doc in docs:
|
||||
doc.refresh_from_db()
|
||||
self.assertIsFile(doc.source_path)
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
class TestCreateClassifier:
|
||||
|
||||
@@ -4,10 +4,13 @@ Tests for the document_retagger management command.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
from documents.file_moves import _active_collector
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
@@ -21,6 +24,12 @@ from paperless_testing.factories import DocumentTypeFactory
|
||||
from paperless_testing.factories import StoragePathFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level type aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -484,3 +493,69 @@ class TestRetaggerEdgeCases(DirectoriesMixin):
|
||||
|
||||
assert d_first.tags.count() == 0
|
||||
assert d_unrelated.tags.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestRetaggerFileMoves:
|
||||
def test_each_document_is_processed_in_its_own_scope(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents and a filename format using the correspondent
|
||||
- A classifier suggestion assigning a correspondent to each
|
||||
WHEN:
|
||||
- document_retagger runs with --correspondent
|
||||
THEN:
|
||||
- Each document is processed inside an open file move scope
|
||||
- When the second document is processed, the first one's file has
|
||||
already moved
|
||||
- Both files end at their final paths
|
||||
"""
|
||||
settings.FILENAME_FORMAT = "{correspondent}/{title}"
|
||||
(paperless_dirs.originals_dir / "none").mkdir(exist_ok=True)
|
||||
for title in ("first", "second"):
|
||||
(paperless_dirs.originals_dir / "none" / f"{title}.pdf").write_bytes(b"x")
|
||||
DocumentFactory(
|
||||
title=title,
|
||||
filename=f"none/{title}.pdf",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
correspondent = CorrespondentFactory(name="acme")
|
||||
processed: list[Document] = []
|
||||
scope_open: list[bool] = []
|
||||
seen_first_moved: list[bool] = []
|
||||
|
||||
def fake_set_correspondent(sender, document, **kwargs):
|
||||
scope_open.append(_active_collector.get() is not None)
|
||||
# Order-agnostic: on the second call, check the first one's file.
|
||||
if processed:
|
||||
first = processed[0]
|
||||
seen_first_moved.append(
|
||||
(
|
||||
paperless_dirs.originals_dir / "acme" / f"{first.title}.pdf"
|
||||
).is_file(),
|
||||
)
|
||||
processed.append(document)
|
||||
document.correspondent = correspondent
|
||||
document.save(update_fields=("correspondent",))
|
||||
return correspondent
|
||||
|
||||
mocker.patch(
|
||||
"documents.management.commands.document_retagger.load_classifier",
|
||||
)
|
||||
mocker.patch(
|
||||
"documents.management.commands.document_retagger.set_correspondent",
|
||||
side_effect=fake_set_correspondent,
|
||||
)
|
||||
|
||||
call_command("document_retagger", "--correspondent", skip_checks=True)
|
||||
|
||||
assert scope_open == [True, True]
|
||||
assert seen_first_moved == [True]
|
||||
for title in ("first", "second"):
|
||||
assert (paperless_dirs.originals_dir / "acme" / f"{title}.pdf").is_file()
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.db import transaction
|
||||
from filelock import Timeout
|
||||
|
||||
from documents.file_moves import in_user_transaction
|
||||
from documents.media_lock import media_lock
|
||||
from paperless_testing.locks import record_media_lock_acquisitions
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
class TestMediaLock:
|
||||
def test_same_thread_reenters_without_blocking(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- media.lock held by the current thread
|
||||
WHEN:
|
||||
- The same thread acquires it again with a zero timeout
|
||||
THEN:
|
||||
- The nested acquisition succeeds immediately
|
||||
"""
|
||||
with media_lock(), media_lock(timeout=0):
|
||||
pass
|
||||
|
||||
def test_other_thread_is_excluded(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- media.lock held by the current thread
|
||||
WHEN:
|
||||
- Another thread tries to acquire it with a short timeout
|
||||
THEN:
|
||||
- The other thread times out
|
||||
- After release, the other thread can acquire it
|
||||
"""
|
||||
outcomes: list[str] = []
|
||||
|
||||
def try_acquire() -> None:
|
||||
try:
|
||||
with media_lock(timeout=0.2):
|
||||
outcomes.append("acquired")
|
||||
except Timeout:
|
||||
outcomes.append("timeout")
|
||||
|
||||
with media_lock():
|
||||
worker = threading.Thread(target=try_acquire)
|
||||
worker.start()
|
||||
worker.join()
|
||||
|
||||
worker = threading.Thread(target=try_acquire)
|
||||
worker.start()
|
||||
worker.join()
|
||||
|
||||
assert outcomes == ["timeout", "acquired"]
|
||||
|
||||
def test_lock_file_is_the_configured_path(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The test's media directory layout
|
||||
WHEN:
|
||||
- media.lock is acquired
|
||||
THEN:
|
||||
- The lock file is created at settings.MEDIA_LOCK
|
||||
"""
|
||||
with media_lock():
|
||||
assert paperless_dirs.media_lock.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestInUserTransaction:
|
||||
def test_false_inside_test_case_wrapper(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The transaction pytest-django wraps around the test
|
||||
WHEN:
|
||||
- in_user_transaction is called
|
||||
THEN:
|
||||
- It returns False
|
||||
"""
|
||||
assert in_user_transaction() is False
|
||||
|
||||
def test_true_inside_atomic(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An atomic block opened by code under test
|
||||
WHEN:
|
||||
- in_user_transaction is called inside it
|
||||
THEN:
|
||||
- It returns True
|
||||
"""
|
||||
with transaction.atomic():
|
||||
assert in_user_transaction() is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
class TestRecordMediaLockAcquisitions:
|
||||
def test_records_only_first_acquisitions(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Nested media.lock acquisitions, the outer one inside a transaction
|
||||
WHEN:
|
||||
- They are recorded
|
||||
THEN:
|
||||
- Only the outer, first acquisition is recorded, flagged as inside
|
||||
a transaction
|
||||
"""
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
with transaction.atomic(), media_lock(), media_lock():
|
||||
pass
|
||||
|
||||
assert [a.in_user_transaction for a in acquisitions] == [True]
|
||||
@@ -1,4 +1,7 @@
|
||||
import pytest
|
||||
from django.db import DatabaseError
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
@@ -22,3 +25,101 @@ class TestDocument:
|
||||
assert Correspondent.objects.count() == 0
|
||||
assert Document.objects.count() == 1
|
||||
assert Document.objects.first().correspondent is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
class TestDocumentFilePathWrites:
|
||||
def test_full_save_does_not_write_file_paths(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose filename columns were changed in the DB after
|
||||
an instance was loaded
|
||||
WHEN:
|
||||
- The stale instance is saved without update_fields
|
||||
THEN:
|
||||
- The DB filename columns keep the newer values
|
||||
- Other fields from the stale instance are written
|
||||
"""
|
||||
doc = DocumentFactory(filename="old.pdf", archive_filename="old.pdf")
|
||||
stale = Document.objects.get(pk=doc.pk)
|
||||
Document.objects.filter(pk=doc.pk).update(
|
||||
filename="new.pdf",
|
||||
archive_filename="new-archive.pdf",
|
||||
)
|
||||
|
||||
stale.title = "changed"
|
||||
stale.save()
|
||||
|
||||
doc.refresh_from_db()
|
||||
assert doc.filename == "new.pdf"
|
||||
assert doc.archive_filename == "new-archive.pdf"
|
||||
assert doc.title == "changed"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"save_kwargs",
|
||||
[
|
||||
pytest.param({"write_file_paths": True}, id="write-file-paths"),
|
||||
pytest.param(
|
||||
{"update_fields": ["filename", "archive_filename"]},
|
||||
id="explicit-update-fields",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_opt_in_writes_file_paths(self, save_kwargs: dict[str, object]) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An existing document
|
||||
WHEN:
|
||||
- Its filename columns are changed in memory and saved with an
|
||||
explicit opt in
|
||||
THEN:
|
||||
- The DB filename columns are updated
|
||||
"""
|
||||
doc = DocumentFactory(filename="old.pdf", archive_filename="old.pdf")
|
||||
|
||||
doc.filename = "new.pdf"
|
||||
doc.archive_filename = "new.pdf"
|
||||
doc.save(**save_kwargs)
|
||||
|
||||
doc.refresh_from_db()
|
||||
assert doc.filename == "new.pdf"
|
||||
assert doc.archive_filename == "new.pdf"
|
||||
|
||||
def test_creation_writes_file_paths(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A new, unsaved document with filename columns set
|
||||
WHEN:
|
||||
- It is saved
|
||||
THEN:
|
||||
- The filename columns are written
|
||||
"""
|
||||
doc = DocumentFactory.build(filename="a.pdf", archive_filename="b.pdf")
|
||||
|
||||
doc.save()
|
||||
|
||||
doc.refresh_from_db()
|
||||
assert doc.filename == "a.pdf"
|
||||
assert doc.archive_filename == "b.pdf"
|
||||
|
||||
def test_full_save_of_deleted_row_raises(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An instance whose row was hard-deleted elsewhere
|
||||
WHEN:
|
||||
- The instance is saved without update_fields
|
||||
THEN:
|
||||
- A DatabaseError is raised instead of re-inserting the row
|
||||
"""
|
||||
doc = DocumentFactory()
|
||||
stale = Document.objects.get(pk=doc.pk)
|
||||
# A plain QuerySet bypasses the soft-delete manager, so the row is
|
||||
# really gone (as after emptying the trash).
|
||||
models.QuerySet(model=Document).filter(pk=doc.pk).delete()
|
||||
|
||||
# The savepoint keeps the failed UPDATE from poisoning the test transaction.
|
||||
with pytest.raises(DatabaseError), transaction.atomic():
|
||||
stale.save()
|
||||
|
||||
assert not Document.global_objects.filter(pk=doc.pk).exists()
|
||||
@@ -132,7 +132,7 @@ class TestCheckSanityOriginal:
|
||||
class TestCheckSanityArchive:
|
||||
def test_checksum_without_filename(self, sample_doc: Document) -> None:
|
||||
sample_doc.archive_filename = None
|
||||
sample_doc.save()
|
||||
sample_doc.save(write_file_paths=True)
|
||||
messages = check_sanity()
|
||||
assert messages.has_error
|
||||
assert any(
|
||||
@@ -185,7 +185,7 @@ class TestCheckSanityArchive:
|
||||
Path(sample_doc.archive_path).unlink()
|
||||
sample_doc.archive_checksum = None
|
||||
sample_doc.archive_filename = None
|
||||
sample_doc.save()
|
||||
sample_doc.save(write_file_paths=True)
|
||||
messages = check_sanity()
|
||||
assert not messages.has_error
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
|
||||
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
|
||||
if archive:
|
||||
self.document.archive_filename = f"{self.document.pk:07}.pdf"
|
||||
self.document.save()
|
||||
self.document.save(write_file_paths=True)
|
||||
path = self.document.archive_path
|
||||
assert path is not None
|
||||
else:
|
||||
|
||||
@@ -126,6 +126,7 @@ from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.file_handling import format_filename
|
||||
from documents.file_moves import flush_pending_file_moves
|
||||
from documents.filters import CorrespondentFilterSet
|
||||
from documents.filters import CustomFieldFilterSet
|
||||
from documents.filters import DocumentFilterSet
|
||||
@@ -1264,6 +1265,8 @@ class DocumentViewSet(
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
# The response must reflect the post-move filename and modified time
|
||||
flush_pending_file_moves()
|
||||
|
||||
if content_updated and content_doc.id != root_doc.id:
|
||||
content_doc.content = (
|
||||
|
||||
+23
-1
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.contrib.django.task import DjangoTask
|
||||
from celery.signals import worker_process_init
|
||||
from kombu.serialization import register
|
||||
|
||||
@@ -26,7 +27,28 @@ register(
|
||||
content_encoding="binary",
|
||||
)
|
||||
|
||||
app = Celery("paperless")
|
||||
|
||||
class FileMoveScopedTask(DjangoTask):
|
||||
"""
|
||||
Run every task inside a document file move scope, so each document a task
|
||||
changes is moved once when the task ends, after its writes have committed.
|
||||
The scope also flushes when the task raises, for writes that committed
|
||||
before the failure.
|
||||
|
||||
Subclasses DjangoTask because a custom task_cls replaces the Django fixup's
|
||||
default, and delay_on_commit lives there.
|
||||
"""
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
# Function-local: this module is imported by paperless/__init__.py
|
||||
# before Django apps are loaded.
|
||||
from documents.file_moves import defer_file_moves
|
||||
|
||||
with defer_file_moves():
|
||||
return super().__call__(*args, **kwargs)
|
||||
|
||||
|
||||
app = Celery("paperless", task_cls=FileMoveScopedTask)
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from asgiref.sync import iscoroutinefunction
|
||||
from asgiref.sync import markcoroutinefunction
|
||||
from compression_middleware.middleware import CompressionMiddleware
|
||||
from django.conf import settings
|
||||
|
||||
@@ -30,3 +32,35 @@ class ApiVersionMiddleware:
|
||||
response["X-Version"] = version.__full_version_str__
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class FileMoveMiddleware:
|
||||
"""
|
||||
Open a document file move scope around each request, so every document the
|
||||
request changes is moved once, after its writes have committed, before the
|
||||
response is returned.
|
||||
"""
|
||||
|
||||
sync_capable = True
|
||||
async_capable = True
|
||||
|
||||
def __init__(self, get_response) -> None:
|
||||
self.get_response = get_response
|
||||
self.async_mode = iscoroutinefunction(get_response)
|
||||
if self.async_mode:
|
||||
markcoroutinefunction(self)
|
||||
|
||||
def __call__(self, request):
|
||||
if self.async_mode:
|
||||
return self.__acall__(request)
|
||||
|
||||
from documents.file_moves import defer_file_moves
|
||||
|
||||
with defer_file_moves():
|
||||
return self.get_response(request)
|
||||
|
||||
async def __acall__(self, request):
|
||||
from documents.file_moves import adefer_file_moves
|
||||
|
||||
async with adefer_file_moves():
|
||||
return await self.get_response(request)
|
||||
@@ -189,6 +189,7 @@ if DEBUG:
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"paperless.middleware.FileMoveMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
|
||||
@@ -8,6 +8,8 @@ import factory
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentBarcode
|
||||
from documents.models import DocumentType
|
||||
@@ -70,6 +72,22 @@ class DocumentFactory(TypedModelFactory[Document]):
|
||||
storage_path = None
|
||||
|
||||
|
||||
class CustomFieldFactory(TypedModelFactory[CustomField]):
|
||||
class Meta:
|
||||
model = CustomField
|
||||
|
||||
name = factory.Sequence(lambda n: f"field {n}")
|
||||
data_type = CustomField.FieldDataType.STRING
|
||||
|
||||
|
||||
class CustomFieldInstanceFactory(TypedModelFactory[CustomFieldInstance]):
|
||||
class Meta:
|
||||
model = CustomFieldInstance
|
||||
|
||||
field = factory.SubFactory(CustomFieldFactory)
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
|
||||
|
||||
class DocumentBarcodeFactory(TypedModelFactory[DocumentBarcode]):
|
||||
class Meta:
|
||||
model = DocumentBarcode
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Record first acquisitions of the global media lock during a test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MediaLockAcquisition:
|
||||
"""One first (non-reentrant) acquisition of settings.MEDIA_LOCK."""
|
||||
|
||||
in_user_transaction: bool
|
||||
|
||||
|
||||
@contextmanager
|
||||
def record_media_lock_acquisitions() -> Iterator[list[MediaLockAcquisition]]:
|
||||
"""
|
||||
Record every first acquisition of settings.MEDIA_LOCK made in the block.
|
||||
|
||||
Reentrant acquisitions by a thread that already holds the lock never wait,
|
||||
so they are not recorded. Each record notes whether a real (non test case)
|
||||
transaction was open at that moment, which is the lock ordering hazard.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from filelock import BaseFileLock
|
||||
|
||||
from documents.file_moves import in_user_transaction
|
||||
|
||||
records: list[MediaLockAcquisition] = []
|
||||
original_acquire = BaseFileLock.acquire
|
||||
|
||||
def recording_acquire(self: BaseFileLock, *args: object, **kwargs: object):
|
||||
if Path(self.lock_file) == Path(settings.MEDIA_LOCK) and not self.is_locked:
|
||||
records.append(
|
||||
MediaLockAcquisition(in_user_transaction=in_user_transaction()),
|
||||
)
|
||||
return original_acquire(self, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(BaseFileLock, "acquire", recording_acquire):
|
||||
yield records
|
||||
Reference in new issue
Block a user