Coverage for documents/double_sided.py: 32%
60 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
1import datetime as dt
2import logging
3import os
4import shutil
5from pathlib import Path
6from typing import Final
8from django.conf import settings
9from pikepdf import Pdf
11from documents.consumer import ConsumerError
12from documents.converters import convert_from_tiff_to_pdf
13from documents.plugins.base import ConsumeTaskPlugin
14from documents.plugins.base import NoCleanupPluginMixin
15from documents.plugins.base import NoSetupPluginMixin
16from documents.plugins.base import StopConsumeTaskError
18logger = logging.getLogger("paperless.double_sided")
20# Hardcoded for now, could be made a configurable setting if needed
21TIMEOUT_MINUTES: Final[int] = 30
22TIMEOUT_SECONDS: Final[int] = TIMEOUT_MINUTES * 60
24# Used by test cases
25STAGING_FILE_NAME = "double-sided-staging.pdf"
28class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin):
29 NAME: str = "CollatePlugin"
31 @property
32 def able_to_run(self) -> bool:
33 return (
34 settings.CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED
35 and settings.CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME
36 in self.input_doc.original_file.parts
37 )
39 def run(self) -> str | None:
40 """
41 Tries to collate pages from 2 single sided scans of a double sided
42 document.
44 When called with a file, it checks whether or not a staging file
45 exists, if not, the current file is turned into that staging file
46 containing the odd numbered pages.
48 If a staging file exists, and it is not too old, the current file is
49 considered to be the second part (the even numbered pages) and it will
50 collate the pages of both, the pages of the second file will be added
51 in reverse order, since the ADF will have scanned the pages from bottom
52 to top.
54 Returns a status message on success, or raises a ConsumerError
55 in case of failure.
56 """
58 if self.input_doc.mime_type == "application/pdf":
59 pdf_file = self.input_doc.original_file
60 elif (
61 self.input_doc.mime_type == "image/tiff"
62 and settings.CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT
63 ):
64 pdf_file = convert_from_tiff_to_pdf(
65 self.input_doc.original_file,
66 self.base_tmp_dir,
67 )
68 self.input_doc.original_file.unlink()
69 else:
70 raise ConsumerError(
71 "Unsupported file type for collation of double-sided scans",
72 )
74 staging: Path = settings.SCRATCH_DIR / STAGING_FILE_NAME
76 valid_staging_exists = False
77 if staging.exists():
78 stats = staging.stat()
79 # if the file is older than the timeout, we don't consider
80 # it valid
81 if (dt.datetime.now().timestamp() - stats.st_mtime) > TIMEOUT_SECONDS:
82 logger.warning("Outdated double sided staging file exists, deleting it")
83 staging.unlink()
84 else:
85 valid_staging_exists = True
87 if valid_staging_exists:
88 try:
89 # Collate pages from second PDF in reverse order
90 with Pdf.open(staging) as pdf1, Pdf.open(pdf_file) as pdf2:
91 pdf2.pages.reverse()
92 try:
93 for i, page in enumerate(pdf2.pages):
94 pdf1.pages.insert(2 * i + 1, page)
95 except IndexError:
96 raise ConsumerError(
97 "This second file (even numbered pages) contains more "
98 "pages than the first/odd numbered one. This means the "
99 "two uploaded files don't belong to the same double-"
100 "sided scan. Please retry, starting with the odd "
101 "numbered pages again.",
102 )
103 # Merged file has the same path, but without the
104 # double-sided subdir. Therefore, it is also in the
105 # consumption dir and will be picked up for processing
106 old_file = self.input_doc.original_file
107 new_file = Path(
108 *(
109 part
110 for part in old_file.with_name(
111 f"{old_file.stem}-collated.pdf",
112 ).parts
113 if part
114 != settings.CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME
115 ),
116 )
117 # If the user didn't create the subdirs yet, do it for them
118 new_file.parent.mkdir(parents=True, exist_ok=True)
119 pdf1.save(new_file)
120 logger.info("Collated documents into new file %s", new_file)
121 raise StopConsumeTaskError(
122 "Success. Even numbered pages of double sided scan collated "
123 "with odd pages",
124 )
125 finally:
126 # Delete staging and recently uploaded file no matter what.
127 # If any error occurs, the user needs to be able to restart
128 # the process from scratch; after all, the staging file
129 # with the odd numbered pages might be the culprit
130 pdf_file.unlink()
131 staging.unlink()
133 else:
134 shutil.move(pdf_file, staging)
135 # update access to modification time so we know if the file
136 # is outdated when another file gets uploaded
137 timestamp = dt.datetime.now().timestamp()
138 os.utime(staging, (timestamp, timestamp))
139 logger.info(
140 "Got scan with odd numbered pages of double-sided scan, moved it to %s",
141 staging,
142 )
143 raise StopConsumeTaskError(
144 "Received odd numbered pages of double sided scan, waiting up to "
145 f"{TIMEOUT_MINUTES} minutes for even numbered pages",
146 )