Coverage for src/cstlcore/settings.py: 94%
124 statements
« prev ^ index » next coverage.py v7.9.1, created at 2026-02-19 12:46 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2026-02-19 12:46 +0000
1import logging
2from enum import Enum
3from functools import cached_property
4from pathlib import Path
5from typing import ClassVar
7from pydantic import (
8 BaseModel,
9 Field,
10 HttpUrl,
11 PostgresDsn,
12 ValidationError,
13 model_validator
14)
15from pydantic_settings import BaseSettings, SettingsConfigDict
16from sqlalchemy.engine.url import URL
19class NewsletterConfig(BaseModel):
20 secret_key: str | None = None
21 # Temporary, should be removed later
22 admin_token: str | None = None
24 @model_validator(mode="after")
25 def validate_secret_key(self) -> "NewsletterConfig":
26 if not self.secret_key:
27 logging.warning(
28 "[Settings] NEWSLETTER__SECRET_KEY not set. Newsletter subscription disabled."
29 )
30 return self
33class HostnameConfig(BaseModel):
34 current: HttpUrl = Field(default=HttpUrl("http://localhost:8001"))
35 graph_api: HttpUrl = Field(default=HttpUrl("http://localhost:8000"))
36 sse: HttpUrl = Field(default=HttpUrl("http://localhost:8002"))
37 frontend: HttpUrl = Field(default=HttpUrl("http://localhost:3000"))
40class PostgresConfig(BaseModel):
41 user: str = Field(default="postgres")
42 password: str = Field(default="password", min_length=8)
43 host: str = Field(default="localhost")
44 port: int = Field(default=5432)
45 db_name: str = Field(default="cstl", alias="db")
46 test_db_name: str = Field(default="test", alias="test_db")
47 test_port: int = Field(default=5433)
49 @cached_property
50 def uri(self) -> URL:
51 url = URL.create(
52 "postgresql",
53 username=self.user,
54 password=self.password,
55 host=self.host,
56 port=self.port,
57 database=self.db_name,
58 )
59 return url
61 @cached_property
62 def test_uri(self) -> URL:
63 url = URL.create(
64 "postgresql",
65 username=self.user,
66 password=self.password,
67 host=self.host,
68 port=self.test_port,
69 database=self.test_db_name,
70 )
71 return url
73 @model_validator(mode="after")
74 def validate_uri(self) -> "PostgresConfig":
75 # Validate the URI once after model initialization
76 try:
77 _ = PostgresDsn(str(self.uri))
78 except ValidationError as e:
79 raise ValueError(f"Invalid Postgres URI: {self.uri}") from e
80 return self
83class GoogleAuthConfig(BaseModel):
84 client_id: str | None = None
85 client_secret: str | None = None
86 redirect_uri: HttpUrl = HttpUrl("http://localhost:8001/auth/google/callback")
87 token_uri: HttpUrl = HttpUrl("https://oauth2.googleapis.com/token")
88 auth_uri: HttpUrl = HttpUrl("https://accounts.google.com/o/oauth2/auth")
90 @model_validator(mode="after")
91 def validate_client_credentials(self) -> "GoogleAuthConfig":
92 if not self.client_id or not self.client_secret:
93 logging.warning(
94 "[Settings] Google OAuth client_id or client_secret not set. Google authentication disabled."
95 )
96 return self
99class SMTPConfig(BaseModel):
100 email: str | None = None
101 password: str | None = None
103 @model_validator(mode="after")
104 def validate_smtp_credentials(self) -> "SMTPConfig":
105 if not self.email or not self.password:
106 logging.warning(
107 "[Settings] SMTP email or password not set. Email sending disabled."
108 )
109 return self
112class JWTConfig(BaseModel):
113 secret_key: str | None = None
114 algorithm: str = Field(default="HS256")
115 access_token_expire_hours: int = Field(default=24)
117 @model_validator(mode="after")
118 def validate_secret_key(self) -> "JWTConfig":
119 if not self.secret_key:
120 raise ValueError(f"JWT__SECRET_KEY must be set, current: {self.secret_key}")
121 return self
124class FileSystemConfig(BaseModel):
125 data_directory: Path = Field(default=Path("./data"))
126 map_directory: Path = Field(default=Path("./data/maps"))
128 def ensure_directories(self) -> None:
129 for path in [self.data_directory, self.map_directory]:
130 if not path.exists():
131 try:
132 path.mkdir(parents=True, exist_ok=True)
133 except Exception as e:
134 raise RuntimeError(f"Failed to create directory {path}: {e}")
137class Target(str, Enum):
138 dev = "dev"
139 prod = "prod"
140 test = "test"
142class AdminConfig(BaseModel):
143 emails: str | list[str] = Field(
144 default="",
145 description="Comma-separated list of admin email addresses"
146 )
148 @model_validator(mode="after")
149 def parse_emails(self) -> "AdminConfig":
150 if self.emails and isinstance(self.emails, str):
151 self.emails = [email.strip() for email in self.emails.split(",") if email.strip()]
152 else:
153 self.emails = []
154 return self
156 def is_admin_email(self, email: str) -> bool:
157 """Check if an email is in the admin list (case-insensitive)"""
158 return email.lower() in [admin_email.lower() for admin_email in self.emails]
161class Settings(BaseSettings):
162 model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
163 env_file=".env",
164 env_file_encoding="utf-8",
165 extra="ignore",
166 env_nested_delimiter="__",
167 )
169 target: Target = Field(default=Target.dev)
170 allowed_origins: list[str] = Field(
171 default=[
172 "http://localhost:3000",
173 "http://localhost:8000",
174 "http://localhost:8001",
175 ]
176 )
177 landing_page_url: HttpUrl = Field(default=HttpUrl("http://localhost:3000/login"))
179 fs: FileSystemConfig = Field(default_factory=FileSystemConfig)
180 google_auth: GoogleAuthConfig = Field(default_factory=GoogleAuthConfig)
181 postgres: PostgresConfig = Field(default_factory=PostgresConfig)
182 jwt: JWTConfig = Field(default_factory=JWTConfig)
183 newsletter: NewsletterConfig = Field(default_factory=NewsletterConfig)
184 smtp: SMTPConfig = Field(default_factory=SMTPConfig)
185 admin: AdminConfig = Field(default_factory=AdminConfig)
187 # Important, when using docker each will see an environment variable
188 # named HOSTNAME such as HOSTNAME=8e2b7c3e1c7a
189 services: HostnameConfig = Field(default_factory=HostnameConfig, alias="SERVICE")
191 @model_validator(mode="after")
192 def validate_allowed_origins(self) -> "Settings":
193 for origin in self.allowed_origins:
194 try:
195 _ = HttpUrl(origin)
196 except ValidationError as e:
197 raise ValueError(f"Invalid allowed_origin: {origin}") from e
198 return self
200 @model_validator(mode="after")
201 def set_cryptographic_keys(self) -> "Settings":
202 if not self.newsletter.secret_key:
203 self.newsletter.secret_key = self.jwt.secret_key
204 return self
207settings = Settings()
208settings.fs.ensure_directories()