Coverage for paperless/checks.py: 73%
147 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 logging
2import os
3import shutil
4import stat
5import subprocess
6from pathlib import Path
7from typing import Any
9from django.conf import settings
10from django.core.checks import Error
11from django.core.checks import Tags
12from django.core.checks import Warning
13from django.core.checks import register
14from django.db import connections
16exists_message = "{} is set but doesn't exist."
17exists_hint = "Create a directory at {}"
18writeable_message = "{} is not writeable"
19writeable_hint = (
20 "Set the permissions of {} to be writeable by the user running the "
21 "Paperless services"
22)
25def path_check(var: str, directory: Path) -> list[Error]:
26 messages: list[Error] = []
27 if directory:
28 if not directory.is_dir(): 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
29 messages.append(
30 Error(exists_message.format(var), exists_hint.format(directory)),
31 )
32 else:
33 test_file: Path = directory / f"__paperless_write_test_{os.getpid()}__"
34 try:
35 with test_file.open("w"):
36 pass
37 except PermissionError:
38 dir_stat: os.stat_result = Path(directory).stat()
39 dir_mode: str = stat.filemode(dir_stat.st_mode)
40 dir_owner: str = ""
41 dir_group: str = ""
42 messages.append(
43 Error(
44 writeable_message.format(var),
45 writeable_hint.format(
46 f"\n{dir_mode} {dir_owner} {dir_group} {directory}\n",
47 ),
48 ),
49 )
50 finally:
51 try:
52 if test_file.is_file(): 52 ↛ 58line 52 didn't jump to line 58 because the condition on line 52 was always true
53 test_file.unlink()
54 except (PermissionError, OSError):
55 # Skip cleanup if we can't access the file — expected in permission tests
56 pass
58 return messages
61@register()
62def paths_check(app_configs: Any, **kwargs: Any) -> list[Error]:
63 """
64 Check the various paths for existence, readability and writeability
65 """
67 return (
68 path_check("PAPERLESS_DATA_DIR", settings.DATA_DIR)
69 + path_check("PAPERLESS_EMPTY_TRASH_DIR", settings.EMPTY_TRASH_DIR)
70 + path_check("PAPERLESS_MEDIA_ROOT", settings.MEDIA_ROOT)
71 + path_check("PAPERLESS_CONSUMPTION_DIR", settings.CONSUMPTION_DIR)
72 )
75@register()
76def binaries_check(app_configs: Any, **kwargs: Any) -> list[Error]:
77 """
78 Paperless requires the existence of a few binaries, so we do some checks
79 for those here.
80 """
82 error = "Paperless can't find {}. Without it, consumption is impossible."
83 hint = "Either it's not in your ${PATH} or it's not installed."
85 binaries = (settings.CONVERT_BINARY, "tesseract", "gs")
87 check_messages = []
88 for binary in binaries:
89 if shutil.which(binary) is None: 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true
90 check_messages.append(Warning(error.format(binary), hint))
92 return check_messages
95@register()
96def debug_mode_check(app_configs: Any, **kwargs: Any) -> list[Warning]:
97 if settings.DEBUG: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 return [
99 Warning(
100 "DEBUG mode is enabled. Disable Debug mode. This is a serious "
101 "security issue, since it puts security overrides in place "
102 "which are meant to be only used during development. This "
103 "also means that paperless will tell anyone various "
104 "debugging information when something goes wrong.",
105 ),
106 ]
107 else:
108 return []
111@register()
112def settings_values_check(app_configs: Any, **kwargs: Any) -> list[Error | Warning]:
113 """
114 Validates at least some of the user provided settings
115 """
117 def _ocrmypdf_settings_check():
118 """
119 Validates some of the arguments which will be provided to ocrmypdf
120 against the valid options. Use "ocrmypdf --help" to see the valid
121 inputs
122 """
123 msgs = []
124 if settings.OCR_OUTPUT_TYPE not in { 124 ↛ 131line 124 didn't jump to line 131 because the condition on line 124 was never true
125 "pdfa",
126 "pdf",
127 "pdfa-1",
128 "pdfa-2",
129 "pdfa-3",
130 }:
131 msgs.append(
132 Error(f'OCR output type "{settings.OCR_OUTPUT_TYPE}" is not valid'),
133 )
135 if settings.OCR_MODE not in {"auto", "force", "redo", "off"}: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true
136 msgs.append(Error(f'OCR output mode "{settings.OCR_MODE}" is not valid'))
138 if settings.ARCHIVE_FILE_GENERATION not in {"auto", "always", "never"}: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 msgs.append(
140 Error(
141 "PAPERLESS_ARCHIVE_FILE_GENERATION setting "
142 f'"{settings.ARCHIVE_FILE_GENERATION}" is not valid',
143 ),
144 )
146 if settings.OCR_CLEAN not in {"clean", "clean-final", "none"}: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 msgs.append(Error(f'OCR clean mode "{settings.OCR_CLEAN}" is not valid'))
148 return msgs
150 def _timezone_validate():
151 """
152 Validates the user provided timezone is a valid timezone
153 """
154 import zoneinfo
156 msgs = []
157 if settings.TIME_ZONE not in zoneinfo.available_timezones(): 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
158 msgs.append(
159 Error(f'Timezone "{settings.TIME_ZONE}" is not a valid timezone'),
160 )
161 return msgs
163 def _email_certificate_validate():
164 msgs = []
165 # Existence checks
166 if ( 166 ↛ 170line 166 didn't jump to line 170 because the condition on line 166 was never true
167 settings.EMAIL_CERTIFICATE_FILE is not None
168 and not settings.EMAIL_CERTIFICATE_FILE.is_file()
169 ):
170 msgs.append(
171 Error(
172 f"Email cert {settings.EMAIL_CERTIFICATE_FILE} is not a file",
173 ),
174 )
175 return msgs
177 return (
178 _ocrmypdf_settings_check()
179 + _timezone_validate()
180 + _email_certificate_validate()
181 )
184@register()
185def audit_log_check(app_configs: Any, **kwargs: Any) -> list[Error]:
186 db_conn = connections["default"]
187 all_tables = db_conn.introspection.table_names()
188 result = []
190 if ("auditlog_logentry" in all_tables) and not settings.AUDIT_LOG_ENABLED: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true
191 result.append(
192 Warning(
193 ("auditlog table was found but audit log is disabled."),
194 ),
195 )
197 return result
200@register(Tags.compatibility)
201def check_v3_minimum_upgrade_version(
202 app_configs: object,
203 **kwargs: object,
204) -> list[Error]:
205 """
206 Enforce that upgrades to v3 must start from v2.20.15.
208 v3 squashes all prior migrations into 0001_squashed and 0002_squashed.
209 If a user skips v2.20.15, the data migration in 1075_workflowaction_order
210 never runs and the squash may apply schema changes against an incomplete
211 database state.
212 """
213 from django.db import DatabaseError
214 from django.db import OperationalError
216 try:
217 all_tables = connections["default"].introspection.table_names()
219 if "django_migrations" not in all_tables:
220 return []
222 with connections["default"].cursor() as cursor:
223 cursor.execute(
224 "SELECT name FROM django_migrations WHERE app = %s",
225 ["documents"],
226 )
227 applied: set[str] = {row[0] for row in cursor.fetchall()}
229 if not applied: 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true
230 return []
232 # Already in a valid v3 state
233 if {"0001_squashed", "0002_squashed"} & applied: 233 ↛ 237line 233 didn't jump to line 237 because the condition on line 233 was always true
234 return []
236 # On v2.20.15 exactly — squash will pick up cleanly from here
237 if "1075_workflowaction_order" in applied:
238 return []
240 except (DatabaseError, OperationalError):
241 return []
243 logger = logging.getLogger(__name__)
244 last_applied = sorted(applied)[-1] if applied else "(none)"
245 logger.error(
246 "V3 upgrade check failed: last applied documents migration is %r. "
247 "Expected '1075_workflowaction_order' (v2.20.15). "
248 "Ensure you have upgraded to v2.20.15 and run 'manage.py migrate' before upgrading to v3.",
249 last_applied,
250 )
252 return [
253 Error(
254 "Cannot upgrade to Paperless-ngx v3 from this version.",
255 hint=(
256 "Upgrading to v3 can only be performed from v2.20.15. "
257 "Please upgrade to v2.20.15, run migrations, then upgrade to v3. "
258 "See https://docs.paperless-ngx.com/setup/#upgrading for details."
259 ),
260 id="paperless.E002",
261 ),
262 ]
265@register()
266def check_deprecated_db_settings(
267 app_configs: object,
268 **kwargs: object,
269) -> list[Warning]:
270 """Check for deprecated database environment variables.
272 Detects legacy advanced options that should be migrated to
273 PAPERLESS_DB_OPTIONS. Returns one Warning per deprecated variable found.
274 """
275 deprecated_vars: dict[str, str] = {
276 "PAPERLESS_DB_TIMEOUT": "timeout",
277 "PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
278 "PAPERLESS_DBSSLMODE": "sslmode",
279 "PAPERLESS_DBSSLROOTCERT": "sslrootcert",
280 "PAPERLESS_DBSSLCERT": "sslcert",
281 "PAPERLESS_DBSSLKEY": "sslkey",
282 }
284 warnings: list[Warning] = []
286 for var_name, db_option_key in deprecated_vars.items():
287 if not os.getenv(var_name): 287 ↛ 289line 287 didn't jump to line 289 because the condition on line 287 was always true
288 continue
289 warnings.append(
290 Warning(
291 f"Deprecated environment variable: {var_name}",
292 hint=(
293 f"{var_name} is no longer supported and will be removed in v3.2. "
294 f"Set the equivalent option via PAPERLESS_DB_OPTIONS instead. "
295 f'Example: PAPERLESS_DB_OPTIONS=\'{{"{db_option_key}": "<value>"}}\'. '
296 "See https://docs.paperless-ngx.com/migration-v3/ for the full reference."
297 ),
298 id="paperless.W001",
299 ),
300 )
302 return warnings
305@register()
306def check_deprecated_v2_ocr_env_vars(
307 app_configs: object,
308 **kwargs: object,
309) -> list[Warning]:
310 """Warn when deprecated v2 OCR environment variables are set.
312 Users upgrading from v2 may still have these in their environment or
313 config files, where they are now silently ignored.
314 """
315 warnings: list[Warning] = []
317 if os.environ.get("PAPERLESS_OCR_SKIP_ARCHIVE_FILE"): 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true
318 warnings.append(
319 Warning(
320 "PAPERLESS_OCR_SKIP_ARCHIVE_FILE is set but has no effect. "
321 "Use PAPERLESS_ARCHIVE_FILE_GENERATION=never/always/auto instead.",
322 id="paperless.W002",
323 ),
324 )
326 ocr_mode = os.environ.get("PAPERLESS_OCR_MODE", "")
327 if ocr_mode in {"skip", "skip_noarchive"}: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 warnings.append(
329 Warning(
330 f"PAPERLESS_OCR_MODE={ocr_mode!r} is not a valid value. "
331 f"Use PAPERLESS_OCR_MODE=auto (and PAPERLESS_ARCHIVE_FILE_GENERATION=never "
332 f"if you used skip_noarchive) instead.",
333 id="paperless.W003",
334 ),
335 )
337 return warnings
340def get_tesseract_langs():
341 proc = subprocess.run(
342 [shutil.which("tesseract"), "--list-langs"],
343 capture_output=True,
344 )
346 # Decode bytes to string, split on newlines, trim out the header
347 proc_lines = proc.stdout.decode("utf8", errors="ignore").strip().split("\n")[1:]
349 return [x.strip() for x in proc_lines]
352@register()
353def check_default_language_available(app_configs: Any, **kwargs: Any) -> list[Error]:
354 errs = []
356 if not settings.OCR_LANGUAGE: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 errs.append(
358 Warning(
359 "No OCR language has been specified with PAPERLESS_OCR_LANGUAGE. "
360 "This means that tesseract will fallback to english.",
361 ),
362 )
363 return errs
365 # binaries_check in paperless will check and report if this doesn't exist
366 # So skip trying to do anything here and let that handle missing binaries
367 if shutil.which("tesseract") is not None: 367 ↛ 382line 367 didn't jump to line 382 because the condition on line 367 was always true
368 installed_langs = get_tesseract_langs()
370 specified_langs = [x.strip() for x in settings.OCR_LANGUAGE.split("+")]
372 for lang in specified_langs:
373 if lang not in installed_langs: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 errs.append(
375 Error(
376 f"The selected ocr language {lang} is "
377 f"not installed. Paperless cannot OCR your documents "
378 f"without it. Please fix PAPERLESS_OCR_LANGUAGE.",
379 ),
380 )
382 return errs