Coverage for documents/sanity_checker.py: 18%

156 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 09:07 +0000

1""" 

2Sanity checker for the Paperless-ngx document archive. 

3 

4Verifies that all documents have valid files, correct checksums, 

5and consistent metadata. Reports orphaned files in the media directory. 

6 

7Progress display is the caller's responsibility -- pass an ``iter_wrapper`` 

8to wrap the document queryset (e.g., with a progress bar). The default 

9is an identity function that adds no overhead. 

10""" 

11 

12import logging 

13from collections import defaultdict 

14from collections.abc import Iterator 

15from pathlib import Path 

16from typing import TYPE_CHECKING 

17from typing import Final 

18from typing import TypedDict 

19 

20from django.conf import settings 

21 

22from documents.models import Document 

23from documents.utils import IterWrapper 

24from documents.utils import compute_checksum 

25from documents.utils import identity 

26from paperless.config import GeneralConfig 

27 

28logger = logging.getLogger("paperless.sanity_checker") 

29 

30 

31class MessageEntry(TypedDict): 

32 """A single sanity check message with its severity level.""" 

33 

34 level: int 

35 message: str 

36 

37 

38class SanityCheckMessages: 

39 """Collects sanity check messages grouped by document primary key. 

40 

41 Messages are categorized as error, warning, or info. ``None`` is used 

42 as the key for messages not associated with a specific document 

43 (e.g., orphaned files). 

44 """ 

45 

46 def __init__(self) -> None: 

47 self._messages: dict[int | None, list[MessageEntry]] = defaultdict(list) 

48 self._document_pks: set[int] = set() 

49 self._document_error_pks: set[int] = set() 

50 self._document_warning_pks: set[int] = set() 

51 self._document_info_pks: set[int] = set() 

52 self._document_error_issue_count: int = 0 

53 self._document_warning_issue_count: int = 0 

54 self.has_error: bool = False 

55 self.has_warning: bool = False 

56 self.has_info: bool = False 

57 self.document_count: int = 0 

58 self.document_error_count: int = 0 

59 self.document_warning_count: int = 0 

60 self.document_info_count: int = 0 

61 self.global_warning_count: int = 0 

62 

63 # -- Recording ---------------------------------------------------------- 

64 

65 def _add_document_issue(self, doc_pk: int, document_pks: set[int]) -> bool: 

66 if doc_pk not in self._document_pks: 

67 self._document_pks.add(doc_pk) 

68 self.document_count += 1 

69 

70 if doc_pk in document_pks: 

71 return False 

72 

73 document_pks.add(doc_pk) 

74 return True 

75 

76 def error(self, doc_pk: int | None, message: str) -> None: 

77 self._messages[doc_pk].append({"level": logging.ERROR, "message": message}) 

78 self.has_error = True 

79 if doc_pk is not None: 

80 self._document_error_issue_count += 1 

81 if self._add_document_issue(doc_pk, self._document_error_pks): 

82 self.document_error_count += 1 

83 

84 def warning(self, doc_pk: int | None, message: str) -> None: 

85 self._messages[doc_pk].append({"level": logging.WARNING, "message": message}) 

86 self.has_warning = True 

87 

88 if doc_pk is not None: 

89 self._document_warning_issue_count += 1 

90 if self._add_document_issue(doc_pk, self._document_warning_pks): 

91 self.document_warning_count += 1 

92 else: 

93 # This is the only type of global message we do right now 

94 self.global_warning_count += 1 

95 

96 def info(self, doc_pk: int | None, message: str) -> None: 

97 self._messages[doc_pk].append({"level": logging.INFO, "message": message}) 

98 self.has_info = True 

99 

100 if doc_pk is not None and self._add_document_issue( 

101 doc_pk, 

102 self._document_info_pks, 

103 ): 

104 self.document_info_count += 1 

105 

106 # -- Iteration / query -------------------------------------------------- 

107 

108 def document_pks(self) -> list[int | None]: 

109 """Return all document PKs (including None for global messages).""" 

