From b79edfd271cd7287364835f3e0985e9ab2527903 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:14:51 -0800 Subject: [PATCH] Adds the checks for depracated env vars --- src/paperless/checks.py | 48 ++++ src/paperless/settings/__init__.py | 88 +------ src/paperless/settings/custom.py | 278 ++++++++++++++++++++++ src/paperless/tests/test_checks.py | 67 ++++++ src/paperless/tests/test_settings.py | 344 +++++++++++++++++++++++---- 5 files changed, 689 insertions(+), 136 deletions(-) create mode 100644 src/paperless/settings/custom.py diff --git a/src/paperless/checks.py b/src/paperless/checks.py index 7df85d146..b26cb5098 100644 --- a/src/paperless/checks.py +++ b/src/paperless/checks.py @@ -202,3 +202,51 @@ def audit_log_check(app_configs, **kwargs): ) return result + + +@register() +def check_deprecated_db_settings(app_configs, **kwargs) -> list[Warning]: + """Check for deprecated database environment variables. + + Detects legacy advanced options that should be migrated to + PAPERLESS_DB_OPTIONS. + + Returns: + List of Django Warning instances for any deprecated vars found. + """ + deprecated_vars = { + "PAPERLESS_DB_TIMEOUT": "timeout (or connect_timeout for Postgres/MariaDB)", + "PAPERLESS_DB_POOLSIZE": "pool.min_size,pool.max_size", + "PAPERLESS_DBSSLMODE": "sslmode (or ssl_mode for MariaDB)", + "PAPERLESS_DBSSLROOTCERT": "sslrootcert (or ssl.ca for MariaDB)", + "PAPERLESS_DBSSLCERT": "sslcert (or ssl.cert for MariaDB)", + "PAPERLESS_DBSSLKEY": "sslkey (or ssl.key for MariaDB)", + } + + found_vars = [] + for var_name in deprecated_vars: + if os.getenv(var_name): + found_vars.append(var_name) + + if not found_vars: + return [] + + # Build migration example + examples = [] + for var in found_vars: + examples.append(f"{var} -> PAPERLESS_DB_OPTIONS={deprecated_vars[var]}=") + + return [ + Warning( + "Deprecated database environment variables detected", + # TODO: Need to check this URL + hint=( + f"Found: {', '.join(found_vars)}. " + "These will be removed in v3.2. " + "Migrate to PAPERLESS_DB_OPTIONS instead. " + f"Examples: {'; '.join(examples[:3])}. " + "See https://docs.paperless-ngx.com/migration/" + ), + id="paperless.W001", + ), + ] diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 88396a4bf..5eace0208 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -16,6 +16,7 @@ from dateparser.languages.loader import LocaleDataLoader from django.utils.translation import gettext_lazy as _ from dotenv import load_dotenv +from paperless.settings.custom import parse_db_settings from paperless.settings.parsers import get_bool_from_env from paperless.settings.parsers import get_float_from_env from paperless.settings.parsers import get_int_from_env @@ -662,92 +663,9 @@ EMAIL_CERTIFICATE_FILE = get_path_from_env("PAPERLESS_EMAIL_CERTIFICATE_LOCATION ############################################################################### # Database # ############################################################################### -def _parse_db_settings() -> dict: - databases = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": DATA_DIR / "db.sqlite3", - "OPTIONS": {}, - }, - } - if os.getenv("PAPERLESS_DBHOST"): - # Have sqlite available as a second option for management commands - # This is important when migrating to/from sqlite - databases["sqlite"] = databases["default"].copy() +DATABASES = parse_db_settings(DATA_DIR) - databases["default"] = { - "HOST": os.getenv("PAPERLESS_DBHOST"), - "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"), - "USER": os.getenv("PAPERLESS_DBUSER", "paperless"), - "PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"), - "OPTIONS": {}, - } - if os.getenv("PAPERLESS_DBPORT"): - databases["default"]["PORT"] = os.getenv("PAPERLESS_DBPORT") - - # Leave room for future extensibility - if os.getenv("PAPERLESS_DBENGINE") == "mariadb": - engine = "django.db.backends.mysql" - # Contrary to Postgres, Django does not natively support connection pooling for MariaDB. - # However, since MariaDB uses threads instead of forks, establishing connections is significantly faster - # compared to PostgreSQL, so the lack of pooling is not an issue - options = { - "read_default_file": "/etc/mysql/my.cnf", - "charset": "utf8mb4", - "ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"), - "ssl": { - "ca": os.getenv("PAPERLESS_DBSSLROOTCERT", None), - "cert": os.getenv("PAPERLESS_DBSSLCERT", None), - "key": os.getenv("PAPERLESS_DBSSLKEY", None), - }, - } - - else: # Default to PostgresDB - engine = "django.db.backends.postgresql" - options = { - "sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"), - "sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT", None), - "sslcert": os.getenv("PAPERLESS_DBSSLCERT", None), - "sslkey": os.getenv("PAPERLESS_DBSSLKEY", None), - } - if int(os.getenv("PAPERLESS_DB_POOLSIZE", 0)) > 0: - options.update( - { - "pool": { - "min_size": 1, - "max_size": int(os.getenv("PAPERLESS_DB_POOLSIZE")), - }, - }, - ) - - databases["default"]["ENGINE"] = engine - databases["default"]["OPTIONS"].update(options) - - if os.getenv("PAPERLESS_DB_TIMEOUT") is not None: - if databases["default"]["ENGINE"] == "django.db.backends.sqlite3": - databases["default"]["OPTIONS"].update( - {"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))}, - ) - else: - databases["default"]["OPTIONS"].update( - {"connect_timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))}, - ) - databases["sqlite"]["OPTIONS"].update( - {"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))}, - ) - return databases - - -DATABASES = _parse_db_settings() - -if os.getenv("PAPERLESS_DBENGINE") == "mariadb": - # Silence Django error on old MariaDB versions. - # VARCHAR can support > 255 in modern versions - # https://docs.djangoproject.com/en/4.1/ref/checks/#database - # https://mariadb.com/kb/en/innodb-system-variables/#innodb_large_prefix - SILENCED_SYSTEM_CHECKS = ["mysql.W003"] - -DEFAULT_AUTO_FIELD = "django.db.models.AutoField" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" ############################################################################### # Internationalization # diff --git a/src/paperless/settings/custom.py b/src/paperless/settings/custom.py new file mode 100644 index 000000000..6885f48a1 --- /dev/null +++ b/src/paperless/settings/custom.py @@ -0,0 +1,278 @@ +import os +from pathlib import Path +from typing import TypeAlias + +from celery.schedules import crontab + +from paperless.settings.parsers import get_choice_from_env +from paperless.settings.parsers import get_int_from_env +from paperless.settings.parsers import parse_dict_from_str + +# Covers: ENGINE/NAME/HOST/USER/PASSWORD (str), PORT (int), OPTIONS (dict) +DatabaseConfig: TypeAlias = dict[str, str | int | dict[str, str | int | dict | None]] + + +def parse_hosting_settings() -> tuple[str | None, str, str, str, str]: + script_name = os.getenv("PAPERLESS_FORCE_SCRIPT_NAME") + base_url = (script_name or "") + "/" + login_url = base_url + "accounts/login/" + login_redirect_url = base_url + "dashboard" + logout_redirect_url = os.getenv( + "PAPERLESS_LOGOUT_REDIRECT_URL", + login_url + "?loggedout=1", + ) + return script_name, base_url, login_url, login_redirect_url, logout_redirect_url + + +def parse_redis_url(env_redis: str | None) -> tuple[str, str]: + """ + Gets the Redis information from the environment or a default and handles + converting from incompatible django_channels and celery formats. + + Returns a tuple of (celery_url, channels_url) + """ + + # Not set, return a compatible default + if env_redis is None: + return ("redis://localhost:6379", "redis://localhost:6379") + + if "unix" in env_redis.lower(): + # channels_redis socket format, looks like: + # "unix:///path/to/redis.sock" + _, path = env_redis.split(":") + # Optionally setting a db number + if "?db=" in env_redis: + path, number = path.split("?db=") + return (f"redis+socket:{path}?virtual_host={number}", env_redis) + else: + return (f"redis+socket:{path}", env_redis) + + elif "+socket" in env_redis.lower(): + # celery socket style, looks like: + # "redis+socket:///path/to/redis.sock" + _, path = env_redis.split(":") + if "?virtual_host=" in env_redis: + # Virtual host (aka db number) + path, number = path.split("?virtual_host=") + return (env_redis, f"unix:{path}?db={number}") + else: + return (env_redis, f"unix:{path}") + + # Not a socket + return (env_redis, env_redis) + + +def parse_beat_schedule() -> dict: + """ + Configures the scheduled tasks, according to default or + environment variables. Task expiration is configured so the task will + expire (and not run), shortly before the default frequency will put another + of the same task into the queue + + + https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-entries + https://docs.celeryq.dev/en/latest/userguide/calling.html#expiration + """ + schedule = {} + tasks = [ + { + "name": "Check all e-mail accounts", + "env_key": "PAPERLESS_EMAIL_TASK_CRON", + # Default every ten minutes + "env_default": "*/10 * * * *", + "task": "paperless_mail.tasks.process_mail_accounts", + "options": { + # 1 minute before default schedule sends again + "expires": 9.0 * 60.0, + }, + }, + { + "name": "Train the classifier", + "env_key": "PAPERLESS_TRAIN_TASK_CRON", + # Default hourly at 5 minutes past the hour + "env_default": "5 */1 * * *", + "task": "documents.tasks.train_classifier", + "options": { + # 1 minute before default schedule sends again + "expires": 59.0 * 60.0, + }, + }, + { + "name": "Optimize the index", + "env_key": "PAPERLESS_INDEX_TASK_CRON", + # Default daily at midnight + "env_default": "0 0 * * *", + "task": "documents.tasks.index_optimize", + "options": { + # 1 hour before default schedule sends again + "expires": 23.0 * 60.0 * 60.0, + }, + }, + { + "name": "Perform sanity check", + "env_key": "PAPERLESS_SANITY_TASK_CRON", + # Default Sunday at 00:30 + "env_default": "30 0 * * sun", + "task": "documents.tasks.sanity_check", + "options": { + # 1 hour before default schedule sends again + "expires": ((7.0 * 24.0) - 1.0) * 60.0 * 60.0, + }, + }, + { + "name": "Empty trash", + "env_key": "PAPERLESS_EMPTY_TRASH_TASK_CRON", + # Default daily at 01:00 + "env_default": "0 1 * * *", + "task": "documents.tasks.empty_trash", + "options": { + # 1 hour before default schedule sends again + "expires": 23.0 * 60.0 * 60.0, + }, + }, + { + "name": "Check and run scheduled workflows", + "env_key": "PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON", + # Default hourly at 5 minutes past the hour + "env_default": "5 */1 * * *", + "task": "documents.tasks.check_scheduled_workflows", + "options": { + # 1 minute before default schedule sends again + "expires": 59.0 * 60.0, + }, + }, + ] + for task in tasks: + # Either get the environment setting or use the default + value = os.getenv(task["env_key"], task["env_default"]) + # Don't add disabled tasks to the schedule + if value == "disable": + continue + # I find https://crontab.guru/ super helpful + # crontab(5) format + # - five time-and-date fields + # - separated by at least one blank + minute, hour, day_month, month, day_week = value.split(" ") + + schedule[task["name"]] = { + "task": task["task"], + "schedule": crontab(minute, hour, day_week, day_month, month), + "options": task["options"], + } + + return schedule + + +def parse_db_settings(data_dir: Path) -> dict[str, DatabaseConfig]: + """Parse database settings from environment variables. + + Core connection variables (no deprecation): + - PAPERLESS_DBENGINE (sqlite/postgresql/mariadb) + - PAPERLESS_DBHOST, PAPERLESS_DBPORT + - PAPERLESS_DBNAME, PAPERLESS_DBUSER, PAPERLESS_DBPASS + + Advanced options can be set via: + - Legacy individual env vars (deprecated in v3.0, removed in v3.2) + - PAPERLESS_DB_OPTIONS (recommended v3+ approach) + + Args: + data_dir: The data directory path for SQLite database location. + + Returns: + A databases dict suitable for Django DATABASES setting. + """ + engine = get_choice_from_env( + "PAPERLESS_DBENGINE", + {"sqlite", "postgresql", "mariadb"}, + default="sqlite", + ) + + match engine: + case "sqlite": + db_config = { + "ENGINE": "django.db.backends.sqlite3", + "NAME": str((data_dir / "db.sqlite3").resolve()), + } + base_options = {} + + case "postgresql": + db_config = { + "ENGINE": "django.db.backends.postgresql", + "HOST": os.getenv("PAPERLESS_DBHOST"), + "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"), + "USER": os.getenv("PAPERLESS_DBUSER", "paperless"), + "PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"), + } + + base_options = { + "sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"), + "sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT"), + "sslcert": os.getenv("PAPERLESS_DBSSLCERT"), + "sslkey": os.getenv("PAPERLESS_DBSSLKEY"), + } + + if (pool_size := get_int_from_env("PAPERLESS_DB_POOLSIZE")) is not None: + base_options["pool"] = { + "min_size": 1, + "max_size": pool_size, + } + + case "mariadb": + db_config = { + "ENGINE": "django.db.backends.mysql", + "HOST": os.getenv("PAPERLESS_DBHOST"), + "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"), + "USER": os.getenv("PAPERLESS_DBUSER", "paperless"), + "PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"), + } + + base_options = { + "read_default_file": "/etc/mysql/my.cnf", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"), + "ssl": { + "ca": os.getenv("PAPERLESS_DBSSLROOTCERT"), + "cert": os.getenv("PAPERLESS_DBSSLCERT"), + "key": os.getenv("PAPERLESS_DBSSLKEY"), + }, + } + + # Handle port setting for external databases + if ( + engine in ("postgresql", "mariadb") + and (port := get_int_from_env("PAPERLESS_DBPORT")) is not None + ): + db_config["PORT"] = port + + # Handle timeout setting (common across all engines, different key names) + if (timeout := get_int_from_env("PAPERLESS_DB_TIMEOUT")) is not None: + timeout_key = "timeout" if engine == "sqlite" else "connect_timeout" + base_options[timeout_key] = timeout + + # Apply PAPERLESS_DB_OPTIONS overrides + db_config["OPTIONS"] = parse_dict_from_str( + os.getenv("PAPERLESS_DB_OPTIONS"), + defaults=base_options, + type_map={ + # SQLite options + "timeout": int, + # Postgres/MariaDB options + "connect_timeout": int, + "pool.min_size": int, + "pool.max_size": int, + }, + ) + + databases = {"default": db_config} + + # Add SQLite fallback for PostgreSQL/MariaDB + # TODO: Is this really useful/used? + if engine in ("postgresql", "mariadb"): + databases["sqlite"] = { + "ENGINE": "django.db.backends.sqlite3", + "NAME": str((data_dir / "db.sqlite3").resolve()), + "OPTIONS": {}, + } + + return databases diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py index fc6150826..0e70cf792 100644 --- a/src/paperless/tests/test_checks.py +++ b/src/paperless/tests/test_checks.py @@ -2,13 +2,16 @@ import os from pathlib import Path from unittest import mock +import pytest from django.test import TestCase from django.test import override_settings +from pytest_mock import MockerFixture from documents.tests.utils import DirectoriesMixin from documents.tests.utils import FileSystemAssertsMixin from paperless.checks import audit_log_check from paperless.checks import binaries_check +from paperless.checks import check_deprecated_db_settings from paperless.checks import debug_mode_check from paperless.checks import paths_check from paperless.checks import settings_values_check @@ -237,3 +240,67 @@ class TestAuditLogChecks(TestCase): ("auditlog table was found but audit log is disabled."), msg.msg, ) + + +class TestDeprecatedDbSettings: + """Test suite for deprecated database settings system check.""" + + def test_no_deprecated_vars_no_warning( + self, + mocker: MockerFixture, + ) -> None: + """Test that no warning is raised when no deprecated vars are set.""" + mocker.patch.dict(os.environ, {}, clear=True) + + warnings = check_deprecated_db_settings(None) + assert warnings == [] + + @pytest.mark.parametrize( + ("env_var", "expected_hint_fragment"), + [ + ("PAPERLESS_DB_TIMEOUT", "timeout"), + ("PAPERLESS_DB_POOLSIZE", "pool.min_size,pool.max_size"), + ("PAPERLESS_DBSSLMODE", "sslmode"), + ("PAPERLESS_DBSSLROOTCERT", "sslrootcert"), + ("PAPERLESS_DBSSLCERT", "sslcert"), + ("PAPERLESS_DBSSLKEY", "sslkey"), + ], + ) + def test_deprecated_var_triggers_warning( + self, + mocker: MockerFixture, + env_var: str, + expected_hint_fragment: str, + ) -> None: + """Test that each deprecated var triggers appropriate warning.""" + mocker.patch.dict(os.environ, {env_var: "some_value"}, clear=True) + + warnings = check_deprecated_db_settings(None) + + assert len(warnings) == 1 + assert warnings[0].id == "paperless.W001" + assert env_var in warnings[0].hint + assert expected_hint_fragment in warnings[0].hint + assert "v3.2" in warnings[0].hint + + def test_multiple_deprecated_vars( + self, + mocker: MockerFixture, + ) -> None: + """Test that multiple deprecated vars are all listed in warning.""" + mocker.patch.dict( + os.environ, + { + "PAPERLESS_DB_TIMEOUT": "30", + "PAPERLESS_DB_POOLSIZE": "10", + "PAPERLESS_DBSSLMODE": "require", + }, + clear=True, + ) + + warnings = check_deprecated_db_settings(None) + + assert len(warnings) == 1 + assert "PAPERLESS_DB_TIMEOUT" in warnings[0].hint + assert "PAPERLESS_DB_POOLSIZE" in warnings[0].hint + assert "PAPERLESS_DBSSLMODE" in warnings[0].hint diff --git a/src/paperless/tests/test_settings.py b/src/paperless/tests/test_settings.py index 02db82ef2..9015106e7 100644 --- a/src/paperless/tests/test_settings.py +++ b/src/paperless/tests/test_settings.py @@ -1,19 +1,21 @@ import datetime import os +from pathlib import Path from unittest import TestCase from unittest import mock import pytest from celery.schedules import crontab +from pytest_mock import MockerFixture from paperless.settings import _parse_base_paths from paperless.settings import _parse_beat_schedule from paperless.settings import _parse_dateparser_languages -from paperless.settings import _parse_db_settings from paperless.settings import _parse_ignore_dates from paperless.settings import _parse_paperless_url from paperless.settings import _parse_redis_url from paperless.settings import default_threads_per_worker +from paperless.settings.custom import parse_db_settings class TestIgnoreDateParsing(TestCase): @@ -378,62 +380,302 @@ class TestCeleryScheduleParsing(TestCase): ) -class TestDBSettings(TestCase): - def test_db_timeout_with_sqlite(self) -> None: - """ - GIVEN: - - PAPERLESS_DB_TIMEOUT is set - WHEN: - - Settings are parsed - THEN: - - PAPERLESS_DB_TIMEOUT set for sqlite - """ - with mock.patch.dict( - os.environ, - { - "PAPERLESS_DB_TIMEOUT": "10", - }, - ): - databases = _parse_db_settings() +class TestParseDbSettings: + """Test suite for parse_db_settings function.""" - self.assertDictEqual( + @pytest.mark.parametrize( + ("env_vars", "expected_database_settings"), + [ + pytest.param( + {}, { - "timeout": 10.0, + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": None, # Will be replaced with tmp_path + "OPTIONS": {}, + }, }, - databases["default"]["OPTIONS"], - ) - - def test_db_timeout_with_not_sqlite(self) -> None: - """ - GIVEN: - - PAPERLESS_DB_TIMEOUT is set but db is not sqlite - WHEN: - - Settings are parsed - THEN: - - PAPERLESS_DB_TIMEOUT set correctly in non-sqlite db & for fallback sqlite db - """ - with mock.patch.dict( - os.environ, - { - "PAPERLESS_DBHOST": "127.0.0.1", - "PAPERLESS_DB_TIMEOUT": "10", - }, - ): - databases = _parse_db_settings() - - self.assertDictEqual( - databases["default"]["OPTIONS"], - databases["default"]["OPTIONS"] - | { - "connect_timeout": 10.0, - }, - ) - self.assertDictEqual( + id="default-sqlite", + ), + pytest.param( { - "timeout": 10.0, + "PAPERLESS_DBENGINE": "sqlite", + "PAPERLESS_DB_OPTIONS": "timeout=30", }, - databases["sqlite"]["OPTIONS"], + { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": None, # Will be replaced with tmp_path + "OPTIONS": { + "timeout": 30, + }, + }, + }, + id="sqlite-with-timeout-override", + ), + pytest.param( + { + "PAPERLESS_DBENGINE": "postgresql", + "PAPERLESS_DBHOST": "localhost", + }, + { + "default": { + "ENGINE": "django.db.backends.postgresql", + "HOST": "localhost", + "NAME": "paperless", + "USER": "paperless", + "PASSWORD": "paperless", + "OPTIONS": { + "sslmode": "prefer", + "sslrootcert": None, + "sslcert": None, + "sslkey": None, + }, + }, + "sqlite": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": None, # Will be replaced with tmp_path + "OPTIONS": {}, + }, + }, + id="postgresql-defaults", + ), + pytest.param( + { + "PAPERLESS_DBENGINE": "postgresql", + "PAPERLESS_DBHOST": "paperless-db-host", + "PAPERLESS_DBPORT": "1111", + "PAPERLESS_DBNAME": "customdb", + "PAPERLESS_DBUSER": "customuser", + "PAPERLESS_DBPASS": "custompass", + "PAPERLESS_DB_OPTIONS": "pool.max_size=50,pool.min_size=2,sslmode=require", + }, + { + "default": { + "ENGINE": "django.db.backends.postgresql", + "HOST": "paperless-db-host", + "PORT": 1111, + "NAME": "customdb", + "USER": "customuser", + "PASSWORD": "custompass", + "OPTIONS": { + "sslmode": "require", + "sslrootcert": None, + "sslcert": None, + "sslkey": None, + "pool": { + "min_size": 2, + "max_size": 50, + }, + }, + }, + "sqlite": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": None, # Will be replaced with tmp_path + "OPTIONS": {}, + }, + }, + id="postgresql-overrides", + ), + pytest.param( + { + "PAPERLESS_DBENGINE": "postgresql", + "PAPERLESS_DBHOST": "pghost", + "PAPERLESS_DB_POOLSIZE": "10", + }, + { + "default": { + "ENGINE": "django.db.backends.postgresql", + "HOST": "pghost", + "NAME": "paperless", + "USER": "paperless", + "PASSWORD": "paperless", + "OPTIONS": { + "sslmode": "prefer", + "sslrootcert": None, + "sslcert": None, + "sslkey": None, + "pool": { + "min_size": 1, + "max_size": 10, + }, + }, + }, + "sqlite": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": None, # Will be replaced with tmp_path + "OPTIONS": {}, + }, + }, + id="postgresql-legacy-poolsize", + ), + pytest.param( + { + "PAPERLESS_DBENGINE": "postgresql", + "PAPERLESS_DBHOST": "pghost", + "PAPERLESS_DBSSLMODE": "require", + "PAPERLESS_DBSSLROOTCERT": "/certs/ca.crt", + "PAPERLESS_DB_TIMEOUT": "30", + }, + { + "default": { + "ENGINE": "django.db.backends.postgresql", + "HOST": "pghost", + "NAME": "paperless", + "USER": "paperless", + "PASSWORD": "paperless", + "OPTIONS": { + "sslmode": "require", + "sslrootcert": "/certs/ca.crt", + "sslcert": None, + "sslkey": None, + "connect_timeout": 30, + }, + }, + "sqlite": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": None, # Will be replaced with tmp_path + "OPTIONS": {}, + }, + }, + id="postgresql-legacy-ssl-and-timeout", + ), + pytest.param( + { + "PAPERLESS_DBENGINE": "mariadb", + "PAPERLESS_DBHOST": "localhost", + }, + { + "default": { + "ENGINE": "django.db.backends.mysql", + "HOST": "localhost", + "NAME": "paperless", + "USER": "paperless", + "PASSWORD": "paperless", + "OPTIONS": { + "read_default_file": "/etc/mysql/my.cnf", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "ssl_mode": "PREFERRED", + "ssl": { + "ca": None, + "cert": None, + "key": None, + }, + }, + }, + "sqlite": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": None, # Will be replaced with tmp_path + "OPTIONS": {}, + }, + }, + id="mariadb-defaults", + ), + pytest.param( + { + "PAPERLESS_DBENGINE": "mariadb", + "PAPERLESS_DBHOST": "paperless-mariadb-host", + "PAPERLESS_DBPORT": "5555", + "PAPERLESS_DBUSER": "my-cool-user", + "PAPERLESS_DBPASS": "my-secure-password", + "PAPERLESS_DB_OPTIONS": "ssl.ca=/path/to/ca.pem,ssl_mode=REQUIRED", + }, + { + "default": { + "ENGINE": "django.db.backends.mysql", + "HOST": "paperless-mariadb-host", + "PORT": 5555, + "NAME": "paperless", + "USER": "my-cool-user", + "PASSWORD": "my-secure-password", + "OPTIONS": { + "read_default_file": "/etc/mysql/my.cnf", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "ssl_mode": "REQUIRED", + "ssl": { + "ca": "/path/to/ca.pem", + "cert": None, + "key": None, + }, + }, + }, + "sqlite": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": None, # Will be replaced with tmp_path + "OPTIONS": {}, + }, + }, + id="mariadb-overrides", + ), + pytest.param( + { + "PAPERLESS_DBENGINE": "mariadb", + "PAPERLESS_DBHOST": "mariahost", + "PAPERLESS_DBSSLMODE": "REQUIRED", + "PAPERLESS_DBSSLROOTCERT": "/certs/ca.pem", + "PAPERLESS_DBSSLCERT": "/certs/client.pem", + "PAPERLESS_DBSSLKEY": "/certs/client.key", + "PAPERLESS_DB_TIMEOUT": "25", + }, + { + "default": { + "ENGINE": "django.db.backends.mysql", + "HOST": "mariahost", + "NAME": "paperless", + "USER": "paperless", + "PASSWORD": "paperless", + "OPTIONS": { + "read_default_file": "/etc/mysql/my.cnf", + "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", + "ssl_mode": "REQUIRED", + "ssl": { + "ca": "/certs/ca.pem", + "cert": "/certs/client.pem", + "key": "/certs/client.key", + }, + "connect_timeout": 25, + }, + }, + "sqlite": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": None, # Will be replaced with tmp_path + "OPTIONS": {}, + }, + }, + id="mariadb-legacy-ssl-and-timeout", + ), + ], + ) + def test_parse_db_settings( + self, + tmp_path: Path, + mocker: MockerFixture, + env_vars: dict[str, str], + expected_database_settings: dict[str, dict], + ) -> None: + """Test various database configurations with defaults and overrides.""" + # Clear environment and set test vars + mocker.patch.dict(os.environ, env_vars, clear=True) + + # Update expected paths with actual tmp_path + if ( + "default" in expected_database_settings + and expected_database_settings["default"]["NAME"] is None + ): + expected_database_settings["default"]["NAME"] = str( + tmp_path / "db.sqlite3", ) + if "sqlite" in expected_database_settings: + expected_database_settings["sqlite"]["NAME"] = str( + tmp_path / "db.sqlite3", + ) + + settings = parse_db_settings(tmp_path) + + assert settings == expected_database_settings class TestPaperlessURLSettings(TestCase):