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

1import secrets 

2import warnings 

3from typing import Annotated, Any, Literal 

4 

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 

16 

17 

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) 

24 

25 

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" 

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 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 ) 

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 # TODO: update type to EmailStr when sqlmodel supports it 

78 EMAILS_FROM_EMAIL: str | None = None 

79 EMAILS_FROM_NAME: str | None = None 

80 

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 

86 

87 EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 

88 

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) 

93 

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 

99 

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) 

110 

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 ) 

118 

119 return self 

120 

121 

122settings = Settings() # type: ignore