Initial template

This commit is contained in:
hh
2026-09-08 19:17:12 +02:00
commit c0724dc291
87 changed files with 2583 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.venv
.env
__pycache__
*.pyc
.ruff_cache
.pytest_cache
sessions
+26
View File
@@ -0,0 +1,26 @@
RUN_ENVIRONMENT=dev
LOG__LEVEL=INFO
LOG__LEVEL_EXTERNAL=WARNING
LOG__SHOW_TIME=false
LOG__CONSOLE_WIDTH=150
{% if database == 'mongo' %}DB__HOST=mongodb
DB__PORT=27017
DB__USER={{ project_slug }}
DB__PASSWORD={{ project_slug }}
DB__DB_NAME={{ project_slug }}
{% endif %}{% if database == 'postgres' %}DB__HOST=postgres
DB__PORT=5432
DB__USER={{ project_slug }}
DB__PASSWORD={{ project_slug }}
DB__DB_NAME={{ project_slug }}
{% endif %}{% if use_redis %}REDIS__HOST=redis
REDIS__PORT=6379
REDIS__CACHE_TTL=300
{% endif %}{% if 'api' in backend_services %}API__HOST=0.0.0.0
API__PORT=8080
API__WORKERS=1
{% endif %}{% if 'aiogram_bot' in backend_services %}BOT__TOKEN=
{% endif %}{% if include_payments %}CRYPTO_PAY__TOKEN=
{% endif %}{% if use_llm %}LLM__MODEL=google-gla:gemini-2.5-flash
LLM__GEMINI_API_KEY=
{% endif %}
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
.ruff_cache/
.pytest_cache/
sessions/*.session*
+1
View File
@@ -0,0 +1 @@
3.13
+7
View File
@@ -0,0 +1,7 @@
## Checking commands
After writing code, always run from `backend/`:
```shell
ruff format
ruff check --fix
ty check
```
+40
View File
@@ -0,0 +1,40 @@
{%- set svc_modules = [] -%}
{%- if 'api' in backend_services %}{% set _ = svc_modules.append("api") %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = svc_modules.append("bot") %}{% endif -%}
{%- if 'taskiq_worker' in backend_services %}{% set _ = svc_modules.append("worker") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = svc_modules.append("userbot") %}{% endif -%}
{%- if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}{% set _ = svc_modules.append("scheduler") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services -%}
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
{%- else -%}
FROM ghcr.io/astral-sh/uv:python3.13-alpine
{%- endif %}
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH" \
RUN_ENVIRONMENT=prod \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
COPY src ./src
RUN uv sync --frozen --no-dev
{%- if database == 'postgres' %}
COPY alembic.ini ./
COPY migrations ./migrations
{%- endif %}
{%- if dynamic_config or 'kurigram_userbot' in backend_services %}
COPY scripts ./scripts
{%- endif %}
ENTRYPOINT ["python", "-m"]
{%- if svc_modules %}
CMD ["{{ svc_modules[0] }}"]
{%- endif %}
+38
View File
@@ -0,0 +1,38 @@
{%- set svc_modules = [] -%}
{%- if 'api' in backend_services %}{% set _ = svc_modules.append("api") %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = svc_modules.append("bot") %}{% endif -%}
{%- if 'taskiq_worker' in backend_services %}{% set _ = svc_modules.append("worker") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = svc_modules.append("userbot") %}{% endif -%}
{%- if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}{% set _ = svc_modules.append("scheduler") %}{% endif -%}
{%- set has_script = dynamic_config and svc_modules -%}
COMPOSE := docker compose --project-directory ..
.PHONY: fmt check build{% if database == 'postgres' %} migrate revision{% endif %}{% if has_script %} script{% endif %}{% if 'kurigram_userbot' in backend_services %} session{% endif %}
fmt:
uv run ruff format
uv run ruff check --fix
check:
uv run ruff format --check
uv run ruff check
uv run ty check
build:
$(COMPOSE) build {{ svc_modules[0] }}
{% if database == 'postgres' %}
migrate:
$(COMPOSE) --profile external run --rm migrator
revision:
$(COMPOSE) --profile external run --rm migrator revision --autogenerate -m "$(m)"
{% endif %}{% if has_script %}
script:
@$(COMPOSE) run --rm {{ svc_modules[0] }} scripts.$(word 2,$(MAKECMDGOALS)) $(wordlist 3,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
%:
@:
{% endif %}{% if 'kurigram_userbot' in backend_services %}
session:
uv run python scripts/session/create.py
{% endif %}
@@ -0,0 +1,15 @@
{%- set has_ports = 'api' in backend_services or database != 'none' or use_redis -%}
services:{% if not has_ports %} {}{% endif %}
{% if 'api' in backend_services %} api:
ports:
- "127.0.0.1:${API__PORT:-8080}:8080"
{% endif %}{% if database == 'mongo' %} mongodb:
ports:
- "127.0.0.1:${DB__PORT:-27017}:${DB__PORT:-27017}"
{% endif %}{% if database == 'postgres' %} postgres:
ports:
- "127.0.0.1:${DB__PORT:-5432}:5432"
{% endif %}{% if use_redis %} redis:
ports:
- "127.0.0.1:${REDIS__PORT:-6379}:6379"
{% endif %}
@@ -0,0 +1,15 @@
{%- set dash = project_slug | replace('_', '-') -%}
{% if 'api' in backend_services -%}
services:
api:
networks:
caddy:
aliases:
- {{ dash }}-api
networks:
caddy:
external: true
{%- else -%}
services: {}
{%- endif %}
+124
View File
@@ -0,0 +1,124 @@
{%- set has_scripts = dynamic_config or 'kurigram_userbot' in backend_services -%}
{%- set svc_networks = [] -%}
{%- if database != 'none' %}{% set _ = svc_networks.append('database') %}{% endif -%}
{%- if use_redis %}{% set _ = svc_networks.append('redis') %}{% endif -%}
{%- set app_services = [] -%}
{%- if 'api' in backend_services %}{% set _ = app_services.append({'name': 'api', 'command': '[api]'}) %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = app_services.append({'name': 'bot', 'command': '[bot]'}) %}{% endif -%}
{%- if 'taskiq_worker' in backend_services %}{% set _ = app_services.append({'name': 'worker', 'command': '[worker]'}) %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = app_services.append({'name': 'userbot', 'command': '[userbot]'}) %}{% endif -%}
{%- if 'scheduler' in backend_services %}{% set _ = app_services.append({'name': 'scheduler', 'command': ('[worker, scheduler]' if 'taskiq_worker' in backend_services else '[scheduler]')}) %}{% endif -%}
{%- macro backend_service(name, command) %} {{ name }}:
build: backend
image: {{ project_slug }}/backend
profiles: [{{ name }}, services]
restart: unless-stopped
env_file:
- path: .env
required: false
- path: backend/.env
required: false
environment:
RUN_ENVIRONMENT: prod
volumes:
- ./backend/src:/app/src
{% if has_scripts %} - ./backend/scripts:/app/scripts
{% endif %}{% if name == 'userbot' %} - ./backend/sessions:/app/sessions
{% endif %}{% if name == 'api' and serve_spa %} - ./frontend/build:/app/static:ro
{% endif %} command: {{ command }}
{% if svc_networks %} networks:
{% for net in svc_networks %} {{ net }}:
{% endfor %}{% endif %}{% endmacro -%}
services:
{% for svc in app_services %}{{ backend_service(svc.name, svc.command) }}{% if not loop.last %}
{% endif %}{% endfor %}
{%- if database == 'mongo' %}
mongodb:
image: mongo:8
profiles: [mongodb, external]
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${DB__USER}
MONGO_INITDB_ROOT_PASSWORD: ${DB__PASSWORD}
command: mongod --port ${DB__PORT:-27017}
volumes:
- database:/data/db
networks:
database:
aliases:
- ${DB__HOST:-mongodb}
{% endif %}
{%- if database == 'postgres' %}
postgres:
image: postgres:17-alpine
profiles: [postgres, external]
restart: unless-stopped
environment:
POSTGRES_USER: ${DB__USER}
POSTGRES_PASSWORD: ${DB__PASSWORD}
POSTGRES_DB: ${DB__DB_NAME}
volumes:
- database:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB__USER}"]
interval: 5s
timeout: 5s
retries: 5
networks:
database:
aliases:
- ${DB__HOST:-postgres}
migrator:
build: backend
image: {{ project_slug }}/backend
profiles: [migrate]
env_file:
- path: .env
required: false
- path: backend/.env
required: false
environment:
RUN_ENVIRONMENT: prod
volumes:
- ./backend/src:/app/src
- ./backend/migrations:/app/migrations
- ./backend/alembic.ini:/app/alembic.ini
entrypoint: [alembic]
command: [upgrade, head]
depends_on:
postgres:
condition: service_healthy
networks:
database:
{% endif %}
{%- if use_redis %}
redis:
image: redis:7-alpine
profiles: [redis, external]
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
networks:
redis:
aliases:
- ${REDIS__HOST:-redis}
{% endif %}
{%- if database != 'none' or use_redis %}
volumes:
{%- if database != 'none' %}
database:
{%- endif %}
{%- if use_redis %}
redis_data:
{%- endif %}
networks:
{%- if database != 'none' %}
database:
{%- endif %}
{%- if use_redis %}
redis:
{%- endif %}
{%- endif %}
+72
View File
@@ -0,0 +1,72 @@
{%- set deps = ["pydantic-settings>=2.14.2", "rich>=15.0.0"] -%}
{%- if use_dishka %}{% set _ = deps.append("dishka>=1.10.1") %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = deps.append("fastapi>=0.138.0") %}{% set _ = deps.append("uvicorn[standard]>=0.49.0") %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = deps.append("aiogram>=3.29.0") %}{% endif -%}
{%- if include_payments %}{% set _ = deps.append("aiosend>=3.0.7") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = deps.append("anyio>=4.0.0") %}{% set _ = deps.append("kurigram>=2.2.7") %}{% set _ = deps.append("tgcrypto>=1.2.5") %}{% set _ = deps.append("uvloop>=0.21.0") %}{% endif -%}
{%- if 'taskiq_worker' in backend_services %}{% set _ = deps.append("taskiq>=0.12.4") %}{% if taskiq_broker == 'redis' %}{% set _ = deps.append("taskiq-redis>=1.2.3") %}{% endif %}{% endif -%}
{%- if 'scheduler' in backend_services %}{% set _ = deps.append("apscheduler>=3.11.0") %}{% endif -%}
{%- if database == 'mongo' %}{% set _ = deps.append("beanie>=2.0.1") %}{% endif -%}
{%- if database == 'postgres' %}{% set _ = deps.append("sqlmodel>=0.0.38") %}{% set _ = deps.append("asyncpg>=0.31.0") %}{% set _ = deps.append("alembic>=1.18.4") %}{% set _ = deps.append("greenlet>=3.1.0") %}{% endif -%}
{%- if use_redis %}{% set _ = deps.append("redis>=5.2.0") %}{% endif -%}
{%- if use_llm %}{% set _ = deps.append("pydantic-ai-slim[google]>=2.1.0") %}{% endif -%}
{%- set modules = [] -%}
{%- if 'api' in backend_services %}{% set _ = modules.append("api") %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = modules.append("bot") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = modules.append("userbot") %}{% endif -%}
{%- if 'taskiq_worker' in backend_services %}{% set _ = modules.append("worker") %}{% endif -%}
{%- if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}{% set _ = modules.append("scheduler") %}{% endif -%}
{%- set _ = modules.append("utils") -%}
{%- if use_dishka %}{% set _ = modules.append("dependencies") %}{% endif -%}
[project]
name = "{{ project_slug }}"
version = "0.1.0"
description = "{{ project_description }}"{%- if author_name %}
authors = [
{ name = "{{ author_name }}"{% if author_email %}, email = "{{ author_email }}"{% endif %} }
]
{%- endif %}
requires-python = ">=3.13"
dependencies = [
{%- for dep in deps | sort %}
"{{ dep }}",
{%- endfor %}
]
[build-system]
requires = ["uv_build>=0.11.14,<0.12.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = [{% for module in modules %}"{{ module }}"{% if not loop.last %}, {% endif %}{% endfor %}]
[tool.ruff]
target-version = "py313"
[tool.ruff.lint]
select = ["ALL"]
ignore = ["CPY", "D1", "D203", "D212", "COM812", "BLE", "S101", "PT", "RUF001", "RUF002", "RUF003", "TRY301"]
unfixable = ["F401"]
[tool.ruff.lint.per-file-ignores]
"scripts/*" = ["INP", "T201", "ANN401"]
"*.lock" = ["ALL"]
{%- if database == 'postgres' %}
"migrations/**" = ["ALL"]
{%- endif %}
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.format]
docstring-code-format = true
skip-magic-trailing-comma = true
[tool.ruff.lint.isort]
split-on-trailing-comma = false
[dependency-groups]
dev = [
"ruff>=0.15.20",
"ty>=0.0.55",
]
@@ -0,0 +1,21 @@
import asyncio
import anyio
from userbot import PyroClient
async def main() -> None:
sessions_dir = anyio.Path("sessions")
await sessions_dir.mkdir(parents=True, exist_ok=True)
client = PyroClient("generated", workdir=".", load_handlers=False)
await client.start()
user_id = client.me.id if client.me else "unknown"
await client.stop()
await anyio.Path("generated.session").rename(sessions_dir / f"{user_id}.session")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,462 @@
{% if database == 'postgres' -%}
import argparse
import asyncio
import json
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from rich.console import Console
from rich.json import JSON
from rich.panel import Panel
from rich.table import Table
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from collections.abc import AsyncGenerator
from pydantic import ValidationError
from dependencies.container import container
from utils.db.models.config import DynamicConfigBase
from utils.db.repositories import ConfigRepository
console = Console()
_MISSING = object()
@asynccontextmanager
async def get_repo() -> AsyncGenerator[ConfigRepository]:
repo = await container.get(ConfigRepository)
try:
yield repo
finally:
await container.close()
def parse_value(raw: str) -> Any:
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
lowered = raw.lower()
if lowered in {"true", "false"}:
return lowered == "true"
if lowered in {"null", "none"}:
return None
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
return raw
def dig(data: dict[str, Any], path: str) -> Any:
current: Any = data
for key in path.split("."):
if not isinstance(current, dict) or key not in current:
return _MISSING
current = current[key]
return current
def set_path(data: dict[str, Any], path: str, value: Any) -> None:
keys = path.split(".")
current = data
for key in keys[:-1]:
nxt = current.get(key)
if not isinstance(nxt, dict):
nxt = {}
current[key] = nxt
current = nxt
current[keys[-1]] = value
def unset_path(data: dict[str, Any], path: str) -> bool:
keys = path.split(".")
current = data
for key in keys[:-1]:
nxt = current.get(key)
if not isinstance(nxt, dict):
return False
current = nxt
return current.pop(keys[-1], _MISSING) is not _MISSING
def validate(data: dict[str, Any]) -> DynamicConfigBase:
try:
return DynamicConfigBase.model_validate(data)
except ValidationError as exc:
console.print("[bold red]✗ Invalid config[/]")
for error in exc.errors():
loc = ".".join(str(part) for part in error["loc"])
console.print(f" [red]{loc or '<root>'}[/]: {error['msg']}")
raise SystemExit(1) from exc
def render(config: DynamicConfigBase) -> None:
console.print(
Panel(
JSON(config.model_dump_json(indent=2)),
title="[bold cyan]DynamicConfig[/]",
border_style="cyan",
expand=False,
)
)
async def cmd_show() -> None:
async with get_repo() as repo:
render(await repo.get())
async def cmd_get(path: str) -> None:
async with get_repo() as repo:
config = await repo.get()
value = dig(config.model_dump(), path)
if value is _MISSING:
console.print(f"[red]✗[/] no such field: [bold]{path}[/]")
raise SystemExit(1)
table = Table(show_header=False, box=None)
table.add_column(style="cyan")
table.add_column(style="white")
table.add_row(path, json.dumps(value, ensure_ascii=False))
console.print(table)
async def cmd_set(path: str, raw: str) -> None:
async with get_repo() as repo:
data = (await repo.get()).model_dump()
value = parse_value(raw)
set_path(data, path, value)
config = await repo.save(validate(data))
shown = json.dumps(value, ensure_ascii=False)
console.print(f"[green]✓[/] set [cyan]{path}[/] = {shown}")
render(config)
async def cmd_unset(path: str) -> None:
async with get_repo() as repo:
data = (await repo.get()).model_dump()
if not unset_path(data, path):
console.print(f"[red]✗[/] no such field: [bold]{path}[/]")
raise SystemExit(1)
config = await repo.save(validate(data))
console.print(f"[green]✓[/] unset [cyan]{path}[/] (reset to default)")
render(config)
async def _mutate_list(path: str, raw: str, *, add: bool) -> None:
async with get_repo() as repo:
data = (await repo.get()).model_dump()
current = dig(data, path)
items = list(current) if isinstance(current, list) else []
value = parse_value(raw)
if add:
if value not in items:
items.append(value)
else:
items = [item for item in items if item != value]
set_path(data, path, items)
config = await repo.save(validate(data))
verb = "added to" if add else "removed from"
shown = json.dumps(value, ensure_ascii=False)
console.print(f"[green]✓[/] {shown} {verb} [cyan]{path}[/]")
render(config)
async def cmd_reset() -> None:
async with get_repo() as repo:
config = await repo.reset()
console.print("[yellow]↺[/] config reset to defaults")
render(config)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Manage DynamicConfig in Postgres")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("show", help="Show the current config")
get_p = sub.add_parser("get", help="Show a single field")
get_p.add_argument("path", help="Dotted field path, e.g. bot.admins")
set_p = sub.add_parser("set", help="Set a field (value is smart-parsed)")
set_p.add_argument("path", help="Dotted field path, e.g. bot.admins")
set_p.add_argument("value", help="New value (JSON or scalar)")
unset_p = sub.add_parser("unset", help="Remove a field (falls back to default)")
unset_p.add_argument("path")
add_p = sub.add_parser("add", help="Append an item to a list field")
add_p.add_argument("path", help="Dotted path to a list, e.g. bot.admins")
add_p.add_argument("value")
rm_p = sub.add_parser("remove", help="Remove an item from a list field")
rm_p.add_argument("path", help="Dotted path to a list, e.g. bot.admins")
rm_p.add_argument("value")
sub.add_parser("reset", help="Reset the whole config to defaults")
return parser
async def run(args: argparse.Namespace) -> None:
match args.command:
case "show":
await cmd_show()
case "get":
await cmd_get(args.path)
case "set":
await cmd_set(args.path, args.value)
case "unset":
await cmd_unset(args.path)
case "add":
await _mutate_list(args.path, args.value, add=True)
case "remove":
await _mutate_list(args.path, args.value, add=False)
case "reset":
await cmd_reset()
def main() -> None:
args = build_parser().parse_args()
asyncio.run(run(args))
if __name__ == "__main__":
main()
{%- else -%}
import argparse
import asyncio
import json
import sys
from pathlib import Path
from typing import Any
from rich.console import Console
from rich.json import JSON
from rich.panel import Panel
from rich.table import Table
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from pydantic import ValidationError
from utils.db import client, init_db
from utils.db.models.config import DynamicConfig, DynamicConfigBase
console = Console()
_MISSING = object()
async def read_config() -> DynamicConfigBase:
await init_db()
doc = await DynamicConfig.get_or_create()
return DynamicConfigBase.model_validate(doc, from_attributes=True)
async def write_config(config: DynamicConfigBase) -> DynamicConfigBase:
await init_db()
doc = await DynamicConfig.get_or_create()
for name in DynamicConfigBase.model_fields:
setattr(doc, name, getattr(config, name))
await doc.save()
return config
def parse_value(raw: str) -> Any:
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
lowered = raw.lower()
if lowered in {"true", "false"}:
return lowered == "true"
if lowered in {"null", "none"}:
return None
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
return raw
def dig(data: dict[str, Any], path: str) -> Any:
current: Any = data
for key in path.split("."):
if not isinstance(current, dict) or key not in current:
return _MISSING
current = current[key]
return current
def set_path(data: dict[str, Any], path: str, value: Any) -> None:
keys = path.split(".")
current = data
for key in keys[:-1]:
nxt = current.get(key)
if not isinstance(nxt, dict):
nxt = {}
current[key] = nxt
current = nxt
current[keys[-1]] = value
def unset_path(data: dict[str, Any], path: str) -> bool:
keys = path.split(".")
current = data
for key in keys[:-1]:
nxt = current.get(key)
if not isinstance(nxt, dict):
return False
current = nxt
return current.pop(keys[-1], _MISSING) is not _MISSING
def validate(data: dict[str, Any]) -> DynamicConfigBase:
try:
return DynamicConfigBase.model_validate(data)
except ValidationError as exc:
console.print("[bold red]✗ Invalid config[/]")
for error in exc.errors():
loc = ".".join(str(part) for part in error["loc"])
console.print(f" [red]{loc or '<root>'}[/]: {error['msg']}")
raise SystemExit(1) from exc
def render(config: DynamicConfigBase) -> None:
console.print(
Panel(
JSON(config.model_dump_json(indent=2)),
title="[bold cyan]DynamicConfig[/]",
border_style="cyan",
expand=False,
)
)
async def cmd_show() -> None:
render(await read_config())
async def cmd_get(path: str) -> None:
config = await read_config()
value = dig(config.model_dump(), path)
if value is _MISSING:
console.print(f"[red]✗[/] no such field: [bold]{path}[/]")
raise SystemExit(1)
table = Table(show_header=False, box=None)
table.add_column(style="cyan")
table.add_column(style="white")
table.add_row(path, json.dumps(value, ensure_ascii=False))
console.print(table)
async def cmd_set(path: str, raw: str) -> None:
data = (await read_config()).model_dump()
value = parse_value(raw)
set_path(data, path, value)
config = await write_config(validate(data))
shown = json.dumps(value, ensure_ascii=False)
console.print(f"[green]✓[/] set [cyan]{path}[/] = {shown}")
render(config)
async def cmd_unset(path: str) -> None:
data = (await read_config()).model_dump()
if not unset_path(data, path):
console.print(f"[red]✗[/] no such field: [bold]{path}[/]")
raise SystemExit(1)
config = await write_config(validate(data))
console.print(f"[green]✓[/] unset [cyan]{path}[/] (reset to default)")
render(config)
async def _mutate_list(path: str, raw: str, *, add: bool) -> None:
data = (await read_config()).model_dump()
current = dig(data, path)
items = list(current) if isinstance(current, list) else []
value = parse_value(raw)
if add:
if value not in items:
items.append(value)
else:
items = [item for item in items if item != value]
set_path(data, path, items)
config = await write_config(validate(data))
verb = "added to" if add else "removed from"
shown = json.dumps(value, ensure_ascii=False)
console.print(f"[green]✓[/] {shown} {verb} [cyan]{path}[/]")
render(config)
async def cmd_reset() -> None:
config = await write_config(DynamicConfigBase())
console.print("[yellow]↺[/] config reset to defaults")
render(config)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Manage DynamicConfig in MongoDB")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("show", help="Show the current config")
get_p = sub.add_parser("get", help="Show a single field")
get_p.add_argument("path", help="Dotted field path, e.g. bot.admins")
set_p = sub.add_parser("set", help="Set a field (value is smart-parsed)")
set_p.add_argument("path", help="Dotted field path, e.g. bot.admins")
set_p.add_argument("value", help="New value (JSON or scalar)")
unset_p = sub.add_parser("unset", help="Remove a field (falls back to default)")
unset_p.add_argument("path")
add_p = sub.add_parser("add", help="Append an item to a list field")
add_p.add_argument("path", help="Dotted path to a list, e.g. bot.admins")
add_p.add_argument("value")
rm_p = sub.add_parser("remove", help="Remove an item from a list field")
rm_p.add_argument("path", help="Dotted path to a list, e.g. bot.admins")
rm_p.add_argument("value")
sub.add_parser("reset", help="Reset the whole config to defaults")
return parser
async def run(args: argparse.Namespace) -> None:
try:
match args.command:
case "show":
await cmd_show()
case "get":
await cmd_get(args.path)
case "set":
await cmd_set(args.path, args.value)
case "unset":
await cmd_unset(args.path)
case "add":
await _mutate_list(args.path, args.value, add=True)
case "remove":
await _mutate_list(args.path, args.value, add=False)
case "reset":
await cmd_reset()
finally:
await client.close()
def main() -> None:
args = build_parser().parse_args()
asyncio.run(run(args))
if __name__ == "__main__":
main()
{%- endif %}
@@ -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"]
+117
View File
@@ -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 %}
@@ -0,0 +1,49 @@
{%- set third = [] -%}
{%- set first = [] -%}
{%- if use_dishka %}{% set _ = third.append(' from dishka.integrations.aiogram import setup_dishka # noqa: PLC0415') %}{% set _ = first.append(' from dependencies.container import container # noqa: PLC0415') %}{% endif -%}
{%- if database != 'none' %}{% set _ = first.append(' from utils.db import init_db # noqa: PLC0415') %}{% endif -%}
{%- set groups = [] -%}
{%- if third %}{% set _ = groups.append(third | join('\n')) %}{% endif -%}
{%- if first %}{% set _ = groups.append(first | join('\n')) %}{% endif -%}
{%- set _ = groups.append(' from . import handlers # noqa: PLC0415\n from .common import bot, cp, dp # noqa: PLC0415') -%}
import asyncio
import contextlib
from utils.logging import logger, setup_logging
setup_logging()
async def runner() -> None:
{{ groups | join('\n\n') }}
{%- if database != 'none' %}
await init_db()
{%- endif %}
dp.include_routers(handlers.router)
{%- if use_dishka %}
setup_dishka(container, dp, auto_inject=True)
{%- endif %}
await bot.delete_webhook(drop_pending_updates=True)
tasks = [dp.start_polling(bot)]
if cp is not None:
tasks.append(cp.start_polling())
{% if use_dishka %}
try:
await asyncio.gather(*tasks)
finally:
await container.close()
{% else %}
await asyncio.gather(*tasks)
{% endif %}
def main() -> None:
logger.info("Starting...")
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Stopped.[/]")
@@ -0,0 +1,4 @@
from . import main
if __name__ == "__main__":
main()
@@ -0,0 +1,19 @@
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
{% if use_redis %}from aiogram.fsm.storage.redis import RedisStorage
{% else %}from aiogram.fsm.storage.memory import MemoryStorage
{% endif %}{% if include_payments %}from aiosend import CryptoPay
{% endif %}
from utils.env import env
bot = Bot(
token=env.bot.token.get_secret_value(),
default=DefaultBotProperties(parse_mode="HTML"),
)
storage = {% if use_redis %}RedisStorage.from_url(env.redis.url){% else %}MemoryStorage(){% endif %}
dp = Dispatcher(storage=storage)
cp = {% if include_payments %}CryptoPay(env.crypto_pay.token.get_secret_value()){% else %}None{% endif %}
__all__ = ["bot", "cp", "dp"]
@@ -0,0 +1,3 @@
from .admin import Admin
__all__ = ["Admin"]
@@ -0,0 +1,12 @@
from aiogram.filters import BaseFilter
from aiogram.types import Message
{% if dynamic_config %}from utils.config import config
{% else %}from utils.env import env
{% endif %}
class Admin(BaseFilter):
async def __call__(self, message: Message) -> bool:
if not message.from_user:
return False
return message.from_user.id in {% if dynamic_config %}(await config()).bot.admins{% else %}env.bot.admins{% endif %}
@@ -0,0 +1,7 @@
from aiogram import Router
from . import {% if include_payments %}deposit, {% endif %}initialize, start{% if include_payments %}, withdraw{% endif %}
router = Router()
{% if include_payments %}router.include_routers(initialize.router, start.router, deposit.router, withdraw.router){% else %}router.include_routers(initialize.router, start.router){% endif %}
@@ -0,0 +1,3 @@
from .initializer import router
__all__ = ["router"]
@@ -0,0 +1,17 @@
from aiogram import Bot, Router, types
from rich import print # noqa: A004
router = Router()
@router.startup()
async def startup(bot: Bot) -> None:
await bot.set_my_commands(
[types.BotCommand(command="start", description="Start the bot")]
)
print(f"[green]Started as[/] @{(await bot.me()).username}")
@router.shutdown()
async def shutdown() -> None:
print("Shutting down bot...")
@@ -0,0 +1,3 @@
from .start import router
__all__ = ["router"]
@@ -0,0 +1,11 @@
from aiogram import Router, types
from aiogram.filters import CommandStart
from bot.keyboards import main_menu_kb
router = Router()
@router.message(CommandStart())
async def on_start(message: types.Message) -> None:
await message.answer("hello", reply_markup=main_menu_kb())
@@ -0,0 +1,90 @@
{% if database != 'mongo' %}from collections import defaultdict
{% endif %}from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiosend.types import Invoice
from bot.common import bot, cp
from bot.keyboards import deposit_amounts_kb, pay_invoice_kb
{% if database == 'mongo' %}from utils.db.models import User
{% endif %}
router = Router()
{% if database != 'mongo' %}balances: defaultdict[int, float] = defaultdict(float)
{% endif %}
class DepositStates(StatesGroup):
waiting_custom_amount = State()
@router.message(F.text == "Deposit")
async def on_deposit_menu(message: types.Message, state: FSMContext) -> None:
await state.clear()
await message.answer("Choose an amount:", reply_markup=deposit_amounts_kb())
@router.callback_query(F.data.startswith("deposit:"))
async def on_deposit_amount(callback: types.CallbackQuery, state: FSMContext) -> None:
if not callback.data or not callback.message or not callback.from_user:
return
amount_str = callback.data.split(":")[1]
if amount_str == "custom":
await state.set_state(DepositStates.waiting_custom_amount)
if isinstance(callback.message, types.Message):
await callback.message.edit_text("Enter an amount in USD (e.g. 2.5):")
await callback.answer()
return
await create_and_send_invoice(callback.from_user.id, float(amount_str))
await callback.answer()
@router.message(DepositStates.waiting_custom_amount)
async def on_custom_amount(message: types.Message, state: FSMContext) -> None:
if not message.text or not message.from_user:
return
try:
amount = float(message.text.replace(",", "."))
except ValueError:
await message.answer("Invalid format. Enter a number (e.g. 2.5):")
return
if amount <= 0:
await message.answer("Amount must be greater than 0.")
return
await state.clear()
await create_and_send_invoice(message.from_user.id, amount)
async def create_and_send_invoice(user_id: int, amount: float) -> None:
invoice = await cp.create_invoice(amount, "USDT")
invoice.poll(user_id=user_id)
await bot.send_message(
user_id,
f"💳 <b>Invoice for ${amount:.2f}</b>\n\nTap below to pay.",
reply_markup=pay_invoice_kb(invoice.bot_invoice_url),
)
@cp.invoice_paid()
async def handle_payment(invoice: Invoice, user_id: int) -> None:
{%- if database == 'mongo' %}
user = await User.get_or_create(user_id)
await user.add_balance(float(invoice.amount))
new_balance = user.balance
{%- else %}
balances[user_id] += float(invoice.amount)
new_balance = balances[user_id]
{%- endif %}
await bot.send_message(
user_id,
f"✅ <b>Payment received!</b>\n\n"
f"Amount: ${float(invoice.amount):.2f}\n"
f"New balance: ${new_balance:.2f}",
)
@@ -0,0 +1,87 @@
import uuid
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from bot.common import cp
{% if database == 'mongo' %}from utils.db.models import User
{% else %}from bot.handlers.deposit.deposit import balances
{% endif %}
router = Router()
MIN_WITHDRAW = 1.2
WITHDRAW_FEE = 0.10
class WithdrawStates(StatesGroup):
waiting_amount = State()
@router.message(F.text == "Withdraw")
async def on_withdraw_menu(message: types.Message, state: FSMContext) -> None:
if not message.from_user:
return
await state.clear()
await state.set_state(WithdrawStates.waiting_amount)
await message.answer(
f"💸 <b>Withdraw</b>\n\n"
f"Minimum: ${MIN_WITHDRAW:.2f}\n"
f"Fee: {int(WITHDRAW_FEE * 100)}%\n\n"
f"Enter an amount in USDT:"
)
@router.message(WithdrawStates.waiting_amount)
async def on_withdraw_amount(message: types.Message, state: FSMContext) -> None:
if not message.text or not message.from_user:
return
try:
amount = float(message.text.replace(",", "."))
except ValueError:
await message.answer("Invalid format. Enter a number (e.g. 1.5):")
return
if amount < MIN_WITHDRAW:
await message.answer(f"Minimum withdrawal: ${MIN_WITHDRAW:.2f}")
return
user_id = message.from_user.id
{%- if database == 'mongo' %}
user = await User.get_by_id(user_id)
if not user or not await user.subtract_balance(amount):
await message.answer("Insufficient funds.")
return
{%- else %}
if balances[user_id] < amount:
await message.answer("Insufficient funds.")
return
balances[user_id] -= amount
{%- endif %}
await state.clear()
fee = amount * WITHDRAW_FEE
payout = amount - fee
try:
await cp.transfer(
user_id=user_id, asset="USDT", amount=payout, spend_id=str(uuid.uuid4())
)
except Exception as e:
{%- if database == 'mongo' %}
await user.add_balance(amount)
{%- else %}
balances[user_id] += amount
{%- endif %}
await message.answer(f"❌ Withdrawal failed. Funds returned.\n<code>{e}</code>")
return
await message.answer(
f"✅ <b>Withdrawn!</b>\n\n"
f"Amount: ${amount:.2f}\n"
f"Fee: ${fee:.2f}\n"
f"Paid out: ${payout:.2f} USDT"
)
@@ -0,0 +1,33 @@
{% if include_payments %}from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
)
{% else %}from aiogram.types import KeyboardButton, ReplyKeyboardMarkup
{% endif %}
def main_menu_kb() -> ReplyKeyboardMarkup:
{% if include_payments %} buttons = [[KeyboardButton(text="Deposit"), KeyboardButton(text="Withdraw")]]
{% else %} buttons = [[KeyboardButton(text="Menu")]]
{% endif %} return ReplyKeyboardMarkup(keyboard=buttons, resize_keyboard=True)
{% if include_payments %}
def deposit_amounts_kb() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="$1", callback_data="deposit:1"),
InlineKeyboardButton(text="$5", callback_data="deposit:5"),
InlineKeyboardButton(text="$10", callback_data="deposit:10"),
],
[InlineKeyboardButton(text="Custom", callback_data="deposit:custom")],
]
)
def pay_invoice_kb(url: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text="💳 Pay", url=url)]]
)
{% endif %}
@@ -0,0 +1,13 @@
import uvicorn
from utils.env import env
def main() -> None:
uvicorn.run(
"api.app:app", host=env.api.host, port=env.api.port, workers=env.api.workers
)
if __name__ == "__main__":
main()
@@ -0,0 +1,53 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
{% if serve_spa %}from pathlib import Path
{% endif %}
{% if use_dishka %}from dishka.integrations.fastapi import setup_dishka
{% endif %}from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
{% if serve_spa %}from fastapi.responses import FileResponse
{% endif %}
from api import routers
{% if use_dishka %}from dependencies.container import container
{% endif %}{% if database != 'none' %}from utils.db import init_db
{% endif %}{% if serve_spa %}from utils.env import env
{% endif %}from utils.logging import setup_logging
{% if serve_spa %}
IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
NO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
{% endif %}
@asynccontextmanager
async def lifespan(app_: FastAPI) -> AsyncGenerator[None]:
setup_logging()
{%- if database != 'none' %}
await init_db()
{%- endif %}
yield
{%- if use_dishka %}
await app_.state.dishka_container.close()
{%- endif %}
app = FastAPI(title="{{ project_name }} API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
app.include_router(routers.router)
{% if serve_spa %}
spa_dir = Path(env.api.static_dir).resolve()
if spa_dir.is_dir():
@app.get("/{spa_path:path}")
async def serve_spa(spa_path: str) -> FileResponse:
candidate = (spa_dir / spa_path).resolve()
if spa_path and candidate.is_relative_to(spa_dir) and candidate.is_file():
immutable = spa_path.startswith("_app/immutable/")
headers = IMMUTABLE_HEADERS if immutable else NO_CACHE_HEADERS
return FileResponse(candidate, headers=headers)
return FileResponse(spa_dir / "index.html", headers=NO_CACHE_HEADERS)
{% endif %}
{% if use_dishka %}setup_dishka(container, app)
{% endif %}
@@ -0,0 +1,6 @@
from fastapi import APIRouter
from . import api
router = APIRouter()
router.include_router(api.router, prefix="/api")
@@ -0,0 +1,6 @@
from fastapi import APIRouter
from . import health
router = APIRouter()
router.include_router(health.router, prefix="/health")
@@ -0,0 +1,38 @@
{% if use_dishka and database == 'postgres' -%}
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter
from sqlalchemy import text
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(tags=["health"], route_class=DishkaRoute)
@router.get("")
@router.get("/")
async def health(session: FromDishka[AsyncSession]) -> dict[str, bool]:
result = await session.scalars(text("SELECT 1"))
return {"db": result.first() == 1}
{% elif use_dishka and database == 'mongo' -%}
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter
from pymongo import AsyncMongoClient
router = APIRouter(tags=["health"], route_class=DishkaRoute)
@router.get("")
@router.get("/")
async def health(client: FromDishka[AsyncMongoClient]) -> dict[str, bool]:
await client.admin.command("ping")
return {"db": True}
{% else -%}
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("")
@router.get("/")
async def health() -> dict[str, bool]:
return {"ok": True}
{% endif -%}
@@ -0,0 +1,3 @@
from .modules.client import PyroClient
__all__ = ["PyroClient"]
@@ -0,0 +1,4 @@
from .runner import main
if __name__ == "__main__":
main()
@@ -0,0 +1,3 @@
from . import echo, private
handlers = echo.handlers + private.handlers
@@ -0,0 +1,12 @@
from pyrogram import filters, types
from userbot import PyroClient
from utils.logging import logger
@PyroClient.on_message(filters.me)
async def echo(_: PyroClient, message: types.Message) -> None:
logger.info(f"[cyan]me:[/] {message.text}")
handlers = echo.handlers
@@ -0,0 +1,3 @@
from . import pm_message
handlers = pm_message.handlers
@@ -0,0 +1,13 @@
from pyrogram import filters, types
from userbot import PyroClient
from utils.logging import logger
@PyroClient.on_message(filters.private & ~filters.me)
async def pm_message(_: PyroClient, message: types.Message) -> None:
sender = message.from_user.id if message.from_user else "unknown"
logger.info(f"[green]pm from {sender}:[/] {message.text}")
handlers = pm_message.handlers
@@ -0,0 +1,27 @@
from pyrogram import Client, enums
class PyroClient(Client):
def __init__(
self, name: str, *, workdir: str = "sessions", load_handlers: bool = True
) -> None:
super().__init__(
name,
workdir=workdir,
api_id=2040,
api_hash="b18441a1ff607e10a989891a5462e627",
device_model="Desktop",
system_version="Windows 11 x64",
app_version="6.2.4 x64",
lang_pack="tdesktop",
client_platform=enums.ClientPlatform.DESKTOP,
)
if load_handlers:
from userbot import handlers # noqa: PLC0415
for handler in handlers.handlers:
self.add_handler(*handler)
__all__ = ["PyroClient"]
@@ -0,0 +1,62 @@
import asyncio
import contextlib
import anyio
import uvloop
{% if use_dishka %}from dependencies.container import container
{% endif %}from userbot.modules.client import PyroClient
{% if database != 'none' %}from utils.db import init_db
{% endif %}from utils.logging import logger, setup_logging
setup_logging()
async def runner() -> None:
{% if database != 'none' %} await init_db()
{% endif %} sessions_dir = anyio.Path("sessions")
await sessions_dir.mkdir(parents=True, exist_ok=True)
started: set[str] = set()
clients: dict[int, PyroClient] = {}
try:
while True:
current = {path.name async for path in sessions_dir.glob("*.session")}
for session_file in current - started:
started.add(session_file)
name = session_file.removesuffix(".session")
try:
client = PyroClient(name)
await client.start()
except Exception as e:
logger.warning(f"[red]Client {name} failed to start: {e}[/]")
continue
me = client.me
logger.info(
f"[green]Client started as[/] "
f"{me.full_name if me else 'unknown'} ({me.id if me else '?'})"
)
if me:
clients[me.id] = client
await asyncio.sleep(10)
finally:
for client in clients.values():
with contextlib.suppress(Exception):
await client.stop()
{% if use_dishka %} await container.close()
{% endif %}
def main() -> None:
uvloop.install()
logger.info("Starting...")
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Stopped.[/]")
@@ -0,0 +1,40 @@
import asyncio
import contextlib
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
{% if database != 'none' %}from utils.db import init_db
{% endif %}from utils.logging import logger, setup_logging
scheduler = AsyncIOScheduler()
async def example_job() -> None:
logger.info("example job ran")
async def runner() -> None:
{%- if database != 'none' %}
await init_db()
{%- endif %}
scheduler.add_job(
example_job,
IntervalTrigger(seconds=60),
id="example_job",
replace_existing=True,
)
scheduler.start()
logger.info("Scheduler started")
with contextlib.suppress(asyncio.CancelledError):
await asyncio.Event().wait()
def main() -> None:
setup_logging()
logger.info("Starting...")
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Stopped.[/]")
@@ -0,0 +1,4 @@
from . import main
if __name__ == "__main__":
main()
@@ -0,0 +1,23 @@
import os
{% if 'scheduler' in backend_services %}import sys
{% endif %}
BROKER = "worker.broker:broker"
{% if 'scheduler' in backend_services %}SCHEDULER = "worker.broker:scheduler"
{% endif %}TASKS = "worker.tasks"
def main() -> None:
{%- if 'scheduler' in backend_services %}
mode = sys.argv[1] if len(sys.argv) > 1 else "worker"
if mode == "scheduler":
argv = ["taskiq", "scheduler", SCHEDULER, TASKS]
else:
argv = ["taskiq", "worker", BROKER, TASKS]
{%- else %}
argv = ["taskiq", "worker", BROKER, TASKS]
{%- endif %}
os.execvp("taskiq", argv) # noqa: S606, S607
if __name__ == "__main__":
main()
@@ -0,0 +1,54 @@
{% if taskiq_broker == 'redis' -%}
from collections.abc import AsyncGenerator
{% if use_dishka %}from dishka.integrations.taskiq import setup_dishka
{% endif %}from redis.asyncio import Redis
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
{% if 'scheduler' in backend_services %}from taskiq import TaskiqScheduler
from taskiq.schedule_sources import LabelScheduleSource
{% endif %}from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend
{% if use_dishka %}from dependencies.container import container
{% endif %}from utils.env import env
from utils.logging import logger
BRPOP_TIMEOUT = 2
class ResilientListQueueBroker(ListQueueBroker):
async def listen(self) -> AsyncGenerator[bytes]:
while True:
try:
async with Redis(connection_pool=self.connection_pool) as conn:
result = await conn.brpop(self.queue_name, timeout=BRPOP_TIMEOUT)
if result is None:
continue
value = result[1]
yield value if isinstance(value, bytes) else value.encode()
except RedisTimeoutError:
continue
except (RedisConnectionError, OSError) as exc:
logger.warning("redis listen error: %s", exc)
broker = ResilientListQueueBroker(env.redis.url).with_result_backend(
RedisAsyncResultBackend(env.redis.url)
)
{% if 'scheduler' in backend_services %}scheduler = TaskiqScheduler(broker, sources=[LabelScheduleSource(broker)])
{% endif %}{% if use_dishka %}
setup_dishka(container, broker)
{% endif %}
{%- else -%}
{% if use_dishka %}from dishka.integrations.taskiq import setup_dishka
{% endif %}from taskiq import InMemoryBroker{% if 'scheduler' in backend_services %}, TaskiqScheduler
from taskiq.schedule_sources import LabelScheduleSource{% endif %}
{% if use_dishka %}from dependencies.container import container
{% endif %}
broker = InMemoryBroker()
{% if 'scheduler' in backend_services %}scheduler = TaskiqScheduler(broker, sources=[LabelScheduleSource(broker)])
{% endif %}{% if use_dishka %}
setup_dishka(container, broker)
{% endif %}
{%- endif %}
@@ -0,0 +1,3 @@
from . import example
__all__ = ["example"]
@@ -0,0 +1,8 @@
from utils.logging import logger
from worker.broker import broker
@broker.task{% if 'scheduler' in backend_services %}(schedule=[{"cron": "*/5 * * * *"}]){% endif %}
async def example() -> str:
logger.info("example task ran")
return "ok"
@@ -0,0 +1,29 @@
{%- set providers = [] -%}
{%- if database != 'none' %}{% set _ = providers.append('DbProvider()') %}{% endif -%}
{%- if dynamic_config %}{% set _ = providers.append('ConfigProvider()') %}{% endif -%}
{%- if database == 'postgres' %}{% set _ = providers.append('RepositoryProvider()') %}{% endif -%}
{%- if use_redis %}{% set _ = providers.append('RedisProvider()') %}{% endif -%}
{%- if use_llm %}{% set _ = providers.append('LlmProvider()') %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = providers.append('AiogramProvider()') %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = providers.append('FastapiProvider()') %}{% endif -%}
{%- set third = ['from dishka import make_async_container'] -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = third.append('from dishka.integrations.aiogram import AiogramProvider') %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = third.append('from dishka.integrations.fastapi import FastapiProvider') %}{% endif -%}
{%- set first = [] -%}
{%- if dynamic_config %}{% set _ = first.append('from dependencies.providers.config import ConfigProvider') %}{% endif -%}
{%- if database != 'none' %}{% set _ = first.append('from dependencies.providers.db import DbProvider') %}{% endif -%}
{%- if use_llm %}{% set _ = first.append('from dependencies.providers.llm import LlmProvider') %}{% endif -%}
{%- if use_redis %}{% set _ = first.append('from dependencies.providers.redis import RedisProvider') %}{% endif -%}
{%- if database == 'postgres' %}{% set _ = first.append('from dependencies.providers.repositories import RepositoryProvider') %}{% endif -%}
{%- set imports = (third | join('\n')) ~ (('\n\n' ~ (first | join('\n'))) if first else '') -%}
{%- set args = providers | join(', ') -%}
{%- set oneline = 'container = make_async_container(' ~ args ~ ')' -%}
{%- set hugged = 'container = make_async_container(\n ' ~ args ~ '\n)' -%}
{%- set indented = [] -%}
{%- for p in providers %}{% set _ = indented.append(' ' ~ p ~ ',') %}{% endfor -%}
{%- set exploded = 'container = make_async_container(\n' ~ (indented | join('\n')) ~ '\n)' -%}
{#- ruff-format order: one line, else all args on one continuation line, else one per line -#}
{%- set body = oneline if (oneline | length) <= 88 else (hugged if (args | length) + 4 <= 88 else exploded) -%}
{{ imports }}
{{ body }}
@@ -0,0 +1,3 @@
from . import asyncio
__all__ = ["asyncio"]
@@ -0,0 +1,25 @@
from collections.abc import Callable
from dishka import FromDishka, Scope
from dishka.integrations.base import wrap_injection
from dependencies.container import container
def inject[**P, T](
_func: Callable[P, T] | None = None, *, scope: Scope | None = None
) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]:
def decorator(func: Callable[P, T]) -> Callable[P, T]:
return wrap_injection(
func=func,
is_async=True,
container_getter=lambda _args, _kwargs: container,
scope=scope,
)
if _func is None:
return decorator
return decorator(_func)
__all__ = ["FromDishka", "Scope", "container", "inject"]
@@ -0,0 +1,52 @@
{% if database == 'postgres' -%}
from collections.abc import AsyncGenerator
from dishka import Provider, Scope, provide
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession
from utils.env import env
class DbProvider(Provider):
@provide(scope=Scope.APP)
async def get_engine(self) -> AsyncGenerator[AsyncEngine]:
engine = create_async_engine(
env.db.async_connection_url,
pool_size=env.db.min_pool_size,
max_overflow=env.db.max_pool_size - env.db.min_pool_size,
pool_pre_ping=True,
)
try:
yield engine
finally:
await engine.dispose()
@provide(scope=Scope.APP)
def get_sessionmaker(self, engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@provide(scope=Scope.REQUEST)
async def get_session(
self, maker: async_sessionmaker[AsyncSession]
) -> AsyncGenerator[AsyncSession]:
async with maker() as session:
yield session
{%- else -%}
from collections.abc import AsyncGenerator
from dishka import Provider, Scope, provide
from pymongo import AsyncMongoClient
from utils.db import client, init_db
class DbProvider(Provider):
@provide(scope=Scope.APP)
async def get_client(self) -> AsyncGenerator[AsyncMongoClient]:
await init_db()
try:
yield client
finally:
await client.close()
{%- endif %}
@@ -0,0 +1,9 @@
from dishka import Provider, Scope, provide
from utils.db.repositories import UserRepository
class RepositoryProvider(Provider):
scope = Scope.REQUEST
user = provide(UserRepository)
@@ -0,0 +1,21 @@
{% if database == 'postgres' -%}
from dishka import Provider, Scope, provide
from utils.db.repositories import ConfigRepository
class ConfigProvider(Provider):
config_repository = provide(ConfigRepository, scope=Scope.APP)
{%- else -%}
from collections.abc import AsyncGenerator
from dishka import Provider, Scope, provide
from utils.db.models import DynamicConfig
class ConfigProvider(Provider):
@provide(scope=Scope.REQUEST)
async def provide_config(self) -> AsyncGenerator[DynamicConfig]:
yield await DynamicConfig.get_or_create()
{%- endif %}
@@ -0,0 +1,19 @@
from dishka import Provider, Scope, provide
from pydantic_ai import Agent
from pydantic_ai.models.google import GoogleModel, GoogleModelSettings
from pydantic_ai.providers.google import GoogleProvider
from utils.env import env
class LlmProvider(Provider):
@provide(scope=Scope.APP)
def agent(self) -> Agent:
model = GoogleModel(
env.llm.model.split(":", 1)[-1],
provider=GoogleProvider(api_key=env.llm.gemini_api_key.get_secret_value()),
settings=GoogleModelSettings(
temperature=0.0, google_thinking_config={"thinking_budget": 0}
),
)
return Agent(model)
@@ -0,0 +1,93 @@
{% if database == 'postgres' -%}
from collections.abc import AsyncGenerator
from dishka import Provider, Scope, provide
from redis.asyncio import Redis
from utils.db.repositories import RedisCache
from utils.env import env
class RedisProvider(Provider):
@provide(scope=Scope.APP)
async def get_redis(self) -> AsyncGenerator[Redis]:
client = Redis.from_url(env.redis.url, decode_responses=True)
try:
yield client
finally:
await client.aclose()
@provide(scope=Scope.APP)
def get_cache(self, redis: Redis) -> RedisCache:
return RedisCache(redis, ttl=env.redis.cache_ttl)
{%- else -%}
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import TypeVar
from dishka import Provider, Scope, provide
from pydantic import BaseModel
from redis.asyncio import Redis
from utils.env import env
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))
class RedisProvider(Provider):
@provide(scope=Scope.APP)
async def get_redis(self) -> AsyncGenerator[Redis]:
client = Redis.from_url(env.redis.url, decode_responses=True)
try:
yield client
finally:
await client.aclose()
@provide(scope=Scope.APP)
def get_cache(self, redis: Redis) -> RedisCache:
return RedisCache(redis, ttl=env.redis.cache_ttl)
{%- endif %}
@@ -0,0 +1,39 @@
[alembic]
script_location = migrations
prepend_sys_path = src
path_separator = os
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
@@ -0,0 +1,46 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import Connection
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel
from utils.db import models # noqa: F401
from utils.env import env
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
context.configure(
url=env.db.connection_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
engine = create_async_engine(env.db.async_connection_url)
async with engine.connect() as connection:
await connection.run_sync(do_run_migrations)
await engine.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+5
View File
@@ -0,0 +1,5 @@
[environment]
python = "backend/.venv"
[src]
exclude = ["backend/migrations"]
@@ -0,0 +1 @@
{{ _copier_answers|to_nice_yaml -}}