Coverage for app/core/config.py: 94%
70 statements
« prev ^ index » next coverage.py v7.6.1, created at 2026-03-24 11:40 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2026-03-24 11:40 +0000
1import secrets
2import warnings
3from typing import Annotated, Any, Literal
5from pydantic import (
6 AnyUrl,
7 BeforeValidator,
8 HttpUrl,
9 PostgresDsn,
10 computed_field,
11 model_validator,
12)
13from pydantic_core import MultiHostUrl
14from pydantic_settings import BaseSettings, SettingsConfigDict
15from typing_extensions import Self
18def parse_cors(v: Any) -> list[str] | str:
19 if isinstance(v, str) and not v.startswith("["):
20 return [i.strip() for i in v.split(",")]
21 elif isinstance(v, list | str):
22 return v
23 raise ValueError(v)
26class Settings(BaseSettings):
27 model_config = SettingsConfigDict(
28 # Use top level .env file (one level above ./backend/)
29 env_file="../.env",
30 env_ignore_empty=True,
31 extra="ignore",
32 )
33 API_V1_STR: str = "/api/v1"
34 SECRET_KEY: str = secrets.token_urlsafe(32)
35 # 60 minutes * 24 hours * 8 days = 8 days
36 ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
37 FRONTEND_HOST: str = "http://localhost:5173"
38 ENVIRONMENT: Literal["local", "staging", "production"] = "local"
40 BACKEND_CORS_ORIGINS: Annotated[
41 list[AnyUrl] | str, BeforeValidator(parse_cors)
42 ] = []
44 @computed_field # type: ignore[prop-decorator]
45 @property
46 def all_cors_origins(self) -> list[str]:
47 return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
48 self.FRONTEND_HOST
49 ]
51 PROJECT_NAME: str
52 SENTRY_DSN: HttpUrl | None = None
53 POSTGRES_SERVER: str
54 POSTGRES_PORT: int = 5432
55 POSTGRES_USER: str
56 POSTGRES_PASSWORD: str = ""
57 POSTGRES_DB: str = ""
59 @computed_field # type: ignore[prop-decorator]
60 @property
61 def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
62 return MultiHostUrl.build(
63 scheme="postgresql+psycopg",
64 username=self.POSTGRES_USER,
65 password=self.POSTGRES_PASSWORD,
66 host=self.POSTGRES_SERVER,
67 port=self.POSTGRES_PORT,
68 path=self.POSTGRES_DB,
69 )
71 SMTP_TLS: bool = True
72 SMTP_SSL: bool = False
73 SMTP_PORT: int = 587
74 SMTP_HOST: str | None = None
75 SMTP_USER: str | None = None
76 SMTP_PASSWORD: str | None = None
77 # TODO: update type to EmailStr when sqlmodel supports it
78 EMAILS_FROM_EMAIL: str | None = None
79 EMAILS_FROM_NAME: str | None = None
81 @model_validator(mode="after")
82 def _set_default_emails_from(self) -> Self:
83 if not self.EMAILS_FROM_NAME:
84 self.EMAILS_FROM_NAME = self.PROJECT_NAME
85 return self
87 EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
89 @computed_field # type: ignore[prop-decorator]
90 @property
91 def emails_enabled(self) -> bool:
92 return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
94 # TODO: update type to EmailStr when sqlmodel supports it
95 EMAIL_TEST_USER: str = "test@example.com"
96 # TODO: update type to EmailStr when sqlmodel supports it
97 FIRST_SUPERUSER: str
98 FIRST_SUPERUSER_PASSWORD: str
100 def _check_default_secret(self, var_name: str, value: str | None) -> None:
101 if value == "changethis":
102 message = (
103 f'The value of {var_name} is "changethis", '
104 "for security, please change it, at least for deployments."
105 )
106 if self.ENVIRONMENT == "local":
107 warnings.warn(message, stacklevel=1)
108 else:
109 raise ValueError(message)
111 @model_validator(mode="after")
112 def _enforce_non_default_secrets(self) -> Self:
113 self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
114 self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
115 self._check_default_secret(
116 "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
117 )
119 return self
122settings = Settings() # type: ignore