|
1 | 1 | import secrets |
2 | | -from typing import Any |
| 2 | +from typing import Annotated, Any, Literal |
3 | 3 |
|
4 | 4 | from pydantic import ( |
5 | | - AnyHttpUrl, |
| 5 | + AnyUrl, |
| 6 | + BeforeValidator, |
6 | 7 | HttpUrl, |
7 | 8 | PostgresDsn, |
8 | | - ValidationInfo, |
9 | | - field_validator, |
| 9 | + computed_field, |
| 10 | + model_validator, |
10 | 11 | ) |
| 12 | +from pydantic_core import MultiHostUrl |
11 | 13 | from pydantic_settings import BaseSettings, SettingsConfigDict |
| 14 | +from typing_extensions import Self |
| 15 | + |
| 16 | + |
| 17 | +def parse_cors(v: Any) -> list[str] | str: |
| 18 | + if isinstance(v, str) and not v.startswith("["): |
| 19 | + return [i.strip() for i in v.split(",")] |
| 20 | + elif isinstance(v, list | str): |
| 21 | + return v |
| 22 | + raise ValueError(v) |
12 | 23 |
|
13 | 24 |
|
14 | 25 | class Settings(BaseSettings): |
| 26 | + model_config = SettingsConfigDict(env_file=".env", env_ignore_empty=True) |
15 | 27 | API_V1_STR: str = "/api/v1" |
16 | 28 | SECRET_KEY: str = secrets.token_urlsafe(32) |
17 | 29 | # 60 minutes * 24 hours * 8 days = 8 days |
18 | 30 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 |
19 | | - SERVER_HOST: AnyHttpUrl |
20 | | - BACKEND_CORS_ORIGINS: list[AnyHttpUrl] | str = [] |
21 | | - |
22 | | - @field_validator("BACKEND_CORS_ORIGINS", mode="before") |
23 | | - @classmethod |
24 | | - def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str: |
25 | | - if isinstance(v, str) and not v.startswith("["): |
26 | | - return [i.strip() for i in v.split(",")] |
27 | | - elif isinstance(v, list | str): |
28 | | - return v |
29 | | - raise ValueError(v) |
| 31 | + DOMAIN: str = "localhost" |
| 32 | + HOST_SCHEME: Literal["https", "http"] = "http" |
30 | 33 |
|
31 | | - PROJECT_NAME: str |
32 | | - SENTRY_DSN: HttpUrl | None = None |
| 34 | + @computed_field # type: ignore[misc] |
| 35 | + @property |
| 36 | + def server_host(self) -> str: |
| 37 | + return f"{self.HOST_SCHEME}://{self.DOMAIN}" |
33 | 38 |
|
34 | | - @field_validator("SENTRY_DSN", mode="before") |
35 | | - @classmethod |
36 | | - def sentry_dsn_can_be_blank(cls, v: str) -> str | None: |
37 | | - if not v: |
38 | | - return None |
39 | | - return v |
| 39 | + BACKEND_CORS_ORIGINS: Annotated[ |
| 40 | + list[AnyUrl] | str, BeforeValidator(parse_cors) |
| 41 | + ] = [] |
40 | 42 |
|
| 43 | + PROJECT_NAME: str |
| 44 | + SENTRY_DSN: HttpUrl | None = None |
41 | 45 | POSTGRES_SERVER: str |
42 | 46 | POSTGRES_USER: str |
43 | 47 | POSTGRES_PASSWORD: str |
44 | | - POSTGRES_DB: str |
45 | | - SQLALCHEMY_DATABASE_URI: PostgresDsn | None = None |
46 | | - |
47 | | - @field_validator("SQLALCHEMY_DATABASE_URI", mode="before") |
48 | | - def assemble_db_connection(cls, v: str | None, info: ValidationInfo) -> Any: |
49 | | - if isinstance(v, str): |
50 | | - return v |
51 | | - return PostgresDsn.build( |
| 48 | + POSTGRES_DB: str = "" |
| 49 | + |
| 50 | + @computed_field # type: ignore[misc] |
| 51 | + @property |
| 52 | + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: |
| 53 | + return MultiHostUrl.build( |
52 | 54 | scheme="postgresql+psycopg", |
53 | | - username=info.data.get("POSTGRES_USER"), |
54 | | - password=info.data.get("POSTGRES_PASSWORD"), |
55 | | - host=info.data.get("POSTGRES_SERVER"), |
56 | | - path=f"{info.data.get('POSTGRES_DB') or ''}", |
| 55 | + username=self.POSTGRES_USER, |
| 56 | + password=self.POSTGRES_PASSWORD, |
| 57 | + host=self.POSTGRES_SERVER, |
| 58 | + path=self.POSTGRES_DB, |
57 | 59 | ) |
58 | 60 |
|
59 | 61 | SMTP_TLS: bool = True |
60 | | - SMTP_PORT: int | None = None |
| 62 | + SMTP_PORT: int = 587 |
61 | 63 | SMTP_HOST: str | None = None |
62 | 64 | SMTP_USER: str | None = None |
63 | 65 | SMTP_PASSWORD: str | None = None |
64 | 66 | # TODO: update type to EmailStr when sqlmodel supports it |
65 | 67 | EMAILS_FROM_EMAIL: str | None = None |
66 | 68 | EMAILS_FROM_NAME: str | None = None |
67 | 69 |
|
68 | | - @field_validator("EMAILS_FROM_NAME") |
69 | | - def get_project_name(cls, v: str | None, info: ValidationInfo) -> str: |
70 | | - if not v: |
71 | | - return str(info.data["PROJECT_NAME"]) |
72 | | - return v |
| 70 | + @model_validator(mode="after") |
| 71 | + def set_default_emails_from(self) -> Self: |
| 72 | + if not self.EMAILS_FROM_NAME: |
| 73 | + self.EMAILS_FROM_NAME = self.PROJECT_NAME |
| 74 | + return self |
73 | 75 |
|
74 | 76 | EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 |
75 | 77 | EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build" |
76 | | - EMAILS_ENABLED: bool = False |
77 | | - |
78 | | - @field_validator("EMAILS_ENABLED", mode="before") |
79 | | - def get_emails_enabled(cls, v: bool, info: ValidationInfo) -> bool: |
80 | | - return bool( |
81 | | - info.data.get("SMTP_HOST") |
82 | | - and info.data.get("SMTP_PORT") |
83 | | - and info.data.get("EMAILS_FROM_EMAIL") |
84 | | - ) |
| 78 | + |
| 79 | + @computed_field # type: ignore[misc] |
| 80 | + @property |
| 81 | + def emails_enabled(self) -> bool: |
| 82 | + return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) |
85 | 83 |
|
86 | 84 | # TODO: update type to EmailStr when sqlmodel supports it |
87 | 85 | EMAIL_TEST_USER: str = "test@example.com" |
88 | 86 | # TODO: update type to EmailStr when sqlmodel supports it |
89 | 87 | FIRST_SUPERUSER: str |
90 | 88 | FIRST_SUPERUSER_PASSWORD: str |
91 | 89 | USERS_OPEN_REGISTRATION: bool = False |
92 | | - model_config = SettingsConfigDict(env_file=".env") |
93 | 90 |
|
94 | 91 |
|
95 | 92 | settings = Settings() # type: ignore |
0 commit comments