110 return list(self._messages.keys()) 

111 

112 def iter_messages(self) -> Iterator[tuple[int | None, list[MessageEntry]]]: 

113 """Iterate over (doc_pk, messages) pairs.""" 

114 yield from self._messages.items() 

115 

116 def __getitem__(self, item: int | None) -> list[MessageEntry]: 

117 return self._messages[item] 

118 

119 # -- Summarize Helpers -------------------------------------------------- 

120 

121 @property 

122 def has_global_issues(self) -> bool: 

123 return None in self._messages 

124 

125 @property 

126 def total_issue_count(self) -> int: 

127 """Total number of error and warning messages across all documents and global.""" 

128 return ( 

129 self._document_error_issue_count 

130 + self._document_warning_issue_count 

131 + self.global_warning_count 

132 ) 

133 

134 # -- Logging output (used by Celery task path) -------------------------- 

135 

136 def log_messages(self) -> None: 

137 """Write all messages to the ``paperless.sanity_checker`` logger. 

138 

139 This is the output path for headless / Celery execution. 

140 Management commands use Rich rendering instead. 

141 """ 

142 if len(self._messages) == 0: 

143 logger.info("Sanity checker detected no issues.") 

144 return 

145 

146 doc_pks = [pk for pk in self._messages if pk is not None] 

147 titles: dict[int, str] = {} 

148 if doc_pks: 

149 titles = dict( 

150 Document.global_objects.filter(pk__in=doc_pks) 

151 .only("pk", "title") 

152 .values_list("pk", "title"), 

153 ) 

154 

155 for doc_pk, entries in self._messages.items(): 

156 if doc_pk is not None: 

157 title = titles.get(doc_pk, "Unknown") 

158 logger.info( 

159 "Detected following issue(s) with document #%s, titled %s", 

160 doc_pk, 

161 title, 

162 ) 

163 for msg in entries: 

164 logger.log(msg["level"], msg["message"]) 

165 

166 

167class SanityCheckFailedException(Exception): 

168 pass 

169 

170 

171# --------------------------------------------------------------------------- 

172# Internal helpers 

173# --------------------------------------------------------------------------- 

174 

175 

176def _build_present_files() -> set[Path]: 

177 """Collect all files in MEDIA_ROOT, excluding directories and ignorable files.""" 

178 present_files = { 

179 x.resolve() 

180 for x in Path(settings.MEDIA_ROOT).glob("**/*") 

181 if not x.is_dir() and x.name not in settings.IGNORABLE_FILES 

182 } 

183 

184 lockfile = Path(settings.MEDIA_LOCK).resolve() 

185 present_files.discard(lockfile) 

186 

187 general_config = GeneralConfig() 

188 app_logo = general_config.app_logo or settings.APP_LOGO 

189 if app_logo: 

190 logo_file = Path(settings.MEDIA_ROOT / Path(app_logo.lstrip("/"))).resolve() 

191 present_files.discard(logo_file) 

192 

193 return present_files 

194 

195 

196def _check_thumbnail( 

197 doc: Document, 

198 messages: SanityCheckMessages, 

199 present_files: set[Path], 

200) -> None: 

201 """Verify the thumbnail exists and is readable.""" 

202 # doc.thumbnail_path already returns a resolved Path; no need to re-resolve. 

203 thumbnail_path: Final[Path] = doc.thumbnail_path 

204 if not thumbnail_path.is_file(): 

205 messages.error(doc.pk, "Thumbnail of document does not exist.") 

206 return 

207 

208 present_files.discard(thumbnail_path) 

209 try: 

210 _ = thumbnail_path.read_bytes() 

211 except OSError as e: 

212 messages.error(doc.pk, f"Cannot read thumbnail file of document: {e}") 

213 

214 

215def _check_original( 

216 doc: Document, 

217 messages: SanityCheckMessages, 

218 present_files: set[Path], 

219) -> None: 

220 """Verify the original file exists, is readable, and has matching checksum.""" 

221 # doc.source_path already returns a resolved Path; no need to re-resolve. 

