commit c0724dc29156676977ac502a4056d7c473077443 Author: h Date: Tue Sep 8 19:15:15 2026 +0200 Initial template diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4befed3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe367da --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# backend-python + +Copier template for a `backend/` part: a uv project with one Docker image and one `python -m ` entrypoint per service (`api`, `bot`, `userbot`, `worker`, `scheduler`), pydantic-settings, rich logging, optional Dishka, MongoDB or PostgreSQL, Redis, taskiq, CryptoPay, pydantic-ai and a DynamicConfig stored in the database. + +Ships `backend/docker-compose.yml` with root-relative paths, `docker-compose.local.yml` (published ports) and `docker-compose.prod.yml` (the `caddy` network), a `Makefile` with `fmt`, `check`, `build` and service-specific targets, `.env.example` and `CLAUDE.md`. Meant to be applied on top of the `infra` template. + +```sh +copier copy --trust -d project_name="My Thing" +copier update -a .copier-answers.backend.yml +``` diff --git a/copier.yml b/copier.yml new file mode 100644 index 0000000..85d55f7 --- /dev/null +++ b/copier.yml @@ -0,0 +1,98 @@ +_subdirectory: template +_templates_suffix: .jinja +_answers_file: .copier-answers.backend.yml +_envops: + keep_trailing_newline: true + +project_name: + type: str + help: Project name + +project_slug: + type: str + help: Package name, snake_case + default: "{{ project_name | lower | replace(' ', '_') | replace('-', '_') | replace('.', '_') }}" + +project_description: + type: str + default: "{{ project_name }}" + +author_name: + type: str + default: "" + +author_email: + type: str + default: "" + +backend_services: + type: str + multiselect: true + help: Services, each runs as python -m from one image + choices: + FastAPI api: api + aiogram bot: aiogram_bot + kurigram userbot: kurigram_userbot + taskiq worker: taskiq_worker + APScheduler scheduler: scheduler + default: + - aiogram_bot + +database: + type: str + choices: + none: none + MongoDB (Beanie): mongo + PostgreSQL (SQLModel + Alembic): postgres + default: mongo + +use_dishka: + type: bool + when: "{{ database != 'postgres' }}" + help: Dishka dependency injection + default: true + +use_redis: + type: bool + help: Redis for cache, FSM storage and the taskiq broker + default: false + +taskiq_broker: + type: str + when: "{{ 'taskiq_worker' in backend_services }}" + choices: + in-memory: memory + Redis: redis + default: memory + +include_payments: + type: bool + when: "{{ 'aiogram_bot' in backend_services }}" + help: CryptoPay (aiosend) deposit and withdraw handlers + default: false + +use_llm: + type: bool + help: pydantic-ai agent with Gemini + default: false + +dynamic_config: + type: bool + when: "{{ database != 'none' }}" + help: DynamicConfig stored in the database with scripts/config/edit.py + default: "{{ database != 'none' }}" + +serve_spa: + type: bool + when: "{{ 'api' in backend_services }}" + help: Serve the frontend build from the api as a SPA + default: false + +install_deps: + type: bool + help: uv sync after generation + default: true + +_tasks: + - "{% if _copier_operation == 'copy' and install_deps %}sh -c 'cd backend && uv sync'{% else %}true{% endif %}" + - "{% if install_deps %}sh -c 'cd backend && uv run ruff format -q'{% else %}true{% endif %}" diff --git a/template/backend/.dockerignore b/template/backend/.dockerignore new file mode 100644 index 0000000..fb3ff32 --- /dev/null +++ b/template/backend/.dockerignore @@ -0,0 +1,7 @@ +.venv +.env +__pycache__ +*.pyc +.ruff_cache +.pytest_cache +sessions diff --git a/template/backend/.env.example.jinja b/template/backend/.env.example.jinja new file mode 100644 index 0000000..ffb8c88 --- /dev/null +++ b/template/backend/.env.example.jinja @@ -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 %} diff --git a/template/backend/.gitignore b/template/backend/.gitignore new file mode 100644 index 0000000..d9df05a --- /dev/null +++ b/template/backend/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.ruff_cache/ +.pytest_cache/ +sessions/*.session* diff --git a/template/backend/.python-version b/template/backend/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/template/backend/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/template/backend/CLAUDE.md b/template/backend/CLAUDE.md new file mode 100644 index 0000000..2cabb83 --- /dev/null +++ b/template/backend/CLAUDE.md @@ -0,0 +1,7 @@ +## Checking commands +After writing code, always run from `backend/`: +```shell +ruff format +ruff check --fix +ty check +``` diff --git a/template/backend/Dockerfile.jinja b/template/backend/Dockerfile.jinja new file mode 100644 index 0000000..112ca3d --- /dev/null +++ b/template/backend/Dockerfile.jinja @@ -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 %} diff --git a/template/backend/Makefile.jinja b/template/backend/Makefile.jinja new file mode 100644 index 0000000..bc818dd --- /dev/null +++ b/template/backend/Makefile.jinja @@ -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 %} diff --git a/template/backend/docker-compose.local.yml.jinja b/template/backend/docker-compose.local.yml.jinja new file mode 100644 index 0000000..546a2c9 --- /dev/null +++ b/template/backend/docker-compose.local.yml.jinja @@ -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 %} diff --git a/template/backend/docker-compose.prod.yml.jinja b/template/backend/docker-compose.prod.yml.jinja new file mode 100644 index 0000000..458e46d --- /dev/null +++ b/template/backend/docker-compose.prod.yml.jinja @@ -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 %} diff --git a/template/backend/docker-compose.yml.jinja b/template/backend/docker-compose.yml.jinja new file mode 100644 index 0000000..b685a73 --- /dev/null +++ b/template/backend/docker-compose.yml.jinja @@ -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 %} diff --git a/template/backend/pyproject.toml.jinja b/template/backend/pyproject.toml.jinja new file mode 100644 index 0000000..731df7c --- /dev/null +++ b/template/backend/pyproject.toml.jinja @@ -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", +] diff --git a/template/backend/scripts/{% if 'kurigram_userbot' in backend_services %}session{% endif %}/create.py.jinja b/template/backend/scripts/{% if 'kurigram_userbot' in backend_services %}session{% endif %}/create.py.jinja new file mode 100644 index 0000000..802cea1 --- /dev/null +++ b/template/backend/scripts/{% if 'kurigram_userbot' in backend_services %}session{% endif %}/create.py.jinja @@ -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()) diff --git a/template/backend/scripts/{% if dynamic_config %}config{% endif %}/edit.py.jinja b/template/backend/scripts/{% if dynamic_config %}config{% endif %}/edit.py.jinja new file mode 100644 index 0000000..66161ee --- /dev/null +++ b/template/backend/scripts/{% if dynamic_config %}config{% endif %}/edit.py.jinja @@ -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 ''}[/]: {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 ''}[/]: {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 %} diff --git a/template/backend/src/utils/__init__.py.jinja b/template/backend/src/utils/__init__.py.jinja new file mode 100644 index 0000000..31fbc80 --- /dev/null +++ b/template/backend/src/utils/__init__.py.jinja @@ -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"] diff --git a/template/backend/src/utils/env.py.jinja b/template/backend/src/utils/env.py.jinja new file mode 100644 index 0000000..6dc2095 --- /dev/null +++ b/template/backend/src/utils/env.py.jinja @@ -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() diff --git a/template/backend/src/utils/logging.py.jinja b/template/backend/src/utils/logging.py.jinja new file mode 100644 index 0000000..06e0f4d --- /dev/null +++ b/template/backend/src/utils/logging.py.jinja @@ -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) diff --git a/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/__init__.py.jinja b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..f6fdb5f --- /dev/null +++ b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/__init__.py.jinja @@ -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 %} diff --git a/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/__init__.py.jinja b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/__init__.py.jinja new file mode 100644 index 0000000..080ee6e --- /dev/null +++ b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/__init__.py.jinja @@ -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"] diff --git a/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/user.py.jinja b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/user.py.jinja new file mode 100644 index 0000000..a5ebec0 --- /dev/null +++ b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/user.py.jinja @@ -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 %} diff --git a/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/{% if database == 'postgres' %}base.py{% endif %}.jinja b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/{% if database == 'postgres' %}base.py{% endif %}.jinja new file mode 100644 index 0000000..d01aea1 --- /dev/null +++ b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/{% if database == 'postgres' %}base.py{% endif %}.jinja @@ -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"), + ) diff --git a/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/{% if dynamic_config %}config.py{% endif %}.jinja b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/{% if dynamic_config %}config.py{% endif %}.jinja new file mode 100644 index 0000000..2622dbb --- /dev/null +++ b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/models/{% if dynamic_config %}config.py{% endif %}.jinja @@ -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 %} diff --git a/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/__init__.py.jinja b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..77657af --- /dev/null +++ b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/__init__.py.jinja @@ -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"] diff --git a/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/user.py.jinja b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/user.py.jinja new file mode 100644 index 0000000..cce53ca --- /dev/null +++ b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/user.py.jinja @@ -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 diff --git a/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/{% if dynamic_config %}config.py{% endif %}.jinja b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/{% if dynamic_config %}config.py{% endif %}.jinja new file mode 100644 index 0000000..71a0c43 --- /dev/null +++ b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/{% if dynamic_config %}config.py{% endif %}.jinja @@ -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 %} diff --git a/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/{% if use_redis %}cache.py{% endif %}.jinja b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/{% if use_redis %}cache.py{% endif %}.jinja new file mode 100644 index 0000000..f86fa8f --- /dev/null +++ b/template/backend/src/utils/{% if database != 'none' %}db{% endif %}/{% if database == 'postgres' %}repositories{% endif %}/{% if use_redis %}cache.py{% endif %}.jinja @@ -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)) diff --git a/template/backend/src/utils/{% if dynamic_config %}config.py{% endif %}.jinja b/template/backend/src/utils/{% if dynamic_config %}config.py{% endif %}.jinja new file mode 100644 index 0000000..c1dc15a --- /dev/null +++ b/template/backend/src/utils/{% if dynamic_config %}config.py{% endif %}.jinja @@ -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 %} diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/__init__.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..ea3fa0f --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/__init__.py.jinja @@ -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.[/]") diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/__main__.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/__main__.py.jinja new file mode 100644 index 0000000..868d99e --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/__main__.py.jinja @@ -0,0 +1,4 @@ +from . import main + +if __name__ == "__main__": + main() diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/common.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/common.py.jinja new file mode 100644 index 0000000..255dd54 --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/common.py.jinja @@ -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"] diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/filters/__init__.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/filters/__init__.py.jinja new file mode 100644 index 0000000..a816480 --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/filters/__init__.py.jinja @@ -0,0 +1,3 @@ +from .admin import Admin + +__all__ = ["Admin"] diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/filters/admin.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/filters/admin.py.jinja new file mode 100644 index 0000000..902d577 --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/filters/admin.py.jinja @@ -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 %} diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/__init__.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/__init__.py.jinja new file mode 100644 index 0000000..1c0f04f --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/__init__.py.jinja @@ -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 %} diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/initialize/__init__.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/initialize/__init__.py.jinja new file mode 100644 index 0000000..1162e4d --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/initialize/__init__.py.jinja @@ -0,0 +1,3 @@ +from .initializer import router + +__all__ = ["router"] diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/initialize/initializer.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/initialize/initializer.py.jinja new file mode 100644 index 0000000..20c4e83 --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/initialize/initializer.py.jinja @@ -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...") diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/start/__init__.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/start/__init__.py.jinja new file mode 100644 index 0000000..38622c4 --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/start/__init__.py.jinja @@ -0,0 +1,3 @@ +from .start import router + +__all__ = ["router"] diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/start/start.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/start/start.py.jinja new file mode 100644 index 0000000..9231361 --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/start/start.py.jinja @@ -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()) diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}deposit{% endif %}/__init__.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}deposit{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..6771c43 --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}deposit{% endif %}/__init__.py.jinja @@ -0,0 +1,3 @@ +from .deposit import router + +__all__ = ["router"] diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}deposit{% endif %}/deposit.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}deposit{% endif %}/deposit.py.jinja new file mode 100644 index 0000000..6a0bee9 --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}deposit{% endif %}/deposit.py.jinja @@ -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"💳 Invoice for ${amount:.2f}\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"✅ Payment received!\n\n" + f"Amount: ${float(invoice.amount):.2f}\n" + f"New balance: ${new_balance:.2f}", + ) diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}withdraw{% endif %}/__init__.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}withdraw{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..023a53a --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}withdraw{% endif %}/__init__.py.jinja @@ -0,0 +1,3 @@ +from .withdraw import router + +__all__ = ["router"] diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}withdraw{% endif %}/withdraw.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}withdraw{% endif %}/withdraw.py.jinja new file mode 100644 index 0000000..e847300 --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/handlers/{% if include_payments %}withdraw{% endif %}/withdraw.py.jinja @@ -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"💸 Withdraw\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{e}") + return + + await message.answer( + f"✅ Withdrawn!\n\n" + f"Amount: ${amount:.2f}\n" + f"Fee: ${fee:.2f}\n" + f"Paid out: ${payout:.2f} USDT" + ) diff --git a/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/keyboards/__init__.py.jinja b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/keyboards/__init__.py.jinja new file mode 100644 index 0000000..d11040a --- /dev/null +++ b/template/backend/src/{% if 'aiogram_bot' in backend_services %}bot{% endif %}/keyboards/__init__.py.jinja @@ -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 %} \ No newline at end of file diff --git a/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/__init__.py.jinja b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/__main__.py.jinja b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/__main__.py.jinja new file mode 100644 index 0000000..ff9384e --- /dev/null +++ b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/__main__.py.jinja @@ -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() diff --git a/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/app.py.jinja b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/app.py.jinja new file mode 100644 index 0000000..4dfb68b --- /dev/null +++ b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/app.py.jinja @@ -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 %} diff --git a/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/middlewares/__init__.py.jinja b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/middlewares/__init__.py.jinja new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/modules/__init__.py.jinja b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/modules/__init__.py.jinja new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/routers/__init__.py.jinja b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/routers/__init__.py.jinja new file mode 100644 index 0000000..67378ff --- /dev/null +++ b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/routers/__init__.py.jinja @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from . import api + +router = APIRouter() +router.include_router(api.router, prefix="/api") diff --git a/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/routers/api/__init__.py.jinja b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/routers/api/__init__.py.jinja new file mode 100644 index 0000000..2448413 --- /dev/null +++ b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/routers/api/__init__.py.jinja @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from . import health + +router = APIRouter() +router.include_router(health.router, prefix="/health") diff --git a/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/routers/api/health.py.jinja b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/routers/api/health.py.jinja new file mode 100644 index 0000000..35abc5b --- /dev/null +++ b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/routers/api/health.py.jinja @@ -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 -%} \ No newline at end of file diff --git a/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/schemas/__init__.py.jinja b/template/backend/src/{% if 'api' in backend_services %}api{% endif %}/schemas/__init__.py.jinja new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/__init__.py.jinja b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..e74ab29 --- /dev/null +++ b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/__init__.py.jinja @@ -0,0 +1,3 @@ +from .modules.client import PyroClient + +__all__ = ["PyroClient"] diff --git a/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/__main__.py.jinja b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/__main__.py.jinja new file mode 100644 index 0000000..acaf989 --- /dev/null +++ b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/__main__.py.jinja @@ -0,0 +1,4 @@ +from .runner import main + +if __name__ == "__main__": + main() diff --git a/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/__init__.py.jinja b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/__init__.py.jinja new file mode 100644 index 0000000..305c405 --- /dev/null +++ b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/__init__.py.jinja @@ -0,0 +1,3 @@ +from . import echo, private + +handlers = echo.handlers + private.handlers diff --git a/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/echo.py.jinja b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/echo.py.jinja new file mode 100644 index 0000000..4243261 --- /dev/null +++ b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/echo.py.jinja @@ -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 diff --git a/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/private/__init__.py.jinja b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/private/__init__.py.jinja new file mode 100644 index 0000000..96c9fa5 --- /dev/null +++ b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/private/__init__.py.jinja @@ -0,0 +1,3 @@ +from . import pm_message + +handlers = pm_message.handlers diff --git a/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/private/pm_message.py.jinja b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/private/pm_message.py.jinja new file mode 100644 index 0000000..def4bfd --- /dev/null +++ b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/handlers/private/pm_message.py.jinja @@ -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 diff --git a/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/modules/__init__.py b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/modules/__init__.py new file mode 100644 index 0000000..2ae2839 --- /dev/null +++ b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/modules/__init__.py @@ -0,0 +1 @@ +pass diff --git a/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/modules/client.py.jinja b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/modules/client.py.jinja new file mode 100644 index 0000000..f392709 --- /dev/null +++ b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/modules/client.py.jinja @@ -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"] diff --git a/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/runner.py.jinja b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/runner.py.jinja new file mode 100644 index 0000000..9e84452 --- /dev/null +++ b/template/backend/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/runner.py.jinja @@ -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.[/]") diff --git a/template/backend/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__init__.py.jinja b/template/backend/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..0dcf9c2 --- /dev/null +++ b/template/backend/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__init__.py.jinja @@ -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.[/]") diff --git a/template/backend/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__main__.py.jinja b/template/backend/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__main__.py.jinja new file mode 100644 index 0000000..868d99e --- /dev/null +++ b/template/backend/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__main__.py.jinja @@ -0,0 +1,4 @@ +from . import main + +if __name__ == "__main__": + main() diff --git a/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/__init__.py.jinja b/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/__main__.py.jinja b/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/__main__.py.jinja new file mode 100644 index 0000000..50da933 --- /dev/null +++ b/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/__main__.py.jinja @@ -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() diff --git a/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/broker.py.jinja b/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/broker.py.jinja new file mode 100644 index 0000000..9e81d86 --- /dev/null +++ b/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/broker.py.jinja @@ -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 %} \ No newline at end of file diff --git a/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/tasks/__init__.py.jinja b/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/tasks/__init__.py.jinja new file mode 100644 index 0000000..6adfe59 --- /dev/null +++ b/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/tasks/__init__.py.jinja @@ -0,0 +1,3 @@ +from . import example + +__all__ = ["example"] diff --git a/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/tasks/example.py.jinja b/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/tasks/example.py.jinja new file mode 100644 index 0000000..fd264e8 --- /dev/null +++ b/template/backend/src/{% if 'taskiq_worker' in backend_services %}worker{% endif %}/tasks/example.py.jinja @@ -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" diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/__init__.py b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/container.py.jinja b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/container.py.jinja new file mode 100644 index 0000000..9b7d65e --- /dev/null +++ b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/container.py.jinja @@ -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 }} diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/integrations/__init__.py.jinja b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/integrations/__init__.py.jinja new file mode 100644 index 0000000..53993f6 --- /dev/null +++ b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/integrations/__init__.py.jinja @@ -0,0 +1,3 @@ +from . import asyncio + +__all__ = ["asyncio"] diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/integrations/asyncio.py.jinja b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/integrations/asyncio.py.jinja new file mode 100644 index 0000000..6b11205 --- /dev/null +++ b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/integrations/asyncio.py.jinja @@ -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"] diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/__init__.py b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if database != 'none' %}db.py{% endif %}.jinja b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if database != 'none' %}db.py{% endif %}.jinja new file mode 100644 index 0000000..398b523 --- /dev/null +++ b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if database != 'none' %}db.py{% endif %}.jinja @@ -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 %} diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if database == 'postgres' %}repositories.py{% endif %}.jinja b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if database == 'postgres' %}repositories.py{% endif %}.jinja new file mode 100644 index 0000000..0fd1787 --- /dev/null +++ b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if database == 'postgres' %}repositories.py{% endif %}.jinja @@ -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) diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if dynamic_config %}config.py{% endif %}.jinja b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if dynamic_config %}config.py{% endif %}.jinja new file mode 100644 index 0000000..8867bb7 --- /dev/null +++ b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if dynamic_config %}config.py{% endif %}.jinja @@ -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 %} diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if use_llm %}llm.py{% endif %}.jinja b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if use_llm %}llm.py{% endif %}.jinja new file mode 100644 index 0000000..7fb63d2 --- /dev/null +++ b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if use_llm %}llm.py{% endif %}.jinja @@ -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) diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if use_redis %}redis.py{% endif %}.jinja b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if use_redis %}redis.py{% endif %}.jinja new file mode 100644 index 0000000..b057b79 --- /dev/null +++ b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/providers/{% if use_redis %}redis.py{% endif %}.jinja @@ -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 %} diff --git a/template/backend/src/{% if use_dishka %}dependencies{% endif %}/types/__init__.py.jinja b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/types/__init__.py.jinja new file mode 100644 index 0000000..2ae2839 --- /dev/null +++ b/template/backend/src/{% if use_dishka %}dependencies{% endif %}/types/__init__.py.jinja @@ -0,0 +1 @@ +pass diff --git a/template/backend/{% if 'kurigram_userbot' in backend_services %}sessions{% endif %}/.gitkeep b/template/backend/{% if 'kurigram_userbot' in backend_services %}sessions{% endif %}/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/template/backend/{% if database == 'postgres' %}alembic.ini{% endif %}.jinja b/template/backend/{% if database == 'postgres' %}alembic.ini{% endif %}.jinja new file mode 100644 index 0000000..5d86160 --- /dev/null +++ b/template/backend/{% if database == 'postgres' %}alembic.ini{% endif %}.jinja @@ -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 diff --git a/template/backend/{% if database == 'postgres' %}migrations{% endif %}/env.py b/template/backend/{% if database == 'postgres' %}migrations{% endif %}/env.py new file mode 100644 index 0000000..c79960f --- /dev/null +++ b/template/backend/{% if database == 'postgres' %}migrations{% endif %}/env.py @@ -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()) diff --git a/template/backend/{% if database == 'postgres' %}migrations{% endif %}/script.py.mako b/template/backend/{% if database == 'postgres' %}migrations{% endif %}/script.py.mako new file mode 100644 index 0000000..b35571c --- /dev/null +++ b/template/backend/{% if database == 'postgres' %}migrations{% endif %}/script.py.mako @@ -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"} diff --git a/template/backend/{% if database == 'postgres' %}migrations{% endif %}/versions/.gitkeep b/template/backend/{% if database == 'postgres' %}migrations{% endif %}/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/template/ty.toml b/template/ty.toml new file mode 100644 index 0000000..d9a9e6a --- /dev/null +++ b/template/ty.toml @@ -0,0 +1,5 @@ +[environment] +python = "backend/.venv" + +[src] +exclude = ["backend/migrations"] diff --git a/template/{{ _copier_conf.answers_file }}.jinja b/template/{{ _copier_conf.answers_file }}.jinja new file mode 100644 index 0000000..d0f5d97 --- /dev/null +++ b/template/{{ _copier_conf.answers_file }}.jinja @@ -0,0 +1 @@ +{{ _copier_answers|to_nice_yaml -}}