import secrets import warnings from pathlib import Path from typing import Annotated, Any, Literal from urllib.parse import quote_plus from pydantic import ( AnyUrl, BeforeValidator, EmailStr, HttpUrl, computed_field, model_validator, ) from pydantic_settings import BaseSettings, SettingsConfigDict from typing_extensions import Self def parse_cors(v: Any) -> list[str] | str: if isinstance(v, str) and not v.startswith("["): return [i.strip() for i in v.split(",") if i.strip()] elif isinstance(v, list | str): return v raise ValueError(v) class Settings(BaseSettings): model_config = SettingsConfigDict( # Use top level .env file (one level above ./backend/) env_file="../.env", env_ignore_empty=True, extra="ignore", ) API_V1_STR: str = "/api/v1" SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 8 days = 8 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 FRONTEND_HOST: str = "http://localhost:5173" ENVIRONMENT: Literal["local", "dev", "qa", "prod"] = "local" BACKEND_CORS_ORIGINS: Annotated[ list[AnyUrl] | str, BeforeValidator(parse_cors) ] = [] @computed_field # type: ignore[prop-decorator] @property def all_cors_origins(self) -> list[str]: return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ self.FRONTEND_HOST ] PROJECT_NAME: str SENTRY_DSN: HttpUrl | None = None POSTGRES_SERVER: str POSTGRES_PORT: int = 5432 POSTGRES_USER: str POSTGRES_PASSWORD: str = "" POSTGRES_DB: str = "" @computed_field # type: ignore[prop-decorator] @property def SQLALCHEMY_DATABASE_URI(self) -> str: # PostgresDsn.build() is broken in pydantic-core ≥2.41 when port is int. # Both call sites use str() anyway, so return a plain string. password = quote_plus(self.POSTGRES_PASSWORD or "") user = quote_plus(self.POSTGRES_USER) return ( f"postgresql+psycopg://{user}:{password}" f"@{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" ) SMTP_TLS: bool = True SMTP_SSL: bool = False SMTP_PORT: int = 587 SMTP_HOST: str | None = None SMTP_USER: str | None = None SMTP_PASSWORD: str | None = None EMAILS_FROM_EMAIL: EmailStr | None = None EMAILS_FROM_NAME: str | None = None @model_validator(mode="after") def _set_default_emails_from(self) -> Self: if not self.EMAILS_FROM_NAME: self.EMAILS_FROM_NAME = self.PROJECT_NAME return self EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 @computed_field # type: ignore[prop-decorator] @property def emails_enabled(self) -> bool: return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) EMAIL_TEST_USER: EmailStr = "test@example.com" FIRST_SUPERUSER: EmailStr FIRST_SUPERUSER_PASSWORD: str # Redis configuration REDIS_HOST: str = "localhost" REDIS_PORT: int = 6379 # Media storage configuration MEDIA_ROOT: Path = Path("./media") MAX_UPLOAD_SIZE: int = 2_147_483_648 # 2GB ALLOWED_VIDEO_EXTENSIONS: list[str] = [".mp4", ".mov", ".avi", ".mkv", ".webm"] # Clipper / Gemini configuration (optional for now, required for video analysis) GEMINI_API_KEY: str | None = None GEMINI_MODEL: str = "gemini-3-flash-preview" # TikTok social videos directory (relative to this file's package root, or absolute) TIKTOK_VIDEOS_DIR: Path | None = None def _check_default_secret(self, var_name: str, value: str | None) -> None: if value == "changethis": message = ( f'The value of {var_name} is "changethis", ' "for security, please change it, at least for deployments." ) if self.ENVIRONMENT == "local": warnings.warn(message, stacklevel=1) else: raise ValueError(message) @model_validator(mode="after") def _enforce_non_default_secrets(self) -> Self: self._check_default_secret("SECRET_KEY", self.SECRET_KEY) self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD) self._check_default_secret( "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD ) return self settings = Settings() # type: ignore