Coverage for postrfp / conf / settings.py: 100%
101 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 01:35 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 01:35 +0000
1import os
2import sys
3import time
4import uuid
5from enum import Enum
6from pathlib import Path
7from typing import Tuple, Any, Generator, Self, Optional
9from pydantic_settings import SettingsConfigDict, BaseSettings
10from pydantic import EmailStr, model_validator
12from postrfp.shared.constants import RunMode
15class Mailer(str, Enum):
16 postmark = "postmark"
17 logfile = "logfile"
20LIVE_SETTINGS_PATH = "/etc/postrfp/postrfp-settings.env"
23class AppSettings(BaseSettings):
24 model_config = SettingsConfigDict(
25 env_prefix="postrfp_",
26 env_file=(LIVE_SETTINGS_PATH, ".env"),
27 env_file_encoding="utf-8",
28 )
30 run_mode: RunMode = RunMode.production
32 db_name: str = "postrfp_dev"
33 db_host: str = "localhost"
34 db_user: str = "root"
35 db_password: str = ""
36 db_brand: str = "mariadb"
37 db_driver: str = "mariadbconnector"
38 # db_driver: str = "mysqldb"
39 crypt_key: str = ""
40 webapp_hostname: str = "localhost"
41 data_directory: str = "./.postrfp-data/"
42 log_directory: str = "./.postrfp-logs/"
43 jwt_expiry_minutes: int = 20 # Default to 20 minutes
44 jwt_refresh_token_expiry_hours: int = 168 # Default to 7 days
46 max_failed_login_attempts: int = 5
47 lockout_duration_minutes: int = 20
49 # Argon2 password hashing parameters
50 argon2_time_cost: int = 2
51 argon2_memory_cost: int = 102400 # 100 MB (in KiB)
52 argon2_parallelism: int = 8
53 argon2_salt_bytes: int = 16 # Optional: Override default salt length
54 argon2_hash_bytes: int = 16 # Optional: Override default hash length
56 remote_events_url: str = "DUMMY_LOG_SERVER"
58 # WSGI Application Mount Paths - set to None to disable mounting
59 app_path_auth: Optional[str] = "/auth"
60 app_path_hooks: Optional[str] = "/hooks"
61 app_path_buyer: Optional[str] = "/buyer"
62 app_path_vendor: Optional[str] = "/vendor"
63 app_path_fsm: Optional[str] = "/fsm"
64 app_path_ref: Optional[str] = "/ref"
65 app_path_internal: Optional[str] = "/internal"
66 app_path_openapi: Optional[str] = "/openapi" # dev only
68 mailer: Mailer = Mailer.logfile
69 email_from_address: EmailStr = "notifications@postrp.com"
70 email_to_override: EmailStr = "test@blackhole.postmarkapp.com"
71 postmark_api_key: str = "POSTMARK_API_TEST"
72 postmark_logging_key: str = "POSTMARK_API_TEST"
73 system_name: str = "PostRFP"
74 template_dir: str = "conf/postmarktmpls/txt/"
76 # Background task / executor configuration
77 # Accepts values: "inline" or "dagu". Defaults to inline for tests/dev.
78 task_executor: str = "inline"
79 # Base URL for Dagu workflow engine (only used when task_executor == "dagu")
80 dagu_base_url: str = "http://localhost:8080/api/v2/"
81 # Optional bearer token for authenticating with Dagu API
82 dagu_api_token: str | None = None
84 app_base_url: str = "http://localhost:9000"
86 @property
87 def sqlalchemy_dsn(self) -> str:
88 return self._build_dsn(self.db_name)
90 def _build_dsn(self, db_name: str) -> str:
91 # mariadb+mariadbconnector://<user>:<password>@<host>[:<port>]/<dbname>
92 return (
93 f"{self.db_brand}+{self.db_driver}://{self.db_user}:{self.db_password}"
94 f"@{self.db_host}/{db_name}"
95 )
97 def conn_string(self, db_name: Optional[str] = None) -> str:
98 name = self.db_name if db_name is None else db_name
99 return self._build_dsn(name)
101 @property
102 def cache_dir(self) -> Path:
103 return Path(self.data_directory) / "cache" / self.db_name
105 @property
106 def attachments_dir(self) -> Path:
107 return Path(self.data_directory) / "attachments" / self.db_name
109 @model_validator(mode="after")
110 def validate_dirs(self) -> Self:
111 """
112 Check that the data directory exists, that correct permissions are set
113 and that cache and attachment subdirectories exists
114 """
115 data_directory = Path(self.data_directory)
116 if not data_directory.is_dir():
117 m = f"\n !!! {data_directory} directory not found - required for cache/ and attachments/"
118 sys.exit(m)
120 for folder in ["cache", "attachments"]:
121 app_directory = data_directory / folder
122 app_directory.mkdir(exist_ok=True)
123 self.test_usable(app_directory)
125 return self
127 def random_cache_file_path(self) -> Tuple[str, Path]:
128 fname = "%s.tmp" % uuid.uuid4().hex
129 return fname, self.cache_dir / fname
131 @classmethod
132 def test_usable(cls, dir_name):
133 try:
134 ts = str(time.time())
135 test_file_path = os.path.join(dir_name, f"proc_{os.getpid()}_test.txt")
136 with open(test_file_path, "w") as tw:
137 tw.write(ts)
138 with open(test_file_path, "r") as tr:
139 tr.read()
140 os.remove(test_file_path)
141 except IOError as ioe:
142 sys.exit(
143 "Test read/write to data directory %s failed with %s" % (dir_name, ioe)
144 )
146 def __iter__(self: Self) -> Generator[Tuple[str, Any], None, None]:
147 for key, value in self.model_dump().items():
148 if value and "password" in key or "key" in key:
149 yield key, "-----"
150 else:
151 yield key, value