Single part at the root, COMPOSE_FILE in .env

This commit is contained in:
hh
2026-09-08 20:38:54 +02:00
parent 321e4b6b35
commit aac037676c
86 changed files with 68 additions and 27 deletions
@@ -0,0 +1,5 @@
{% if dynamic_config %}from .config import config
{% endif %}from .env import env
from .logging import logger
__all__ = [{% if dynamic_config %}"config", {% endif %}"env", "logger"]
@@ -0,0 +1,117 @@
{% if database != 'none' or use_redis %}import os
{% endif %}from pydantic import Field{% if database != 'none' or 'aiogram_bot' in backend_services or include_payments or use_llm or use_redis %}, SecretStr{% endif %}
from pydantic_settings import BaseSettings, SettingsConfigDict
{% if database != 'none' or use_redis %}
def is_prod() -> bool:
return os.getenv("RUN_ENVIRONMENT") == "prod"
{% endif %}
class LogSettings(BaseSettings):
level: str = "INFO"
level_external: str = "WARNING"
show_time: bool = False
console_width: int = 150
{% if database == 'mongo' %}
class DatabaseSettings(BaseSettings):
host: str = "mongodb"
port: int = 27017
user: str = "{{ project_slug }}"
password: SecretStr = SecretStr("{{ project_slug }}")
db_name: str = "{{ project_slug }}"
connection_params: str = "?authSource=admin"
@property
def connection_url(self) -> str:
host = self.host if is_prod() else "localhost"
password = self.password.get_secret_value()
return f"mongodb://{self.user}:{password}@{host}:{self.port}/{self.db_name}{self.connection_params}"
{% elif database == 'postgres' %}
class DatabaseSettings(BaseSettings):
host: str = "postgres"
port: int = 5432
user: str = "{{ project_slug }}"
password: SecretStr = SecretStr("{{ project_slug }}")
db_name: str = "{{ project_slug }}"
min_pool_size: int = 5
max_pool_size: int = 20
@property
def connection_url(self) -> str:
host = self.host if is_prod() else "localhost"
password = self.password.get_secret_value()
return f"postgresql://{self.user}:{password}@{host}:{self.port}/{self.db_name}"
@property
def async_connection_url(self) -> str:
return self.connection_url.replace("postgresql://", "postgresql+asyncpg://", 1)
{% endif %}{% if use_redis %}
class RedisSettings(BaseSettings):
host: str = "redis"
port: int = 6379
db: int = 0
password: SecretStr | None = None
cache_ttl: int = 300
@property
def url(self) -> str:
host = self.host if is_prod() else "localhost"
auth = f":{self.password.get_secret_value()}@" if self.password else ""
return f"redis://{auth}{host}:{self.port}/{self.db}"
{% endif %}{% if 'api' in backend_services %}
class ApiSettings(BaseSettings):
host: str = "0.0.0.0" # noqa: S104
port: int = 8080
workers: int = 1
{%- if serve_spa %}
static_dir: str = "static"
{%- endif %}
{% endif %}{% if 'aiogram_bot' in backend_services %}
class BotSettings(BaseSettings):
token: SecretStr = SecretStr("")
admins: list[int] = Field(default_factory=list)
{% endif %}{% if include_payments %}
class CryptoPaySettings(BaseSettings):
token: SecretStr = SecretStr("")
{% endif %}{% if use_llm %}
class LlmSettings(BaseSettings):
model: str = "google-gla:gemini-2.5-flash"
gemini_api_key: SecretStr = SecretStr("")
{% endif %}
class Settings(BaseSettings):
log: LogSettings = Field(default_factory=LogSettings)
{%- if database != 'none' %}
db: DatabaseSettings = Field(default_factory=DatabaseSettings)
{%- endif %}
{%- if use_redis %}
redis: RedisSettings = Field(default_factory=RedisSettings)
{%- endif %}
{%- if 'api' in backend_services %}
api: ApiSettings = Field(default_factory=ApiSettings)
{%- endif %}
{%- if 'aiogram_bot' in backend_services %}
bot: BotSettings = Field(default_factory=BotSettings)
{%- endif %}
{%- if include_payments %}
crypto_pay: CryptoPaySettings = Field(default_factory=CryptoPaySettings)
{%- endif %}
{%- if use_llm %}
llm: LlmSettings = Field(default_factory=LlmSettings)
{%- endif %}
model_config = SettingsConfigDict(
case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore"
)
env = Settings()
@@ -0,0 +1,38 @@
import logging
from rich.console import Console
from rich.logging import RichHandler
from rich.traceback import install
from .env import env
console = Console(width=env.log.console_width, color_system="auto", force_terminal=True)
def setup_logging() -> None:
{% if 'aiogram_bot' in backend_services %} from aiogram.dispatcher import router # noqa: PLC0415
{% endif %} logging.basicConfig(
level=env.log.level_external,
format="",
datefmt=None,
handlers=[
RichHandler(
console=console,
markup=True,
rich_tracebacks=True,
enable_link_path=False,
tracebacks_show_locals=True,
omit_repeated_times=False,
show_time=env.log.show_time,
{%- if 'aiogram_bot' in backend_services %}
tracebacks_suppress=[router],
{%- endif %}
)
],
)
install(console=console, show_locals=True)
logger = logging.getLogger("{{ project_slug }}")
logger.setLevel(env.log.level)
@@ -0,0 +1,19 @@
{% if database == 'postgres' -%}
async def init_db() -> None:
from . import models # noqa: F401, PLC0415
{%- else -%}
from beanie import init_beanie
from pymongo import AsyncMongoClient
from utils.env import env
client = AsyncMongoClient(env.db.connection_url)
async def init_db() -> None:
from .models import {% if dynamic_config %}DynamicConfig, {% endif %}User # noqa: PLC0415
{% if dynamic_config %} await init_beanie(
database=client[env.db.db_name], document_models=[DynamicConfig, User]
){% else %} await init_beanie(database=client[env.db.db_name], document_models=[User]){% endif %}
{%- endif %}
@@ -0,0 +1,4 @@
{% if dynamic_config %}from .config import BotConfig, DynamicConfig, DynamicConfigBase
{% endif %}from .user import User
__all__ = [{% if dynamic_config %}"BotConfig", "DynamicConfig", "DynamicConfigBase", {% endif %}"User"]
@@ -0,0 +1,54 @@
{% if database == 'postgres' -%}
from datetime import datetime
from sqlalchemy import BigInteger, Column
from sqlmodel import Field as SQLField
from sqlmodel import SQLModel
from .base import created_at_col
class User(SQLModel, table=True):
__tablename__ = "user_account"
id: int | None = SQLField(default=None, primary_key=True)
tg_id: int = SQLField(sa_column=Column(BigInteger, unique=True, index=True))
username: str | None = None
first_name: str | None = None
last_name: str | None = None
created_at: datetime | None = created_at_col()
{%- else -%}
from beanie import Document
class User(Document):
id: int
balance: float = 0
class Settings:
name = "users"
@classmethod
async def get_by_id(cls, id_: int) -> "User | None":
return await cls.find_one(cls.id == id_)
@classmethod
async def get_or_create(cls, id_: int) -> "User":
user = await cls.get_by_id(id_)
if user is None:
user = cls(id=id_)
await user.insert()
return user
async def add_balance(self, amount: float) -> None:
self.balance += amount
await self.save()
async def subtract_balance(self, amount: float) -> bool:
if self.balance >= amount:
self.balance -= amount
await self.save()
return True
return False
{%- endif %}
@@ -0,0 +1,60 @@
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, func, text
from sqlalchemy.dialects import postgresql
from sqlmodel import Field as SQLField
def uuid_pk() -> uuid.UUID:
return SQLField(
default_factory=uuid.uuid4,
sa_column=Column(postgresql.UUID(as_uuid=True), primary_key=True),
)
def uuid_fk(target: str, *, nullable: bool = True) -> Column:
return Column(
postgresql.UUID(as_uuid=True),
ForeignKey(target, ondelete="SET NULL"),
nullable=nullable,
)
def bigint_col(*, nullable: bool = True) -> Column:
return Column(BigInteger, nullable=nullable)
def nullable_ts_col() -> datetime | None:
return SQLField(
default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
)
def created_at_col() -> datetime:
return SQLField(
default=None,
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
),
)
def updated_at_col() -> datetime:
return SQLField(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
),
)
def jsonb_column(*, nullable: bool = False, default: str = "[]") -> Column:
return Column(
postgresql.JSONB,
nullable=nullable,
server_default=None if nullable else text(f"'{default}'::jsonb"),
)
@@ -0,0 +1,58 @@
{% if database == 'postgres' -%}
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
from sqlalchemy import Column, DateTime, func
from sqlalchemy.dialects import postgresql
from sqlmodel import Field as SQLField
from sqlmodel import SQLModel
class BotConfig(BaseModel):
admins: list[int] = Field(default_factory=list)
class DynamicConfigBase(BaseModel):
bot: BotConfig = Field(default_factory=BotConfig)
class DynamicConfig(SQLModel, table=True):
__tablename__ = "config"
id: int = SQLField(default=1, primary_key=True)
data: dict[str, Any] = SQLField(sa_column=Column(postgresql.JSONB, nullable=False))
updated_at: datetime | None = SQLField(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
),
)
{%- else -%}
from beanie import Document
from pydantic import BaseModel, Field
class BotConfig(BaseModel):
admins: list[int] = Field(default_factory=list)
class DynamicConfigBase(BaseModel):
bot: BotConfig = Field(default_factory=BotConfig)
class DynamicConfig(DynamicConfigBase, Document):
class Settings:
name = "config"
@classmethod
async def get_or_create(cls) -> "DynamicConfig":
config = await cls.find_one()
if config is None:
config = cls()
await config.save()
return config
{%- endif %}
@@ -0,0 +1,5 @@
{% if use_redis %}from .cache import RedisCache
{% endif %}{% if dynamic_config %}from .config import ConfigRepository
{% endif %}from .user import UserRepository
__all__ = [{% if dynamic_config %}"ConfigRepository", {% endif %}{% if use_redis %}"RedisCache", {% endif %}"UserRepository"]
@@ -0,0 +1,35 @@
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from utils.db.models import User
class UserRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get(self, user_id: int) -> User | None:
return await self._session.get(User, user_id)
async def by_tg_id(self, tg_id: int) -> User | None:
stmt = select(User).where(User.tg_id == tg_id)
return (await self._session.exec(stmt)).first()
async def upsert(
self,
tg_id: int,
*,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
) -> User:
user = await self.by_tg_id(tg_id)
if user is None:
user = User(tg_id=tg_id)
user.username = username
user.first_name = first_name
user.last_name = last_name
self._session.add(user)
await self._session.commit()
await self._session.refresh(user)
return user
@@ -0,0 +1,88 @@
{% if use_redis -%}
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from utils.db.models.config import DynamicConfig, DynamicConfigBase
from .cache import RedisCache
CONFIG_CACHE_KEY = "config:dynamic"
CONFIG_ROW_ID = 1
class ConfigRepository:
def __init__(
self, sessionmaker: async_sessionmaker[AsyncSession], cache: RedisCache
) -> None:
self._sessionmaker = sessionmaker
self._cache = cache
async def get(self) -> DynamicConfigBase:
return await self._cache.get_or_set_model(
CONFIG_CACHE_KEY, DynamicConfigBase, self._load
)
async def _load(self) -> DynamicConfigBase:
async with self._sessionmaker() as session:
row = await session.get(DynamicConfig, CONFIG_ROW_ID)
if row is None:
config = DynamicConfigBase()
session.add(DynamicConfig(id=CONFIG_ROW_ID, data=config.model_dump()))
await session.commit()
return config
return DynamicConfigBase.model_validate(row.data)
async def save(self, config: DynamicConfigBase) -> DynamicConfigBase:
async with self._sessionmaker() as session:
row = await session.get(DynamicConfig, CONFIG_ROW_ID)
if row is None:
session.add(DynamicConfig(id=CONFIG_ROW_ID, data=config.model_dump()))
else:
row.data = config.model_dump()
session.add(row)
await session.commit()
await self._cache.set_model(CONFIG_CACHE_KEY, config)
return config
async def reset(self) -> DynamicConfigBase:
return await self.save(DynamicConfigBase())
async def invalidate(self) -> None:
await self._cache.invalidate(CONFIG_CACHE_KEY)
{%- else -%}
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from utils.db.models.config import DynamicConfig, DynamicConfigBase
CONFIG_ROW_ID = 1
class ConfigRepository:
def __init__(self, sessionmaker: async_sessionmaker[AsyncSession]) -> None:
self._sessionmaker = sessionmaker
async def get(self) -> DynamicConfigBase:
async with self._sessionmaker() as session:
row = await session.get(DynamicConfig, CONFIG_ROW_ID)
if row is None:
config = DynamicConfigBase()
session.add(DynamicConfig(id=CONFIG_ROW_ID, data=config.model_dump()))
await session.commit()
return config
return DynamicConfigBase.model_validate(row.data)
async def save(self, config: DynamicConfigBase) -> DynamicConfigBase:
async with self._sessionmaker() as session:
row = await session.get(DynamicConfig, CONFIG_ROW_ID)
if row is None:
session.add(DynamicConfig(id=CONFIG_ROW_ID, data=config.model_dump()))
else:
row.data = config.model_dump()
session.add(row)
await session.commit()
return config
async def reset(self) -> DynamicConfigBase:
return await self.save(DynamicConfigBase())
{%- endif %}
@@ -0,0 +1,52 @@
from collections.abc import Awaitable, Callable
from typing import TypeVar
from pydantic import BaseModel
from redis.asyncio import Redis
ModelT = TypeVar("ModelT", bound=BaseModel)
NAMESPACE = "{{ project_slug }}"
class RedisCache:
def __init__(
self, redis: Redis, *, namespace: str = NAMESPACE, ttl: int = 300
) -> None:
self._redis = redis
self._namespace = namespace
self._ttl = ttl
def _key(self, key: str) -> str:
return f"{self._namespace}:{key}"
async def get_model(self, key: str, model: type[ModelT]) -> ModelT | None:
raw = await self._redis.get(self._key(key))
if raw is None:
return None
return model.model_validate_json(raw)
async def set_model(
self, key: str, value: BaseModel, *, ttl: int | None = None
) -> None:
await self._redis.set(
self._key(key), value.model_dump_json(), ex=ttl or self._ttl
)
async def get_or_set_model(
self,
key: str,
model: type[ModelT],
loader: Callable[[], Awaitable[ModelT]],
*,
ttl: int | None = None,
) -> ModelT:
cached = await self.get_model(key, model)
if cached is not None:
return cached
value = await loader()
await self.set_model(key, value, ttl=ttl)
return value
async def invalidate(self, *keys: str) -> None:
if keys:
await self._redis.delete(*(self._key(key) for key in keys))
@@ -0,0 +1,15 @@
{% if database == 'postgres' -%}
from utils.db.models.config import DynamicConfigBase
from utils.db.repositories import ConfigRepository
async def config() -> DynamicConfigBase:
from dependencies.container import container # noqa: PLC0415
repo = await container.get(ConfigRepository)
return await repo.get()
{%- else -%}
from utils.db.models.config import DynamicConfig
config = DynamicConfig.get_or_create
{%- endif %}