222 source_path: Final[Path] = doc.source_path 

223 if not source_path.is_file(): 

224 messages.error(doc.pk, "Original of document does not exist.") 

225 return 

226 

227 present_files.discard(source_path) 

228 try: 

229 checksum = compute_checksum(source_path) 

230 except OSError as e: 

231 messages.error(doc.pk, f"Cannot read original file of document: {e}") 

232 else: 

233 if checksum != doc.checksum: 

234 messages.error( 

235 doc.pk, 

236 f"Checksum mismatch. Stored: {doc.checksum}, actual: {checksum}.", 

237 ) 

238 

239 

240def _check_archive( 

241 doc: Document, 

242 messages: SanityCheckMessages, 

243 present_files: set[Path], 

244) -> None: 

245 """Verify archive file consistency: checksum/filename pairing and file integrity.""" 

246 if doc.archive_checksum is not None and doc.archive_filename is None: 

247 messages.error( 

248 doc.pk, 

249 "Document has an archive file checksum, but no archive filename.", 

250 ) 

251 elif doc.archive_checksum is None and doc.archive_filename is not None: 

252 messages.error( 

253 doc.pk, 

254 "Document has an archive file, but its checksum is missing.", 

255 ) 

256 elif doc.has_archive_version: 

257 if TYPE_CHECKING: 

258 assert isinstance(doc.archive_path, Path) 

259 # doc.archive_path already returns a resolved Path; no need to re-resolve. 

260 archive_path: Final[Path] = doc.archive_path # type: ignore[assignment] 

261 if not archive_path.is_file(): 

262 messages.error(doc.pk, "Archived version of document does not exist.") 

263 return 

264 

265 present_files.discard(archive_path) 

266 try: 

267 checksum = compute_checksum(archive_path) 

268 except OSError as e: 

269 messages.error( 

270 doc.pk, 

271 f"Cannot read archive file of document: {e}", 

272 ) 

273 else: 

274 if checksum != doc.archive_checksum: 

275 messages.error( 

276 doc.pk, 

277 "Checksum mismatch of archived document. " 

278 f"Stored: {doc.archive_checksum}, actual: {checksum}.", 

279 ) 

280 

281 

282def _check_content(doc: Document, messages: SanityCheckMessages) -> None: 

283 """Flag documents with no OCR content.""" 

284 if not doc.content: 

285 messages.info(doc.pk, "Document contains no OCR data") 

286 

287 

288def _check_document( 

289 doc: Document, 

290 messages: SanityCheckMessages, 

291 present_files: set[Path], 

292) -> None: 

293 """Run all checks for a single document.""" 

294 _check_thumbnail(doc, messages, present_files) 

295 _check_original(doc, messages, present_files) 

296 _check_archive(doc, messages, present_files) 

297 _check_content(doc, messages) 

298 

299 

300# --------------------------------------------------------------------------- 

301# Public entry point 

302# --------------------------------------------------------------------------- 

303 

304 

305def check_sanity( 

306 *, 

307 iter_wrapper: IterWrapper[Document] = identity, 

308) -> SanityCheckMessages: 

309 """Run a full sanity check on the document archive. 

310 

311 Args: 

312 iter_wrapper: A callable that wraps the document iterable, e.g., 

313 for progress bar display. Defaults to identity (no wrapping). 

314 

315 Returns: 

316 A SanityCheckMessages instance containing all detected issues. 

317 """ 

318 messages = SanityCheckMessages() 

319 present_files = _build_present_files() 

320 

321 documents = Document.global_objects.only( 

322 "pk", 

323 "filename", 

324 "mime_type", 

325 "checksum", 

326 "archive_checksum", 

327 "archive_filename", 

328 "content", 

329 ).iterator(chunk_size=500) 

330 for doc in iter_wrapper(documents): 

331 _check_document(doc, messages, present_files) 

332 

333 for extra_file in present_files: 

334 messages.warning(None, f"Orphaned file in media dir: {extra_file}") 

335 

336 return messages