← full-stack-fastapi-template  /  backend/app/core/config.py

1
import secrets
2
import warnings
3
from typing import Annotated, Any, Literal
4
5
from pydantic import (
6
    AnyUrl,
7
    BeforeValidator,
8
    EmailStr,
9
    HttpUrl,
10
    PostgresDsn,
11
    computed_field,
12
    model_validator,
13
)
14
from pydantic_settings import BaseSettings, SettingsConfigDict
15
from typing_extensions import Self
16
17
18
def 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(",") if i.strip()]
21
    elif isinstance(v, list | str):
22
        return v
23
    raise ValueError(v)
24
25
26
class 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"
39
40
    BACKEND_CORS_ORIGINS: Annotated[
41
        list[AnyUrl] | str, BeforeValidator(parse_cors)
42
    ] = []
43
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
        ]
50
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 = ""
58
59
    @computed_field  # type: ignore[prop-decorator]
60
    @property
61
    def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
62
        return PostgresDsn.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
        )
70
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
    EMAILS_FROM_EMAIL: EmailStr | None = None
78
    EMAILS_FROM_NAME: str | None = None
79
80
    @model_validator(mode="after")
81
    def _set_default_emails_from(self) -> Self:
82
        if not self.EMAILS_FROM_NAME:
83
            self.EMAILS_FROM_NAME = self.PROJECT_NAME
84
        return self
85
86
    EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
87
88
    @computed_field  # type: ignore[prop-decorator]
89
    @property
90
    def emails_enabled(self) -> bool:
91
        return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
92
93
    EMAIL_TEST_USER: EmailStr = "test@example.com"
94
    FIRST_SUPERUSER: EmailStr
95
    FIRST_SUPERUSER_PASSWORD: str
96
97
    def _check_default_secret(self, var_name: str, value: str | None) -> None:
98
        if value == "changethis":
99
            message = (
100
                f'The value of {var_name} is "changethis", '
101
                "for security, please change it, at least for deployments."
102
            )
103
            if self.ENVIRONMENT == "local":
104
                warnings.warn(message, stacklevel=1)
105
            else:
106
                raise ValueError(message)
107
108
    @model_validator(mode="after")
109
    def _enforce_non_default_secrets(self) -> Self:
110
        self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
111
        self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
112
        self._check_default_secret(
113
            "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
114
        )
115
116
        return self
117
118
119
settings = Settings()  # type: ignore
120