mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-03 06:40:31 +00:00
The `created` date fallback derived a naive datetime from a file's mtime using the OS-local zone, then labeled it as the configured TIME_ZONE without converting. When the OS-local zone and TIME_ZONE disagree, or when the C library can't resolve zoneinfo at all (as in some sandboxed environments, where it silently falls back to UTC), the resulting date can land on the wrong calendar day. Convert the timestamp directly into the target zone with `tz=` on fromtimestamp() instead of a naive conversion plus make_aware().
1130 lines
43 KiB
Python
1130 lines
43 KiB
Python
import datetime
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from enum import StrEnum
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
from typing import Final
|
|
|
|
import magic
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import User
|
|
from django.db import transaction
|
|
from django.db.models import Max
|
|
from django.db.models import Q
|
|
from django.utils import timezone
|
|
from filelock import FileLock
|
|
from rest_framework.reverse import reverse
|
|
|
|
from documents.classifier import load_classifier
|
|
from documents.data_models import ConsumableDocument
|
|
from documents.data_models import ConsumeFileSuccessResult
|
|
from documents.data_models import DocumentMetadataOverrides
|
|
from documents.file_handling import create_source_path_directory
|
|
from documents.file_handling import generate_filename
|
|
from documents.file_handling import generate_unique_filename
|
|
from documents.file_handling import validate_path_in_root
|
|
from documents.loggers import LoggingMixin
|
|
from documents.models import Correspondent
|
|
from documents.models import CustomField
|
|
from documents.models import CustomFieldInstance
|
|
from documents.models import Document
|
|
from documents.models import DocumentType
|
|
from documents.models import StoragePath
|
|
from documents.models import Tag
|
|
from documents.models import WorkflowTrigger
|
|
from documents.parsers import ParseError
|
|
from documents.permissions import set_permissions_for_object
|
|
from documents.plugins.base import AlwaysRunPluginMixin
|
|
from documents.plugins.base import ConsumeTaskPlugin
|
|
from documents.plugins.base import NoCleanupPluginMixin
|
|
from documents.plugins.base import NoSetupPluginMixin
|
|
from documents.plugins.date_parsing import get_date_parser
|
|
from documents.plugins.helpers import ProgressManager
|
|
from documents.plugins.helpers import ProgressStatusOptions
|
|
from documents.signals import document_consumption_finished
|
|
from documents.signals import document_consumption_started
|
|
from documents.signals import document_updated
|
|
from documents.signals.handlers import run_workflows
|
|
from documents.templating.workflows import parse_w_workflow_placeholders
|
|
from documents.utils import compute_checksum
|
|
from documents.utils import copy_basic_file_stats
|
|
from documents.utils import copy_file_with_basic_stats
|
|
from documents.utils import run_subprocess
|
|
from paperless.config import OcrConfig
|
|
from paperless.config import RemoteOCRConfig
|
|
from paperless.models import ArchiveFileGenerationChoices
|
|
from paperless.parsers import ParserContext
|
|
from paperless.parsers import ParserProtocol
|
|
from paperless.parsers.registry import get_parser_registry
|
|
from paperless.parsers.utils import pdf_born_digital_text
|
|
|
|
LOGGING_NAME: Final[str] = "paperless.consumer"
|
|
|
|
|
|
class WorkflowTriggerPlugin(
|
|
NoCleanupPluginMixin,
|
|
NoSetupPluginMixin,
|
|
AlwaysRunPluginMixin,
|
|
ConsumeTaskPlugin,
|
|
):
|
|
NAME: str = "WorkflowTriggerPlugin"
|
|
|
|
def run(self) -> str | None:
|
|
"""
|
|
Get overrides from matching workflows
|
|
"""
|
|
overrides, msg = run_workflows(
|
|
trigger_type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
document=self.input_doc,
|
|
logging_group=None,
|
|
overrides=DocumentMetadataOverrides(),
|
|
)
|
|
if overrides:
|
|
self.metadata.update(overrides)
|
|
return msg
|
|
|
|
|
|
class ConsumerError(Exception):
|
|
pass
|
|
|
|
|
|
class ConsumeFileDuplicateError(ConsumerError):
|
|
"""Raised when a file is rejected because it duplicates an existing document."""
|
|
|
|
def __init__(self, message: str, duplicate_id: int, *, in_trash: bool) -> None:
|
|
super().__init__(message)
|
|
self.duplicate_id = duplicate_id
|
|
self.in_trash = in_trash
|
|
|
|
|
|
class ConsumerStatusShortMessage(StrEnum):
|
|
DOCUMENT_ALREADY_EXISTS = "document_already_exists"
|
|
DOCUMENT_ALREADY_EXISTS_IN_TRASH = "document_already_exists_in_trash"
|
|
ASN_ALREADY_EXISTS = "asn_already_exists"
|
|
ASN_ALREADY_EXISTS_IN_TRASH = "asn_already_exists_in_trash"
|
|
ASN_RANGE = "asn_value_out_of_range"
|
|
FILE_NOT_FOUND = "file_not_found"
|
|
PRE_CONSUME_SCRIPT_NOT_FOUND = "pre_consume_script_not_found"
|
|
PRE_CONSUME_SCRIPT_ERROR = "pre_consume_script_error"
|
|
POST_CONSUME_SCRIPT_NOT_FOUND = "post_consume_script_not_found"
|
|
POST_CONSUME_SCRIPT_ERROR = "post_consume_script_error"
|
|
NEW_FILE = "new_file"
|
|
UNSUPPORTED_TYPE = "unsupported_type"
|
|
PARSING_DOCUMENT = "parsing_document"
|
|
GENERATING_THUMBNAIL = "generating_thumbnail"
|
|
PARSE_DATE = "parse_date"
|
|
SAVE_DOCUMENT = "save_document"
|
|
FINISHED = "finished"
|
|
FAILED = "failed"
|
|
|
|
|
|
def should_produce_archive(
|
|
parser: "ParserProtocol",
|
|
mime_type: str,
|
|
document_path: Path,
|
|
log: logging.Logger | None = None,
|
|
) -> bool:
|
|
"""Return True if a PDF/A archive should be produced for this document.
|
|
|
|
IMPORTANT: *parser* must be an instantiated parser, not the class.
|
|
``requires_pdf_rendition`` and ``can_produce_archive`` are instance
|
|
``@property`` methods — accessing them on the class returns the descriptor
|
|
(always truthy).
|
|
"""
|
|
_log = log or logging.getLogger(LOGGING_NAME)
|
|
|
|
# Must produce a PDF so the frontend can display the original format at all.
|
|
if parser.requires_pdf_rendition:
|
|
_log.debug("Archive: yes - parser requires PDF rendition for frontend display")
|
|
return True
|
|
|
|
# Parser cannot produce an archive (e.g. TextDocumentParser).
|
|
if not parser.can_produce_archive:
|
|
_log.debug("Archive: no - parser cannot produce archives")
|
|
return False
|
|
|
|
generation = OcrConfig().archive_file_generation
|
|
|
|
if generation == ArchiveFileGenerationChoices.ALWAYS:
|
|
_log.debug("Archive: yes - ARCHIVE_FILE_GENERATION=always")
|
|
return True
|
|
if generation == ArchiveFileGenerationChoices.NEVER:
|
|
_log.debug("Archive: no - ARCHIVE_FILE_GENERATION=never")
|
|
return False
|
|
|
|
# auto: produce archives for scanned/image documents; skip for born-digital PDFs.
|
|
if mime_type.startswith("image/"):
|
|
_log.debug("Archive: yes - image document, ARCHIVE_FILE_GENERATION=auto")
|
|
return True
|
|
if mime_type == "application/pdf":
|
|
text, born_digital = pdf_born_digital_text(document_path, log=_log)
|
|
text_length = len(text) if text else 0
|
|
if born_digital:
|
|
_log.debug(
|
|
"Archive: no - born-digital PDF (text_length=%d),"
|
|
" ARCHIVE_FILE_GENERATION=auto",
|
|
text_length,
|
|
)
|
|
return False
|
|
_log.debug(
|
|
"Archive: yes - scanned/textless PDF (text_length=%d),"
|
|
" ARCHIVE_FILE_GENERATION=auto",
|
|
text_length,
|
|
)
|
|
return True
|
|
_log.debug(
|
|
"Archive: no - MIME type %r not eligible for auto archive generation",
|
|
mime_type,
|
|
)
|
|
return False
|
|
|
|
|
|
class ConsumerPluginMixin:
|
|
if TYPE_CHECKING:
|
|
from logging import Logger
|
|
from logging import LoggerAdapter
|
|
|
|
log: "LoggerAdapter" # type: ignore[type-arg]
|
|
|
|
def __init__(
|
|
self,
|
|
input_doc: ConsumableDocument,
|
|
metadata: DocumentMetadataOverrides,
|
|
status_mgr: ProgressManager,
|
|
base_tmp_dir: Path,
|
|
task_id: str,
|
|
) -> None:
|
|
super().__init__(input_doc, metadata, status_mgr, base_tmp_dir, task_id)
|
|
|
|
self.renew_logging_group()
|
|
|
|
self.filename = self.metadata.filename or self.input_doc.original_file.name
|
|
|
|
def _send_progress(
|
|
self,
|
|
current_progress: int,
|
|
max_progress: int,
|
|
status: ProgressStatusOptions,
|
|
message: ConsumerStatusShortMessage | str | None = None,
|
|
document_id=None,
|
|
) -> None: # pragma: no cover
|
|
self.status_mgr.send_progress(
|
|
status,
|
|
message,
|
|
current_progress,
|
|
max_progress,
|
|
document_id=document_id,
|
|
owner_id=self.metadata.owner_id if self.metadata.owner_id else None,
|
|
users_can_view=(self.metadata.view_users or [])
|
|
+ (self.metadata.change_users or []),
|
|
groups_can_view=(self.metadata.view_groups or [])
|
|
+ (self.metadata.change_groups or []),
|
|
)
|
|
|
|
def _fail(
|
|
self,
|
|
message: ConsumerStatusShortMessage | str,
|
|
log_message: str | None = None,
|
|
exc_info=None,
|
|
exception: Exception | None = None,
|
|
):
|
|
self._send_progress(100, 100, ProgressStatusOptions.FAILED, message)
|
|
self.log.error(log_message or message, exc_info=exc_info)
|
|
raise ConsumerError(f"{self.filename}: {log_message or message}") from exception
|
|
|
|
|
|
class ConsumerPlugin(
|
|
AlwaysRunPluginMixin,
|
|
NoSetupPluginMixin,
|
|
NoCleanupPluginMixin,
|
|
LoggingMixin,
|
|
ConsumerPluginMixin,
|
|
ConsumeTaskPlugin,
|
|
):
|
|
logging_name = LOGGING_NAME
|
|
|
|
def _create_version_from_root(
|
|
self,
|
|
root_doc: Document,
|
|
*,
|
|
text: str | None,
|
|
page_count: int | None,
|
|
mime_type: str,
|
|
) -> Document:
|
|
self.log.debug("Saving record for updated version to database")
|
|
root_doc_frozen = Document.objects.select_for_update().get(pk=root_doc.pk)
|
|
next_version_index = (
|
|
Document.global_objects.filter(
|
|
root_document_id=root_doc_frozen.pk,
|
|
).aggregate(
|
|
max_index=Max("version_index"),
|
|
)["max_index"]
|
|
or 0
|
|
)
|
|
file_for_checksum = (
|
|
self.unmodified_original
|
|
if self.unmodified_original is not None
|
|
else self.working_copy
|
|
)
|
|
version_doc = Document(
|
|
root_document=root_doc_frozen,
|
|
version_index=next_version_index + 1,
|
|
checksum=compute_checksum(file_for_checksum),
|
|
content=text or "",
|
|
page_count=page_count,
|
|
mime_type=mime_type,
|
|
original_filename=self.filename,
|
|
owner_id=root_doc_frozen.owner_id,
|
|
created=root_doc_frozen.created,
|
|
title=root_doc_frozen.title,
|
|
added=timezone.now(),
|
|
modified=timezone.now(),
|
|
)
|
|
if self.metadata.version_label is not None:
|
|
version_doc.version_label = self.metadata.version_label
|
|
return version_doc
|
|
|
|
def run_pre_consume_script(self) -> None:
|
|
"""
|
|
If one is configured and exists, run the pre-consume script and
|
|
handle its output and/or errors
|
|
"""
|
|
if not settings.PRE_CONSUME_SCRIPT:
|
|
return
|
|
|
|
if not Path(settings.PRE_CONSUME_SCRIPT).is_file():
|
|
self._fail(
|
|
ConsumerStatusShortMessage.PRE_CONSUME_SCRIPT_NOT_FOUND,
|
|
f"Configured pre-consume script "
|
|
f"{settings.PRE_CONSUME_SCRIPT} does not exist.",
|
|
)
|
|
|
|
self.log.info(f"Executing pre-consume script {settings.PRE_CONSUME_SCRIPT}")
|
|
|
|
working_file_path = str(self.working_copy)
|
|
original_file_path = str(self.input_doc.original_file)
|
|
|
|
script_env = os.environ.copy()
|
|
script_env["DOCUMENT_SOURCE_PATH"] = original_file_path
|
|
script_env["DOCUMENT_WORKING_PATH"] = working_file_path
|
|
script_env["TASK_ID"] = self.task_id or ""
|
|
|
|
try:
|
|
run_subprocess(
|
|
[
|
|
settings.PRE_CONSUME_SCRIPT,
|
|
],
|
|
script_env,
|
|
self.log,
|
|
)
|
|
|
|
except Exception as e:
|
|
self._fail(
|
|
ConsumerStatusShortMessage.PRE_CONSUME_SCRIPT_ERROR,
|
|
f"Error while executing pre-consume script: {e}",
|
|
exc_info=True,
|
|
exception=e,
|
|
)
|
|
|
|
def run_post_consume_script(self, document: Document) -> None:
|
|
"""
|
|
If one is configured and exists, run the pre-consume script and
|
|
handle its output and/or errors
|
|
"""
|
|
if not settings.POST_CONSUME_SCRIPT:
|
|
return
|
|
|
|
if not Path(settings.POST_CONSUME_SCRIPT).is_file():
|
|
self._fail(
|
|
ConsumerStatusShortMessage.POST_CONSUME_SCRIPT_NOT_FOUND,
|
|
f"Configured post-consume script "
|
|
f"{settings.POST_CONSUME_SCRIPT} does not exist.",
|
|
)
|
|
|
|
self.log.info(
|
|
f"Executing post-consume script {settings.POST_CONSUME_SCRIPT}",
|
|
)
|
|
|
|
script_env = os.environ.copy()
|
|
|
|
script_env["DOCUMENT_ID"] = str(document.pk)
|
|
script_env["DOCUMENT_TYPE"] = str(document.document_type)
|
|
script_env["DOCUMENT_CREATED"] = str(document.created)
|
|
script_env["DOCUMENT_MODIFIED"] = str(document.modified)
|
|
script_env["DOCUMENT_ADDED"] = str(document.added)
|
|
script_env["DOCUMENT_FILE_NAME"] = document.get_public_filename()
|
|
script_env["DOCUMENT_SOURCE_PATH"] = os.path.normpath(document.source_path)
|
|
script_env["DOCUMENT_ARCHIVE_PATH"] = os.path.normpath(
|
|
str(document.archive_path),
|
|
)
|
|
script_env["DOCUMENT_THUMBNAIL_PATH"] = os.path.normpath(
|
|
document.thumbnail_path,
|
|
)
|
|
script_env["DOCUMENT_DOWNLOAD_URL"] = reverse(
|
|
"document-download",
|
|
kwargs={"pk": document.pk},
|
|
)
|
|
script_env["DOCUMENT_THUMBNAIL_URL"] = reverse(
|
|
"document-thumb",
|
|
kwargs={"pk": document.pk},
|
|
)
|
|
script_env["DOCUMENT_OWNER"] = (
|
|
document.owner.get_username() if document.owner else ""
|
|
)
|
|
script_env["DOCUMENT_CORRESPONDENT"] = str(document.correspondent)
|
|
script_env["DOCUMENT_TAGS"] = str(
|
|
",".join(document.tags.all().values_list("name", flat=True)),
|
|
)
|
|
script_env["DOCUMENT_ORIGINAL_FILENAME"] = str(document.original_filename)
|
|
script_env["TASK_ID"] = self.task_id or ""
|
|
|
|
try:
|
|
run_subprocess(
|
|
[
|
|
settings.POST_CONSUME_SCRIPT,
|
|
],
|
|
script_env,
|
|
self.log,
|
|
)
|
|
|
|
except Exception as e:
|
|
self._fail(
|
|
ConsumerStatusShortMessage.POST_CONSUME_SCRIPT_ERROR,
|
|
f"Error while executing post-consume script: {e}",
|
|
exc_info=True,
|
|
exception=e,
|
|
)
|
|
|
|
def run(self) -> "ConsumeFileSuccessResult":
|
|
"""
|
|
Return the document object if it was successfully created.
|
|
"""
|
|
|
|
# Preflight has already run including progress update to 0%
|
|
self.log.info(f"Consuming {self.filename}")
|
|
|
|
# For the actual work, copy the file into a tempdir
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="paperless-ngx",
|
|
dir=settings.SCRATCH_DIR,
|
|
) as tmpdir:
|
|
self.working_copy = Path(tmpdir) / Path(self.filename)
|
|
copy_file_with_basic_stats(self.input_doc.original_file, self.working_copy)
|
|
self.unmodified_original = None
|
|
|
|
# Determine the parser class.
|
|
|
|
mime_type = magic.from_file(self.working_copy, mime=True)
|
|
|
|
self.log.debug(f"Detected mime type: {mime_type}")
|
|
|
|
if (
|
|
Path(self.filename).suffix.lower() == ".pdf"
|
|
and mime_type in settings.CONSUMER_PDF_RECOVERABLE_MIME_TYPES
|
|
):
|
|
try:
|
|
# The file might be a pdf, but the mime type is wrong.
|
|
# Try to clean with qpdf
|
|
self.log.debug(
|
|
"Detected possible PDF with wrong mime type, trying to clean with qpdf",
|
|
)
|
|
run_subprocess(
|
|
[
|
|
"qpdf",
|
|
"--replace-input",
|
|
self.working_copy,
|
|
],
|
|
logger=self.log,
|
|
)
|
|
mime_type = magic.from_file(self.working_copy, mime=True)
|
|
self.log.debug(f"Detected mime type after qpdf: {mime_type}")
|
|
# Save the original file for later
|
|
self.unmodified_original = (
|
|
Path(tmpdir) / Path("uo") / Path(self.filename)
|
|
)
|
|
self.unmodified_original.parent.mkdir(exist_ok=True)
|
|
copy_file_with_basic_stats(
|
|
self.input_doc.original_file,
|
|
self.unmodified_original,
|
|
)
|
|
except Exception as e:
|
|
self.log.error(f"Error attempting to clean PDF: {e}")
|
|
|
|
# Workflows have already run at this point, so the metadata knows
|
|
# whether this document was singled out for remote OCR
|
|
allow_remote = (
|
|
self.metadata.remote_ocr or RemoteOCRConfig().remote_ocr_by_default
|
|
)
|
|
|
|
# Based on the mime type, get the parser for that type
|
|
parser_class: type[ParserProtocol] | None = (
|
|
get_parser_registry().get_parser_for_file(
|
|
mime_type,
|
|
self.filename,
|
|
self.working_copy,
|
|
allow_remote=allow_remote,
|
|
)
|
|
)
|
|
if not parser_class:
|
|
self._fail(
|
|
ConsumerStatusShortMessage.UNSUPPORTED_TYPE,
|
|
f"Unsupported mime type {mime_type}",
|
|
)
|
|
|
|
if self.metadata.remote_ocr and not getattr(
|
|
parser_class,
|
|
"uses_remote_service",
|
|
False,
|
|
):
|
|
self.log.warning(
|
|
"Remote OCR was requested for this document but no remote "
|
|
"parser is available for it, processing locally instead.",
|
|
)
|
|
|
|
# Notify all listeners that we're going to do some work.
|
|
|
|
document_consumption_started.send(
|
|
sender=self.__class__,
|
|
filename=self.working_copy,
|
|
logging_group=self.logging_group,
|
|
)
|
|
|
|
self.run_pre_consume_script()
|
|
|
|
# This doesn't parse the document yet, but gives us a parser.
|
|
with parser_class() as document_parser:
|
|
document_parser.configure(
|
|
ParserContext(mailrule_id=self.input_doc.mailrule_id),
|
|
)
|
|
|
|
self.log.debug(
|
|
f"Parser: {document_parser.name} v{document_parser.version}",
|
|
)
|
|
|
|
# Parse the document. This may take some time.
|
|
|
|
text = None
|
|
date = None
|
|
thumbnail = None
|
|
archive_path = None
|
|
page_count = None
|
|
|
|
try:
|
|
self._send_progress(
|
|
20,
|
|
100,
|
|
ProgressStatusOptions.WORKING,
|
|
ConsumerStatusShortMessage.PARSING_DOCUMENT,
|
|
)
|
|
self.log.debug(f"Parsing {self.filename}...")
|
|
|
|
produce_archive = should_produce_archive(
|
|
document_parser,
|
|
mime_type,
|
|
self.working_copy,
|
|
self.log,
|
|
)
|
|
document_parser.parse(
|
|
self.working_copy,
|
|
mime_type,
|
|
produce_archive=produce_archive,
|
|
)
|
|
|
|
self.log.debug(f"Generating thumbnail for {self.filename}...")
|
|
self._send_progress(
|
|
70,
|
|
100,
|
|
ProgressStatusOptions.WORKING,
|
|
ConsumerStatusShortMessage.GENERATING_THUMBNAIL,
|
|
)
|
|
thumbnail = document_parser.get_thumbnail(
|
|
self.working_copy,
|
|
mime_type,
|
|
)
|
|
|
|
text = document_parser.get_text()
|
|
date = document_parser.get_date()
|
|
if date is None:
|
|
self._send_progress(
|
|
90,
|
|
100,
|
|
ProgressStatusOptions.WORKING,
|
|
ConsumerStatusShortMessage.PARSE_DATE,
|
|
)
|
|
with get_date_parser() as date_parser:
|
|
date = next(date_parser.parse(self.filename, text), None)
|
|
archive_path = document_parser.get_archive_path()
|
|
page_count = document_parser.get_page_count(
|
|
self.working_copy,
|
|
mime_type,
|
|
)
|
|
|
|
except ParseError as e:
|
|
self._fail(
|
|
str(e),
|
|
f"Error occurred while consuming document {self.filename}: {e}",
|
|
exc_info=True,
|
|
exception=e,
|
|
)
|
|
except Exception as e:
|
|
self._fail(
|
|
str(e),
|
|
f"Unexpected error while consuming document {self.filename}: {e}",
|
|
exc_info=True,
|
|
exception=e,
|
|
)
|
|
|
|
# Prepare the document classifier.
|
|
|
|
# TODO: I don't really like to do this here, but this way we avoid
|
|
# reloading the classifier multiple times, since there are multiple
|
|
# post-consume hooks that all require the classifier.
|
|
|
|
classifier = load_classifier()
|
|
|
|
self._send_progress(
|
|
95,
|
|
100,
|
|
ProgressStatusOptions.WORKING,
|
|
ConsumerStatusShortMessage.SAVE_DOCUMENT,
|
|
)
|
|
# now that everything is done, we can start to store the document
|
|
# in the system. This will be a transaction and reasonably fast.
|
|
try:
|
|
with transaction.atomic():
|
|
# store the document.
|
|
if self.input_doc.root_document_id:
|
|
# If this is a new version of an existing document, we need
|
|
# to make sure we're not creating a new document, but updating
|
|
# the existing one.
|
|
root_doc = Document.objects.get(
|
|
pk=self.input_doc.root_document_id,
|
|
)
|
|
original_document = self._create_version_from_root(
|
|
root_doc,
|
|
text=text,
|
|
page_count=page_count,
|
|
mime_type=mime_type,
|
|
)
|
|
actor = None
|
|
|
|
# Save the new version, potentially creating an audit log entry for the version addition if enabled.
|
|
if (
|
|
settings.AUDIT_LOG_ENABLED
|
|
and self.metadata.actor_id is not None
|
|
):
|
|
actor = User.objects.filter(
|
|
pk=self.metadata.actor_id,
|
|
).first()
|
|
if actor is not None:
|
|
from auditlog.context import ( # type: ignore[import-untyped]
|
|
set_actor,
|
|
)
|
|
|
|
with set_actor(actor):
|
|
original_document.save()
|
|
else:
|
|
original_document.save()
|
|
else:
|
|
original_document.save()
|
|
|
|
# Adding a version changes the effective document, so update root modified
|
|
Document.objects.filter(pk=root_doc.pk).update(
|
|
modified=timezone.now(),
|
|
)
|
|
|
|
# Create a log entry for the version addition, if enabled
|
|
if settings.AUDIT_LOG_ENABLED:
|
|
from auditlog.models import ( # type: ignore[import-untyped]
|
|
LogEntry,
|
|
)
|
|
|
|
LogEntry.objects.log_create(
|
|
instance=root_doc,
|
|
changes={
|
|
"Version Added": ["None", original_document.id],
|
|
},
|
|
action=LogEntry.Action.UPDATE,
|
|
actor=actor,
|
|
additional_data={
|
|
"reason": "Version added",
|
|
"version_id": original_document.id,
|
|
},
|
|
)
|
|
document = original_document
|
|
else:
|
|
document = self._store(
|
|
text=text,
|
|
date=date,
|
|
page_count=page_count,
|
|
mime_type=mime_type,
|
|
)
|
|
|
|
# If we get here, it was successful. Proceed with post-consume
|
|
# hooks. If they fail, nothing will get changed.
|
|
|
|
document = Document.objects.prefetch_related("versions").get(
|
|
pk=document.pk,
|
|
)
|
|
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=document,
|
|
logging_group=self.logging_group,
|
|
classifier=classifier,
|
|
original_file=self.unmodified_original
|
|
if self.unmodified_original
|
|
else self.working_copy,
|
|
)
|
|
|
|
# After everything is in the database, copy the files into
|
|
# place. If this fails, we'll also rollback the transaction.
|
|
with FileLock(settings.MEDIA_LOCK):
|
|
generated_filename = generate_unique_filename(document)
|
|
if (
|
|
len(str(generated_filename))
|
|
> Document.MAX_STORED_FILENAME_LENGTH
|
|
):
|
|
self.log.warning(
|
|
"Generated source filename exceeds db path limit, falling back to default naming",
|
|
)
|
|
generated_filename = generate_filename(
|
|
document,
|
|
use_format=False,
|
|
)
|
|
document.filename = generated_filename
|
|
validate_path_in_root(
|
|
document.source_path,
|
|
settings.ORIGINALS_DIR,
|
|
)
|
|
create_source_path_directory(document.source_path)
|
|
|
|
self._write(
|
|
self.unmodified_original
|
|
if self.unmodified_original is not None
|
|
else self.working_copy,
|
|
document.source_path,
|
|
)
|
|
|
|
self._write(
|
|
thumbnail,
|
|
document.thumbnail_path,
|
|
)
|
|
|
|
if archive_path and Path(archive_path).is_file():
|
|
generated_archive_filename = generate_unique_filename(
|
|
document,
|
|
archive_filename=True,
|
|
)
|
|
if (
|
|
len(str(generated_archive_filename))
|
|
> Document.MAX_STORED_FILENAME_LENGTH
|
|
):
|
|
self.log.warning(
|
|
"Generated archive filename exceeds db path limit, falling back to default naming",
|
|
)
|
|
generated_archive_filename = generate_filename(
|
|
document,
|
|
archive_filename=True,
|
|
use_format=False,
|
|
)
|
|
document.archive_filename = generated_archive_filename
|
|
validate_path_in_root(
|
|
document.archive_path,
|
|
settings.ARCHIVE_DIR,
|
|
)
|
|
create_source_path_directory(document.archive_path)
|
|
self._write(
|
|
archive_path,
|
|
document.archive_path,
|
|
)
|
|
|
|
document.archive_checksum = compute_checksum(
|
|
document.archive_path,
|
|
)
|
|
|
|
# Don't save with the lock active. Saving will cause the file
|
|
# renaming logic to acquire the lock as well.
|
|
# This triggers things like file renaming
|
|
document.save()
|
|
|
|
if document.root_document_id:
|
|
document_updated.send(
|
|
sender=self.__class__,
|
|
document=document.root_document,
|
|
skip_ai_index=True, # document_consumption_finished already enqueues the LLM update
|
|
)
|
|
|
|
# Delete the file only if it was successfully consumed
|
|
self.log.debug(
|
|
f"Deleting original file {self.input_doc.original_file}",
|
|
)
|
|
self.input_doc.original_file.unlink()
|
|
self.log.debug(f"Deleting working copy {self.working_copy}")
|
|
self.working_copy.unlink()
|
|
if self.unmodified_original is not None: # pragma: no cover
|
|
self.log.debug(
|
|
f"Deleting unmodified original file {self.unmodified_original}",
|
|
)
|
|
self.unmodified_original.unlink()
|
|
|
|
# https://github.com/jonaswinkler/paperless-ng/discussions/1037
|
|
shadow_file = (
|
|
Path(self.input_doc.original_file).parent
|
|
/ f"._{Path(self.input_doc.original_file).name}"
|
|
)
|
|
|
|
if Path(shadow_file).is_file():
|
|
self.log.debug(f"Deleting shadow file {shadow_file}")
|
|
Path(shadow_file).unlink()
|
|
|
|
except Exception as e:
|
|
self._fail(
|
|
str(e),
|
|
f"The following error occurred while storing document "
|
|
f"{self.filename} after parsing: {e}",
|
|
exc_info=True,
|
|
exception=e,
|
|
)
|
|
|
|
self.run_post_consume_script(document)
|
|
|
|
self.log.info(f"Document {document} consumption finished")
|
|
|
|
self._send_progress(
|
|
100,
|
|
100,
|
|
ProgressStatusOptions.SUCCESS,
|
|
ConsumerStatusShortMessage.FINISHED,
|
|
document.id,
|
|
)
|
|
|
|
# Return the most up to date fields
|
|
document.refresh_from_db()
|
|
|
|
return ConsumeFileSuccessResult(document_id=document.pk)
|
|
|
|
def _parse_title_placeholders(self, title: str) -> str:
|
|
local_added = timezone.localtime(timezone.now())
|
|
|
|
correspondent_name = (
|
|
Correspondent.objects.get(pk=self.metadata.correspondent_id).name
|
|
if self.metadata.correspondent_id is not None
|
|
else None
|
|
)
|
|
doc_type_name = (
|
|
DocumentType.objects.get(pk=self.metadata.document_type_id).name
|
|
if self.metadata.document_type_id is not None
|
|
else None
|
|
)
|
|
owner_username = (
|
|
User.objects.get(pk=self.metadata.owner_id).username
|
|
if self.metadata.owner_id is not None
|
|
else None
|
|
)
|
|
|
|
return parse_w_workflow_placeholders(
|
|
title,
|
|
correspondent_name,
|
|
doc_type_name,
|
|
owner_username,
|
|
local_added,
|
|
self.filename,
|
|
self.filename,
|
|
)
|
|
|
|
def _store(
|
|
self,
|
|
text: str,
|
|
date: datetime.datetime | None,
|
|
page_count: int | None,
|
|
mime_type: str,
|
|
) -> Document:
|
|
# If someone gave us the original filename, use it instead of doc.
|
|
|
|
self.log.debug("Saving record to database")
|
|
|
|
if self.metadata.created is not None:
|
|
create_date = self.metadata.created
|
|
self.log.debug(
|
|
f"Creation date from post_documents parameter: {create_date}",
|
|
)
|
|
elif date is not None:
|
|
create_date = date
|
|
self.log.debug(f"Creation date from parse_date: {create_date}")
|
|
else:
|
|
stats = Path(self.input_doc.original_file).stat()
|
|
create_date = datetime.datetime.fromtimestamp(
|
|
stats.st_mtime,
|
|
tz=timezone.get_current_timezone(),
|
|
)
|
|
self.log.debug(f"Creation date from st_mtime: {create_date}")
|
|
|
|
if self.metadata.filename:
|
|
title = Path(self.metadata.filename).stem
|
|
else:
|
|
title = self.input_doc.original_file.stem
|
|
|
|
if self.metadata.title is not None:
|
|
try:
|
|
title = self._parse_title_placeholders(self.metadata.title)
|
|
except Exception as e:
|
|
self.log.error(
|
|
f"Error occurred parsing title override '{self.metadata.title}', falling back to original. Exception: {e}",
|
|
)
|
|
|
|
file_for_checksum = (
|
|
self.unmodified_original
|
|
if self.unmodified_original is not None
|
|
else self.working_copy
|
|
)
|
|
|
|
document = Document.objects.create(
|
|
title=title[:127],
|
|
content=text,
|
|
mime_type=mime_type,
|
|
checksum=compute_checksum(file_for_checksum),
|
|
created=create_date,
|
|
modified=create_date,
|
|
page_count=page_count,
|
|
original_filename=self.filename,
|
|
)
|
|
|
|
self.apply_overrides(document)
|
|
|
|
document.save()
|
|
|
|
return document
|
|
|
|
def apply_overrides(self, document: Document) -> None:
|
|
if self.metadata.correspondent_id:
|
|
document.correspondent = Correspondent.objects.get(
|
|
pk=self.metadata.correspondent_id,
|
|
)
|
|
|
|
if self.metadata.document_type_id:
|
|
document.document_type = DocumentType.objects.get(
|
|
pk=self.metadata.document_type_id,
|
|
)
|
|
|
|
if self.metadata.tag_ids:
|
|
for tag_id in self.metadata.tag_ids:
|
|
document.add_nested_tags([Tag.objects.get(pk=tag_id)])
|
|
|
|
if self.metadata.storage_path_id:
|
|
document.storage_path = StoragePath.objects.get(
|
|
pk=self.metadata.storage_path_id,
|
|
)
|
|
|
|
if self.metadata.asn is not None:
|
|
document.archive_serial_number = self.metadata.asn
|
|
|
|
if self.metadata.version_label is not None:
|
|
document.version_label = self.metadata.version_label
|
|
|
|
if self.metadata.owner_id:
|
|
document.owner = User.objects.get(
|
|
pk=self.metadata.owner_id,
|
|
)
|
|
|
|
if (
|
|
self.metadata.view_users is not None
|
|
or self.metadata.view_groups is not None
|
|
or self.metadata.change_users is not None
|
|
or self.metadata.change_groups is not None
|
|
):
|
|
permissions = {
|
|
"view": {
|
|
"users": self.metadata.view_users or [],
|
|
"groups": self.metadata.view_groups or [],
|
|
},
|
|
"change": {
|
|
"users": self.metadata.change_users or [],
|
|
"groups": self.metadata.change_groups or [],
|
|
},
|
|
}
|
|
set_permissions_for_object(permissions=permissions, object=document)
|
|
|
|
if self.metadata.custom_fields:
|
|
for field in CustomField.objects.filter(
|
|
id__in=self.metadata.custom_fields.keys(),
|
|
).distinct():
|
|
value_field_name = CustomFieldInstance.get_value_field_name(
|
|
data_type=field.data_type,
|
|
)
|
|
args = {
|
|
"field": field,
|
|
"document": document,
|
|
value_field_name: self.metadata.custom_fields.get(field.id, None),
|
|
}
|
|
CustomFieldInstance.objects.create(**args) # adds to document
|
|
|
|
def _write(self, source, target) -> None:
|
|
with (
|
|
Path(source).open("rb") as read_file,
|
|
Path(target).open("wb") as write_file,
|
|
):
|
|
shutil.copyfileobj(read_file, write_file)
|
|
|
|
# Attempt to copy file's original stats, but it's ok if we can't
|
|
try:
|
|
copy_basic_file_stats(source, target)
|
|
except Exception: # pragma: no cover
|
|
pass
|
|
|
|
|
|
class ConsumerPreflightPlugin(
|
|
NoCleanupPluginMixin,
|
|
NoSetupPluginMixin,
|
|
AlwaysRunPluginMixin,
|
|
LoggingMixin,
|
|
ConsumerPluginMixin,
|
|
ConsumeTaskPlugin,
|
|
):
|
|
NAME: str = "ConsumerPreflightPlugin"
|
|
logging_name = LOGGING_NAME
|
|
|
|
def pre_check_file_exists(self) -> None:
|
|
"""
|
|
Confirm the input file still exists where it should
|
|
"""
|
|
if TYPE_CHECKING:
|
|
assert isinstance(self.input_doc.original_file, Path), (
|
|
self.input_doc.original_file
|
|
)
|
|
if not self.input_doc.original_file.is_file():
|
|
self._fail(
|
|
ConsumerStatusShortMessage.FILE_NOT_FOUND,
|
|
f"Cannot consume {self.input_doc.original_file}: File not found.",
|
|
)
|
|
|
|
def pre_check_duplicate(self) -> None:
|
|
"""
|
|
Using the SHA256 of the file, check this exact file doesn't already exist
|
|
"""
|
|
checksum = compute_checksum(Path(self.input_doc.original_file))
|
|
existing_doc = Document.global_objects.filter(
|
|
Q(checksum=checksum) | Q(archive_checksum=checksum),
|
|
)
|
|
if existing_doc.exists():
|
|
existing_doc = existing_doc.order_by("-created")
|
|
duplicates_in_trash = existing_doc.filter(deleted_at__isnull=False)
|
|
log_msg = (
|
|
f"Consuming duplicate {self.filename}: "
|
|
f"{existing_doc.count()} existing document(s) share the same content."
|
|
)
|
|
|
|
if duplicates_in_trash.exists():
|
|
log_msg += " Note: at least one existing document is in the trash."
|
|
|
|
self.log.warning(log_msg)
|
|
|
|
if settings.CONSUMER_DELETE_DUPLICATES:
|
|
duplicate = existing_doc.first()
|
|
duplicate_label = (
|
|
duplicate.title
|
|
or duplicate.original_filename
|
|
or (Path(duplicate.filename).name if duplicate.filename else None)
|
|
or str(duplicate.pk)
|
|
)
|
|
|
|
Path(self.input_doc.original_file).unlink()
|
|
|
|
failure_msg = (
|
|
f"Not consuming {self.filename}: "
|
|
f"It is a duplicate of {duplicate_label} (#{duplicate.pk})"
|
|
)
|
|
status_msg = ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS
|
|
|
|
if duplicates_in_trash.exists():
|
|
status_msg = (
|
|
ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS_IN_TRASH
|
|
)
|
|
failure_msg += " Note: existing document is in the trash."
|
|
|
|
self._send_progress(100, 100, ProgressStatusOptions.FAILED, status_msg)
|
|
self.log.error(failure_msg)
|
|
in_trash = duplicates_in_trash.exists()
|
|
raise ConsumeFileDuplicateError(
|
|
f"{self.filename}: {failure_msg}",
|
|
duplicate.pk,
|
|
in_trash=in_trash,
|
|
)
|
|
|
|
def pre_check_directories(self) -> None:
|
|
"""
|
|
Ensure all required directories exist before attempting to use them
|
|
"""
|
|
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
|
settings.THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)
|
|
settings.ORIGINALS_DIR.mkdir(parents=True, exist_ok=True)
|
|
settings.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
def run(self) -> None:
|
|
self._send_progress(
|
|
0,
|
|
100,
|
|
ProgressStatusOptions.STARTED,
|
|
ConsumerStatusShortMessage.NEW_FILE,
|
|
)
|
|
|
|
# Make sure that preconditions for consuming the file are met.
|
|
|
|
self.pre_check_file_exists()
|
|
self.pre_check_duplicate()
|
|
self.pre_check_directories()
|
|
|
|
|
|
class AsnCheckPlugin(
|
|
NoCleanupPluginMixin,
|
|
NoSetupPluginMixin,
|
|
AlwaysRunPluginMixin,
|
|
LoggingMixin,
|
|
ConsumerPluginMixin,
|
|
ConsumeTaskPlugin,
|
|
):
|
|
NAME: str = "AsnCheckPlugin"
|
|
logging_name = LOGGING_NAME
|
|
|
|
def pre_check_asn_value(self) -> None:
|
|
"""
|
|
Check that if override_asn is given, it is unique and within a valid range
|
|
"""
|
|
if self.metadata.asn is None:
|
|
# if ASN is None
|
|
return
|
|
# Validate the range is above zero and less than uint32_t max
|
|
# otherwise, Whoosh can't handle it in the index
|
|
if (
|
|
self.metadata.asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
|
|
or self.metadata.asn > Document.ARCHIVE_SERIAL_NUMBER_MAX
|
|
):
|
|
self._fail(
|
|
ConsumerStatusShortMessage.ASN_RANGE,
|
|
f"Not consuming {self.filename}: "
|
|
f"Given ASN {self.metadata.asn} is out of range "
|
|
f"[{Document.ARCHIVE_SERIAL_NUMBER_MIN:,}, "
|
|
f"{Document.ARCHIVE_SERIAL_NUMBER_MAX:,}]",
|
|
)
|
|
existing_asn_doc = Document.global_objects.filter(
|
|
archive_serial_number=self.metadata.asn,
|
|
)
|
|
if existing_asn_doc.exists():
|
|
msg = ConsumerStatusShortMessage.ASN_ALREADY_EXISTS
|
|
log_msg = f"Not consuming {self.filename}: Given ASN {self.metadata.asn} already exists!"
|
|
|
|
if existing_asn_doc.first().deleted_at is not None:
|
|
msg = ConsumerStatusShortMessage.ASN_ALREADY_EXISTS_IN_TRASH
|
|
log_msg += " Note: existing document is in the trash."
|
|
|
|
self._fail(
|
|
msg,
|
|
log_msg,
|
|
)
|
|
|
|
def run(self) -> None:
|
|
self.pre_check_asn_value()
|