mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-05 14:29:22 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0abacee87 | ||
|
|
eb297fe17e | ||
|
|
8e09e44b83 | ||
|
|
eb1a1fbf44 | ||
|
|
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
|
# 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
|
## paperless-ngx 3.4.1
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+19
-10
@@ -15,7 +15,6 @@ from django.db import transaction
|
|||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from filelock import FileLock
|
|
||||||
from rest_framework.reverse import reverse
|
from rest_framework.reverse import reverse
|
||||||
|
|
||||||
from documents.barcodes import read_barcode_values
|
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_filename
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.file_handling import validate_path_in_root
|
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.loggers import LoggingMixin
|
||||||
|
from documents.media_lock import media_lock
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
@@ -605,7 +607,13 @@ class ConsumerPlugin(
|
|||||||
# now that everything is done, we can start to store the document
|
# now that everything is done, we can start to store the document
|
||||||
# in the system. This will be a transaction and reasonably fast.
|
# in the system. This will be a transaction and reasonably fast.
|
||||||
try:
|
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.
|
# store the document.
|
||||||
if self.input_doc.root_document_id:
|
if self.input_doc.root_document_id:
|
||||||
# If this is a new version of an existing document, we need
|
# 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
|
# After everything is in the database, copy the files into
|
||||||
# place. If this fails, we'll also rollback the transaction.
|
# place. If this fails, we'll also rollback the transaction.
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
with media_lock():
|
||||||
generated_filename = generate_unique_filename(document)
|
generated_filename = generate_unique_filename(document)
|
||||||
if (
|
if (
|
||||||
len(str(generated_filename))
|
len(str(generated_filename))
|
||||||
@@ -759,10 +767,9 @@ class ConsumerPlugin(
|
|||||||
document.archive_path,
|
document.archive_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Don't save with the lock active. Saving will cause the file
|
# Any filename follow-up from this save is deferred by
|
||||||
# renaming logic to acquire the lock as well.
|
# the scope above until after commit.
|
||||||
# This triggers things like file renaming
|
document.save(write_file_paths=True)
|
||||||
document.save()
|
|
||||||
|
|
||||||
if document.root_document_id:
|
if document.root_document_id:
|
||||||
document_updated.send(
|
document_updated.send(
|
||||||
@@ -803,6 +810,11 @@ class ConsumerPlugin(
|
|||||||
exception=e,
|
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()
|
||||||
|
document.refresh_from_db()
|
||||||
|
|
||||||
self.run_post_consume_script(document)
|
self.run_post_consume_script(document)
|
||||||
|
|
||||||
self.log.info(f"Document {document} consumption finished")
|
self.log.info(f"Document {document} consumption finished")
|
||||||
@@ -815,9 +827,6 @@ class ConsumerPlugin(
|
|||||||
document.id,
|
document.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return the most up to date fields
|
|
||||||
document.refresh_from_db()
|
|
||||||
|
|
||||||
return ConsumeFileSuccessResult(document_id=document.pk)
|
return ConsumeFileSuccessResult(document_id=document.pk)
|
||||||
|
|
||||||
def _parse_title_placeholders(self, title: str) -> str:
|
def _parse_title_placeholders(self, title: str) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
"""
|
||||||
|
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:
|
||||||
|
version_pks = list(
|
||||||
|
Document.global_objects.values_list("pk", flat=True).filter(
|
||||||
|
root_document_id=pk,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for version_pk in version_pks:
|
||||||
|
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 email import message_from_bytes
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from filelock import FileLock
|
|
||||||
|
from documents.media_lock import media_lock
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -48,7 +48,7 @@ def send_email(
|
|||||||
used_filenames: set[str] = set()
|
used_filenames: set[str] = set()
|
||||||
|
|
||||||
# Something could be renaming the file concurrently so it can't be attached
|
# 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:
|
for attachment in attachments:
|
||||||
filename = _get_unique_filename(
|
filename = _get_unique_filename(
|
||||||
attachment.friendly_name,
|
attachment.friendly_name,
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ from django.core import serializers
|
|||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from filelock import FileLock
|
|
||||||
from guardian.models import GroupObjectPermission
|
from guardian.models import GroupObjectPermission
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
|
|
||||||
|
from documents.media_lock import media_lock
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
@@ -328,7 +329,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Prevent any ongoing changes in the documents while exporting
|
# Prevent any ongoing changes in the documents while exporting
|
||||||
with FileLock(settings.MEDIA_LOCK), sink:
|
with media_lock(), sink:
|
||||||
self.dump(sink)
|
self.dump(sink)
|
||||||
|
|
||||||
def dump(self, sink: ExportSink) -> None:
|
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 import Model
|
||||||
from django.db.models.signals import m2m_changed
|
from django.db.models.signals import m2m_changed
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from filelock import FileLock
|
|
||||||
from guardian.shortcuts import clear_ct_cache
|
from guardian.shortcuts import clear_ct_cache
|
||||||
|
|
||||||
from documents.export.compression import compress_type_readable
|
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.file_handling import create_source_path_directory
|
||||||
from documents.management.commands.base import PaperlessCommand
|
from documents.management.commands.base import PaperlessCommand
|
||||||
from documents.management.commands.mixins import CryptMixin
|
from documents.management.commands.mixins import CryptMixin
|
||||||
|
from documents.media_lock import media_lock
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
@@ -644,7 +644,7 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
else:
|
else:
|
||||||
archive_path = None
|
archive_path = None
|
||||||
|
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
with media_lock():
|
||||||
if Path(document.source_path).is_file():
|
if Path(document.source_path).is_file():
|
||||||
raise FileExistsError(document.source_path)
|
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.",
|
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)
|
bundle_target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
copy_file_with_basic_stats(
|
copy_file_with_basic_stats(
|
||||||
bundle_source_path,
|
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.management.commands.base import PaperlessCommand
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
@@ -11,5 +10,9 @@ class Command(PaperlessCommand):
|
|||||||
supports_multiprocessing = False
|
supports_multiprocessing = False
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
for document in self.track(Document.objects.all(), description="Renaming..."):
|
for document in self.track(
|
||||||
post_save.send(Document, instance=document, created=False)
|
# move_files_for_document handles a root's versions itself
|
||||||
|
Document.objects.filter(root_document__isnull=True).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 rich.text import Text
|
||||||
|
|
||||||
from documents.classifier import load_classifier
|
from documents.classifier import load_classifier
|
||||||
|
from documents.file_moves import defer_file_moves
|
||||||
from documents.management.commands.base import PaperlessCommand
|
from documents.management.commands.base import PaperlessCommand
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.signals.handlers import set_correspondent
|
from documents.signals.handlers import set_correspondent
|
||||||
@@ -273,61 +274,63 @@ class Command(PaperlessCommand):
|
|||||||
description="Retagging...",
|
description="Retagging...",
|
||||||
stats_renderer=render_stats,
|
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:
|
if do_correspondent:
|
||||||
correspondent = set_correspondent(
|
correspondent = set_correspondent(
|
||||||
None,
|
None,
|
||||||
document,
|
document,
|
||||||
classifier=classifier,
|
classifier=classifier,
|
||||||
replace=overwrite,
|
replace=overwrite,
|
||||||
use_first=use_first,
|
use_first=use_first,
|
||||||
dry_run=suggest,
|
dry_run=suggest,
|
||||||
)
|
)
|
||||||
if correspondent is not None:
|
if correspondent is not None:
|
||||||
stats.correspondents += 1
|
stats.correspondents += 1
|
||||||
suggestion.correspondent = correspondent
|
suggestion.correspondent = correspondent
|
||||||
|
|
||||||
if do_document_type:
|
if do_document_type:
|
||||||
document_type = set_document_type(
|
document_type = set_document_type(
|
||||||
None,
|
None,
|
||||||
document,
|
document,
|
||||||
classifier=classifier,
|
classifier=classifier,
|
||||||
replace=overwrite,
|
replace=overwrite,
|
||||||
use_first=use_first,
|
use_first=use_first,
|
||||||
dry_run=suggest,
|
dry_run=suggest,
|
||||||
)
|
)
|
||||||
if document_type is not None:
|
if document_type is not None:
|
||||||
stats.document_types += 1
|
stats.document_types += 1
|
||||||
suggestion.document_type = document_type
|
suggestion.document_type = document_type
|
||||||
|
|
||||||
if do_tags:
|
if do_tags:
|
||||||
tags_to_add, tags_to_remove = set_tags(
|
tags_to_add, tags_to_remove = set_tags(
|
||||||
None,
|
None,
|
||||||
document,
|
document,
|
||||||
classifier=classifier,
|
classifier=classifier,
|
||||||
replace=overwrite,
|
replace=overwrite,
|
||||||
dry_run=suggest,
|
dry_run=suggest,
|
||||||
)
|
)
|
||||||
stats.tags_added += len(tags_to_add)
|
stats.tags_added += len(tags_to_add)
|
||||||
stats.tags_removed += len(tags_to_remove)
|
stats.tags_removed += len(tags_to_remove)
|
||||||
suggestion.tags_to_add = frozenset(tags_to_add)
|
suggestion.tags_to_add = frozenset(tags_to_add)
|
||||||
suggestion.tags_to_remove = frozenset(tags_to_remove)
|
suggestion.tags_to_remove = frozenset(tags_to_remove)
|
||||||
|
|
||||||
if do_storage_path:
|
if do_storage_path:
|
||||||
storage_path = set_storage_path(
|
storage_path = set_storage_path(
|
||||||
None,
|
None,
|
||||||
document,
|
document,
|
||||||
classifier=classifier,
|
classifier=classifier,
|
||||||
replace=overwrite,
|
replace=overwrite,
|
||||||
use_first=use_first,
|
use_first=use_first,
|
||||||
dry_run=suggest,
|
dry_run=suggest,
|
||||||
)
|
)
|
||||||
if storage_path is not None:
|
if storage_path is not None:
|
||||||
stats.storage_paths += 1
|
stats.storage_paths += 1
|
||||||
suggestion.storage_path = storage_path
|
suggestion.storage_path = storage_path
|
||||||
|
|
||||||
stats.documents_processed += 1
|
stats.documents_processed += 1
|
||||||
|
|
||||||
if suggest:
|
if suggest:
|
||||||
suggestions.append(suggestion)
|
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 datetime
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
import pathvalidate
|
import pathvalidate
|
||||||
@@ -157,6 +158,12 @@ class StoragePath(MatchingModel):
|
|||||||
|
|
||||||
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
||||||
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
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)
|
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
|
||||||
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
|
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
|
||||||
|
|
||||||
@@ -368,6 +375,31 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
res += f" {self.title}"
|
res += f" {self.title}"
|
||||||
return res
|
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"]:
|
def get_effective_barcodes(self) -> list["DocumentBarcode"]:
|
||||||
"""
|
"""
|
||||||
Returns the stored barcodes for the document, like
|
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.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import DatabaseError
|
|
||||||
from django.db import close_old_connections
|
from django.db import close_old_connections
|
||||||
from django.db import connections
|
from django.db import connections
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from filelock import FileLock
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from documents import matching
|
from documents import matching
|
||||||
from documents.caching import clear_document_caches
|
|
||||||
from documents.caching import invalidate_llm_suggestions_cache
|
from documents.caching import invalidate_llm_suggestions_cache
|
||||||
from documents.caching import invalidate_suggestions_cache
|
from documents.caching import invalidate_suggestions_cache
|
||||||
from documents.data_models import ConsumableDocument
|
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 delete_empty_directories
|
||||||
from documents.file_handling import generate_filename
|
from documents.file_moves import move_files_for_document
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_moves import request_file_move
|
||||||
|
from documents.media_lock import media_lock
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
@@ -55,7 +52,6 @@ from documents.models import WorkflowTrigger
|
|||||||
from documents.permissions import restrict_queryset_to_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
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.utils import copy_file_with_basic_stats
|
||||||
from documents.workflows.actions import build_workflow_action_context
|
from documents.workflows.actions import build_workflow_action_context
|
||||||
from documents.workflows.actions import execute_email_action
|
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
|
# see empty_trash in documents/tasks.py for signal handling
|
||||||
def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
with media_lock():
|
||||||
if settings.EMPTY_TRASH_DIR:
|
if settings.EMPTY_TRASH_DIR:
|
||||||
# Find a non-conflicting filename in case a document with the same
|
# Find a non-conflicting filename in case a document with the same
|
||||||
# name was moved to trash earlier
|
# 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:
|
def _filename_template_uses_custom_fields(doc: Document) -> bool:
|
||||||
template = None
|
template = None
|
||||||
if doc.storage_path is not None:
|
if doc.storage_path is not None:
|
||||||
@@ -438,235 +423,24 @@ def update_filename_and_move_files(
|
|||||||
instance: Document | CustomFieldInstance,
|
instance: Document | CustomFieldInstance,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
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 kwargs.get("reverse"):
|
||||||
|
# tag.documents.add(doc): instance is the Tag, pk_set holds Document pks.
|
||||||
|
for document in Document.objects.filter(pk__in=kwargs.get("pk_set") or ()):
|
||||||
|
request_file_move(document)
|
||||||
|
return
|
||||||
|
|
||||||
if isinstance(instance, CustomFieldInstance):
|
if isinstance(instance, CustomFieldInstance):
|
||||||
if not _filename_template_uses_custom_fields(instance.document):
|
if not _filename_template_uses_custom_fields(instance.document):
|
||||||
return
|
return
|
||||||
instance = instance.document
|
instance = instance.document
|
||||||
|
|
||||||
def validate_move(instance, old_path: Path, new_path: Path, root: Path) -> None:
|
request_file_move(instance)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
@@ -691,7 +465,8 @@ def process_cf_select_update(custom_field: CustomField) -> None:
|
|||||||
|
|
||||||
for cf_instance in custom_field.fields.select_related("document").iterator():
|
for cf_instance in custom_field.fields.select_related("document").iterator():
|
||||||
# Update the filename and move files if necessary
|
# 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
|
# 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 import transaction
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from filelock import FileLock
|
|
||||||
|
|
||||||
from documents import sanity_checker
|
from documents import sanity_checker
|
||||||
from documents.barcodes import BarcodePlugin
|
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 create_source_path_directory
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.matching import prefilter_documents_by_workflowtrigger
|
from documents.matching import prefilter_documents_by_workflowtrigger
|
||||||
|
from documents.media_lock import media_lock
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
@@ -310,6 +310,7 @@ def sanity_check(*, raise_on_error: bool = True) -> str:
|
|||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def bulk_update_documents(document_ids) -> None:
|
def bulk_update_documents(document_ids) -> None:
|
||||||
|
from documents.file_moves import flush_pending_file_moves
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
document_ids = list(document_ids)
|
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
|
skip_ai_index=True, # bulk path calls update_llm_index once below
|
||||||
)
|
)
|
||||||
post_save.send(Document, instance=doc, created=False)
|
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:
|
with get_backend().batch_update() as batch:
|
||||||
batch.add_or_update_ids(document_ids)
|
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)
|
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)
|
oldDocument = Document.objects.get(pk=document.pk)
|
||||||
if parser.get_archive_path():
|
if parser.get_archive_path():
|
||||||
checksum = compute_checksum(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(),
|
modified=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
if parser.get_archive_path():
|
||||||
if parser.get_archive_path():
|
create_source_path_directory(document.archive_path)
|
||||||
create_source_path_directory(document.archive_path)
|
shutil.move(parser.get_archive_path(), document.archive_path)
|
||||||
shutil.move(parser.get_archive_path(), document.archive_path)
|
shutil.move(thumbnail, document.thumbnail_path)
|
||||||
shutil.move(thumbnail, document.thumbnail_path)
|
|
||||||
|
|
||||||
document.refresh_from_db()
|
document.refresh_from_db()
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -531,7 +540,11 @@ def empty_trash(doc_ids=None) -> None:
|
|||||||
deleted_document_ids = list(documents.values_list("id", flat=True))
|
deleted_document_ids = list(documents.values_list("id", flat=True))
|
||||||
# Temporarily connect the cleanup handler
|
# Temporarily connect the cleanup handler
|
||||||
models.signals.post_delete.connect(cleanup_document_deletion, sender=Document)
|
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")
|
logger.info(f"Deleted {len(deleted_document_ids)} documents from trash")
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
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.
|
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(
|
scheduled_workflows = get_workflows_for_trigger(
|
||||||
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||||
)
|
)
|
||||||
@@ -688,6 +703,9 @@ def check_scheduled_workflows() -> None:
|
|||||||
sender=None,
|
sender=None,
|
||||||
document=document,
|
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:
|
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
||||||
|
|||||||
@@ -739,7 +739,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
self.doc1.archive_filename = sample1_archive
|
self.doc1.archive_filename = sample1_archive
|
||||||
self.doc1.save()
|
self.doc1.save(write_file_paths=True)
|
||||||
self.doc2 = Document.objects.create(
|
self.doc2 = Document.objects.create(
|
||||||
checksum="B",
|
checksum="B",
|
||||||
title="B",
|
title="B",
|
||||||
@@ -749,7 +749,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
created=date(2023, 1, 2),
|
created=date(2023, 1, 2),
|
||||||
)
|
)
|
||||||
self.doc2.archive_filename = sample2_archive
|
self.doc2.archive_filename = sample2_archive
|
||||||
self.doc2.save()
|
self.doc2.save(write_file_paths=True)
|
||||||
self.doc3 = Document.objects.create(
|
self.doc3 = Document.objects.create(
|
||||||
checksum="C",
|
checksum="C",
|
||||||
title="C",
|
title="C",
|
||||||
@@ -774,7 +774,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
created=date(2023, 1, 3),
|
created=date(2023, 1, 3),
|
||||||
)
|
)
|
||||||
self.img_doc.archive_filename = img_doc_archive
|
self.img_doc.archive_filename = img_doc_archive
|
||||||
self.img_doc.save()
|
self.img_doc.save(write_file_paths=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fake_decrypt(
|
def fake_decrypt(
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
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.parsers import ParseError
|
||||||
from documents.plugins.helpers import ProgressStatusOptions
|
from documents.plugins.helpers import ProgressStatusOptions
|
||||||
from documents.tasks import sanity_check
|
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 TagFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.fakes.progress import FakeProgressManager
|
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.permissions import grant_object
|
||||||
from paperless_testing.samples import MULTI_PAGE_DIGITAL_PDF
|
from paperless_testing.samples import MULTI_PAGE_DIGITAL_PDF
|
||||||
from paperless_testing.samples import MULTI_PAGE_IMAGES_PDF
|
from paperless_testing.samples import MULTI_PAGE_IMAGES_PDF
|
||||||
@@ -221,6 +226,160 @@ class TestConsumer(
|
|||||||
shutil.copy(src, dst)
|
shutil.copy(src, dst)
|
||||||
return 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(
|
@override_settings(
|
||||||
FILENAME_FORMAT=None,
|
FILENAME_FORMAT=None,
|
||||||
TIME_ZONE="America/Chicago",
|
TIME_ZONE="America/Chicago",
|
||||||
@@ -750,7 +909,7 @@ class TestConsumer(
|
|||||||
FILENAME_FORMAT="{correspondent}/{title}",
|
FILENAME_FORMAT="{correspondent}/{title}",
|
||||||
ARCHIVE_FILE_GENERATION="always",
|
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:
|
def testFilenameHandlingUnstableFormat(self, m) -> None:
|
||||||
filenames = ["this", "that", "now this", "i cannot decide"]
|
filenames = ["this", "that", "now this", "i cannot decide"]
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
# Ensure that filename is properly generated
|
# Ensure that filename is properly generated
|
||||||
self.assertEqual(document.filename, Path("none/none.pdf"))
|
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
|
# test that creating dirs for the source_path creates the correct directory
|
||||||
create_source_path_directory(document.source_path)
|
create_source_path_directory(document.source_path)
|
||||||
@@ -190,7 +190,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
|
|
||||||
# Set a correspondent and save the document
|
# Set a correspondent and save the document
|
||||||
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
|
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
|
||||||
document.save()
|
document.save(write_file_paths=True)
|
||||||
|
|
||||||
# Check proper handling of files
|
# Check proper handling of files
|
||||||
self.assertIsFile(
|
self.assertIsFile(
|
||||||
@@ -232,7 +232,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
disable_auditlog(),
|
disable_auditlog(),
|
||||||
):
|
):
|
||||||
m.side_effect = DatabaseError()
|
m.side_effect = DatabaseError()
|
||||||
document.save()
|
document.save(write_file_paths=True)
|
||||||
|
|
||||||
# Check proper handling of files
|
# Check proper handling of files
|
||||||
self.assertIsFile(document.source_path)
|
self.assertIsFile(document.source_path)
|
||||||
@@ -336,7 +336,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
|
|
||||||
# Ensure that filename is properly generated
|
# Ensure that filename is properly generated
|
||||||
document.filename = generate_filename(document)
|
document.filename = generate_filename(document)
|
||||||
document.save()
|
document.save(write_file_paths=True)
|
||||||
self.assertEqual(document.filename, "none/none.pdf")
|
self.assertEqual(document.filename, "none/none.pdf")
|
||||||
|
|
||||||
create_source_path_directory(document.source_path)
|
create_source_path_directory(document.source_path)
|
||||||
@@ -362,7 +362,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
|
|
||||||
# Ensure that filename is properly generated
|
# Ensure that filename is properly generated
|
||||||
document.filename = generate_filename(document)
|
document.filename = generate_filename(document)
|
||||||
document.save()
|
document.save(write_file_paths=True)
|
||||||
self.assertEqual(document.filename, "none/none.pdf")
|
self.assertEqual(document.filename, "none/none.pdf")
|
||||||
|
|
||||||
create_source_path_directory(document.source_path)
|
create_source_path_directory(document.source_path)
|
||||||
@@ -385,7 +385,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
|
|
||||||
document.save()
|
document.save()
|
||||||
document.filename = generate_filename(document)
|
document.filename = generate_filename(document)
|
||||||
document.save()
|
document.save(write_file_paths=True)
|
||||||
create_source_path_directory(document.source_path)
|
create_source_path_directory(document.source_path)
|
||||||
Path(document.source_path).touch()
|
Path(document.source_path).touch()
|
||||||
document.delete()
|
document.delete()
|
||||||
@@ -421,7 +421,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
|
|
||||||
# Set a correspondent and save the document
|
# Set a correspondent and save the document
|
||||||
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
|
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
|
||||||
document.save()
|
document.save(write_file_paths=True)
|
||||||
|
|
||||||
# Check proper handling of files
|
# Check proper handling of files
|
||||||
self.assertIsDir(settings.ORIGINALS_DIR / "test")
|
self.assertIsDir(settings.ORIGINALS_DIR / "test")
|
||||||
@@ -476,7 +476,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
def test_filename_relative(self) -> None:
|
def test_filename_relative(self) -> None:
|
||||||
doc = Document.objects.create(title="doc1", mime_type="application/pdf")
|
doc = Document.objects.create(title="doc1", mime_type="application/pdf")
|
||||||
doc.filename = generate_filename(doc)
|
doc.filename = generate_filename(doc)
|
||||||
doc.save()
|
doc.save(write_file_paths=True)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
doc.source_path,
|
doc.source_path,
|
||||||
@@ -532,7 +532,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
|
|
||||||
# Ensure that filename is properly generated
|
# Ensure that filename is properly generated
|
||||||
document.filename = generate_filename(document)
|
document.filename = generate_filename(document)
|
||||||
document.save()
|
document.save(write_file_paths=True)
|
||||||
self.assertEqual(document.filename, "none/none/none.pdf")
|
self.assertEqual(document.filename, "none/none/none.pdf")
|
||||||
create_source_path_directory(document.source_path)
|
create_source_path_directory(document.source_path)
|
||||||
Path(document.source_path).touch()
|
Path(document.source_path).touch()
|
||||||
@@ -620,13 +620,13 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
Path(document.source_path).touch()
|
Path(document.source_path).touch()
|
||||||
Path(document2.source_path).touch()
|
Path(document2.source_path).touch()
|
||||||
document.filename = "0000001.pdf"
|
document.filename = "0000001.pdf"
|
||||||
document.save()
|
document.save(write_file_paths=True)
|
||||||
|
|
||||||
self.assertIsFile(document.source_path)
|
self.assertIsFile(document.source_path)
|
||||||
self.assertEqual(document.filename, "qwe.pdf")
|
self.assertEqual(document.filename, "qwe.pdf")
|
||||||
|
|
||||||
document2.filename = "0000002.pdf"
|
document2.filename = "0000002.pdf"
|
||||||
document2.save()
|
document2.save(write_file_paths=True)
|
||||||
|
|
||||||
self.assertIsFile(document.source_path)
|
self.assertIsFile(document.source_path)
|
||||||
self.assertEqual(document2.filename, "qwe_01.pdf")
|
self.assertEqual(document2.filename, "qwe_01.pdf")
|
||||||
@@ -656,19 +656,17 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
self.assertEqual(document2.filename, "qwe.pdf")
|
self.assertEqual(document2.filename, "qwe.pdf")
|
||||||
|
|
||||||
@override_settings(FILENAME_FORMAT="{title}")
|
@override_settings(FILENAME_FORMAT="{title}")
|
||||||
@mock.patch("documents.signals.handlers.Document.objects.filter")
|
@mock.patch("documents.file_moves.Document.global_objects.filter")
|
||||||
@mock.patch("documents.signals.handlers.shutil.move")
|
@mock.patch("documents.file_moves.shutil.move")
|
||||||
def test_no_move_only_save(self, mock_move, mock_filter) -> None:
|
def test_no_move_only_save(self, mock_move, mock_filter) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A document with a filename
|
- A document with a filename
|
||||||
- The document is saved
|
|
||||||
- The filename is not changed
|
|
||||||
WHEN:
|
WHEN:
|
||||||
- The document is saved
|
- The document is saved without a change that affects its path
|
||||||
THEN:
|
THEN:
|
||||||
- The document modified date is updated
|
- The document modified date is updated by the save itself
|
||||||
- The document is not moved
|
- The file is not moved and the mover writes nothing
|
||||||
"""
|
"""
|
||||||
with disable_auditlog():
|
with disable_auditlog():
|
||||||
doc = Document.objects.create(
|
doc = Document.objects.create(
|
||||||
@@ -686,7 +684,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
doc.save()
|
doc.save()
|
||||||
doc.refresh_from_db()
|
doc.refresh_from_db()
|
||||||
|
|
||||||
mock_filter.assert_called()
|
mock_filter.assert_not_called()
|
||||||
self.assertNotEqual(original_modified, doc.modified)
|
self.assertNotEqual(original_modified, doc.modified)
|
||||||
mock_move.assert_not_called()
|
mock_move.assert_not_called()
|
||||||
|
|
||||||
@@ -694,7 +692,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
FILENAME_FORMAT="{{title}}_{{custom_fields|get_cf_value('test')}}",
|
FILENAME_FORMAT="{{title}}_{{custom_fields|get_cf_value('test')}}",
|
||||||
CELERY_TASK_ALWAYS_EAGER=True,
|
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:
|
def test_select_cf_updated(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -2045,7 +2043,7 @@ class TestCustomFieldFilenameUpdates(
|
|||||||
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
Path(self.doc.source_path).touch()
|
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
|
m.side_effect = generate_unique_filename
|
||||||
self.cfi.value_text = "updated"
|
self.cfi.value_text = "updated"
|
||||||
self.cfi.save()
|
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).parent.mkdir(parents=True, exist_ok=True)
|
||||||
Path(self.doc.source_path).touch()
|
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
|
m.side_effect = generate_unique_filename
|
||||||
self.cfi.value_text = "updated"
|
self.cfi.value_text = "updated"
|
||||||
self.cfi.save()
|
self.cfi.save()
|
||||||
@@ -2145,7 +2143,7 @@ def test_refresh_failure_surfaces_database_error(
|
|||||||
GIVEN:
|
GIVEN:
|
||||||
- A document with a stored filename
|
- A document with a stored filename
|
||||||
WHEN:
|
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:
|
THEN:
|
||||||
- The database error is raised, not an unbound variable error from the rollback
|
- 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 (
|
with (
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
Document,
|
Document.global_objects,
|
||||||
"refresh_from_db",
|
"get",
|
||||||
side_effect=DatabaseError("database is locked"),
|
side_effect=DatabaseError("database is locked"),
|
||||||
),
|
),
|
||||||
pytest.raises(DatabaseError, match="database is locked"),
|
pytest.raises(DatabaseError, match="database is locked"),
|
||||||
|
|||||||
@@ -0,0 +1,945 @@
|
|||||||
|
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_requests_a_move_per_document(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A tag
|
||||||
|
WHEN:
|
||||||
|
- A document is added through the tag's reverse relation
|
||||||
|
THEN:
|
||||||
|
- A move is requested for the added document, never the tag
|
||||||
|
"""
|
||||||
|
tag = TagFactory()
|
||||||
|
request = mocker.patch("documents.signals.handlers.request_file_move")
|
||||||
|
|
||||||
|
tag.documents.add(document)
|
||||||
|
|
||||||
|
request.assert_called_once()
|
||||||
|
assert request.call_args.args[0].pk == document.pk
|
||||||
|
|
||||||
|
|
||||||
|
@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 pytest_mock import MockerFixture
|
||||||
|
|
||||||
from documents.file_handling import generate_filename
|
from documents.file_handling import generate_filename
|
||||||
|
from documents.file_moves import move_files_for_document
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tasks import update_document_content_maybe_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
|
from paperless_testing.locks import record_media_lock_acquisitions
|
||||||
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
|
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
|
||||||
|
|
||||||
sample_file: Path = 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.assertTrue(filecmp.cmp(sample_file, doc.source_path))
|
||||||
self.assertEqual(doc.archive_filename, "none/A.pdf")
|
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:
|
def test_unknown_mime_type(self) -> None:
|
||||||
doc = self.make_models()
|
doc = self.make_models()
|
||||||
doc.mime_type = "sdgfh"
|
doc.mime_type = "sdgfh"
|
||||||
@@ -236,7 +260,7 @@ class TestRenamer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
doc = Document.objects.create(title="test", mime_type="image/jpeg")
|
doc = Document.objects.create(title="test", mime_type="image/jpeg")
|
||||||
doc.filename = generate_filename(doc)
|
doc.filename = generate_filename(doc)
|
||||||
doc.archive_filename = generate_filename(doc, archive_filename=True)
|
doc.archive_filename = generate_filename(doc, archive_filename=True)
|
||||||
doc.save()
|
doc.save(write_file_paths=True)
|
||||||
|
|
||||||
Path(doc.source_path).touch()
|
Path(doc.source_path).touch()
|
||||||
Path(doc.archive_path).touch()
|
Path(doc.archive_path).touch()
|
||||||
@@ -253,6 +277,42 @@ class TestRenamer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
self.assertIsFile(doc2.source_path)
|
self.assertIsFile(doc2.source_path)
|
||||||
self.assertIsFile(doc2.archive_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
|
@pytest.mark.management
|
||||||
class TestCreateClassifier:
|
class TestCreateClassifier:
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ Tests for the document_retagger management command.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
|
from documents.file_moves import _active_collector
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
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 StoragePathFactory
|
||||||
from paperless_testing.factories import TagFactory
|
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
|
# Module-level type aliases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -484,3 +493,69 @@ class TestRetaggerEdgeCases(DirectoriesMixin):
|
|||||||
|
|
||||||
assert d_first.tags.count() == 0
|
assert d_first.tags.count() == 0
|
||||||
assert d_unrelated.tags.count() == 2
|
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
|
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 Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
@@ -22,3 +25,101 @@ class TestDocument:
|
|||||||
assert Correspondent.objects.count() == 0
|
assert Correspondent.objects.count() == 0
|
||||||
assert Document.objects.count() == 1
|
assert Document.objects.count() == 1
|
||||||
assert Document.objects.first().correspondent is None
|
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:
|
class TestCheckSanityArchive:
|
||||||
def test_checksum_without_filename(self, sample_doc: Document) -> None:
|
def test_checksum_without_filename(self, sample_doc: Document) -> None:
|
||||||
sample_doc.archive_filename = None
|
sample_doc.archive_filename = None
|
||||||
sample_doc.save()
|
sample_doc.save(write_file_paths=True)
|
||||||
messages = check_sanity()
|
messages = check_sanity()
|
||||||
assert messages.has_error
|
assert messages.has_error
|
||||||
assert any(
|
assert any(
|
||||||
@@ -185,7 +185,7 @@ class TestCheckSanityArchive:
|
|||||||
Path(sample_doc.archive_path).unlink()
|
Path(sample_doc.archive_path).unlink()
|
||||||
sample_doc.archive_checksum = None
|
sample_doc.archive_checksum = None
|
||||||
sample_doc.archive_filename = None
|
sample_doc.archive_filename = None
|
||||||
sample_doc.save()
|
sample_doc.save(write_file_paths=True)
|
||||||
messages = check_sanity()
|
messages = check_sanity()
|
||||||
assert not messages.has_error
|
assert not messages.has_error
|
||||||
|
|
||||||
|
|||||||
@@ -366,7 +366,7 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
|
|||||||
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
|
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
|
||||||
if archive:
|
if archive:
|
||||||
self.document.archive_filename = f"{self.document.pk:07}.pdf"
|
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
|
path = self.document.archive_path
|
||||||
assert path is not None
|
assert path is not None
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ from documents.data_models import ConsumableDocument
|
|||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
from documents.file_handling import format_filename
|
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 CorrespondentFilterSet
|
||||||
from documents.filters import CustomFieldFilterSet
|
from documents.filters import CustomFieldFilterSet
|
||||||
from documents.filters import DocumentFilterSet
|
from documents.filters import DocumentFilterSet
|
||||||
@@ -1264,6 +1265,8 @@ class DocumentViewSet(
|
|||||||
)
|
)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
self.perform_update(serializer)
|
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:
|
if content_updated and content_doc.id != root_doc.id:
|
||||||
content_doc.content = (
|
content_doc.content = (
|
||||||
|
|||||||
+23
-1
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery.contrib.django.task import DjangoTask
|
||||||
from celery.signals import worker_process_init
|
from celery.signals import worker_process_init
|
||||||
from kombu.serialization import register
|
from kombu.serialization import register
|
||||||
|
|
||||||
@@ -26,7 +27,28 @@ register(
|
|||||||
content_encoding="binary",
|
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
|
# Using a string here means the worker doesn't have to serialize
|
||||||
# the configuration object to child processes.
|
# 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 compression_middleware.middleware import CompressionMiddleware
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@@ -30,3 +32,35 @@ class ApiVersionMiddleware:
|
|||||||
response["X-Version"] = version.__full_version_str__
|
response["X-Version"] = version.__full_version_str__
|
||||||
|
|
||||||
return response
|
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 = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"paperless.middleware.FileMoveMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import factory
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentBarcode
|
from documents.models import DocumentBarcode
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
@@ -70,6 +72,22 @@ class DocumentFactory(TypedModelFactory[Document]):
|
|||||||
storage_path = None
|
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 DocumentBarcodeFactory(TypedModelFactory[DocumentBarcode]):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DocumentBarcode
|
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