Initial template
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# backend-python
|
||||||
|
|
||||||
|
Copier template for a `backend/` part: a uv project with one Docker image and one `python -m <module>` 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" <this-repo> <dest>
|
||||||
|
copier update -a .copier-answers.backend.yml
|
||||||
|
```
|
||||||
+98
@@ -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 <module> 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 %}"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.venv
|
||||||
|
.env
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.ruff_cache
|
||||||
|
.pytest_cache
|
||||||
|
sessions
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
sessions/*.session*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
## Checking commands
|
||||||
|
After writing code, always run from `backend/`:
|
||||||
|
```shell
|
||||||
|
ruff format
|
||||||
|
ruff check --fix
|
||||||
|
ty check
|
||||||
|
```
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{%- set svc_modules = [] -%}
|
||||||
|
{%- if 'api' in backend_services %}{% set _ = svc_modules.append("api") %}{% endif -%}
|
||||||
|
{%- if 'aiogram_bot' in backend_services %}{% set _ = svc_modules.append("bot") %}{% endif -%}
|
||||||
|
{%- if 'taskiq_worker' in backend_services %}{% set _ = svc_modules.append("worker") %}{% endif -%}
|
||||||
|
{%- if 'kurigram_userbot' in backend_services %}{% set _ = svc_modules.append("userbot") %}{% endif -%}
|
||||||
|
{%- if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}{% set _ = svc_modules.append("scheduler") %}{% endif -%}
|
||||||
|
{%- set has_script = dynamic_config and svc_modules -%}
|
||||||
|
COMPOSE := docker compose --project-directory ..
|
||||||
|
|
||||||
|
.PHONY: fmt check build{% if database == 'postgres' %} migrate revision{% endif %}{% if has_script %} script{% endif %}{% if 'kurigram_userbot' in backend_services %} session{% endif %}
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
uv run ruff format
|
||||||
|
uv run ruff check --fix
|
||||||
|
|
||||||
|
check:
|
||||||
|
uv run ruff format --check
|
||||||
|
uv run ruff check
|
||||||
|
uv run ty check
|
||||||
|
|
||||||
|
build:
|
||||||
|
$(COMPOSE) build {{ svc_modules[0] }}
|
||||||
|
{% if database == 'postgres' %}
|
||||||
|
migrate:
|
||||||
|
$(COMPOSE) --profile external run --rm migrator
|
||||||
|
|
||||||
|
revision:
|
||||||
|
$(COMPOSE) --profile external run --rm migrator revision --autogenerate -m "$(m)"
|
||||||
|
{% endif %}{% if has_script %}
|
||||||
|
script:
|
||||||
|
@$(COMPOSE) run --rm {{ svc_modules[0] }} scripts.$(word 2,$(MAKECMDGOALS)) $(wordlist 3,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
|
||||||
|
|
||||||
|
%:
|
||||||
|
@:
|
||||||
|
{% endif %}{% if 'kurigram_userbot' in backend_services %}
|
||||||
|
session:
|
||||||
|
uv run python scripts/session/create.py
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{%- set has_ports = 'api' in backend_services or database != 'none' or use_redis -%}
|
||||||
|
services:{% if not has_ports %} {}{% endif %}
|
||||||
|
{% if 'api' in backend_services %} api:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${API__PORT:-8080}:8080"
|
||||||
|
{% endif %}{% if database == 'mongo' %} mongodb:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${DB__PORT:-27017}:${DB__PORT:-27017}"
|
||||||
|
{% endif %}{% if database == 'postgres' %} postgres:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${DB__PORT:-5432}:5432"
|
||||||
|
{% endif %}{% if use_redis %} redis:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${REDIS__PORT:-6379}:6379"
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{%- set dash = project_slug | replace('_', '-') -%}
|
||||||
|
{% if 'api' in backend_services -%}
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
aliases:
|
||||||
|
- {{ dash }}-api
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
|
{%- else -%}
|
||||||
|
services: {}
|
||||||
|
{%- endif %}
|
||||||
@@ -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 %}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from userbot import PyroClient
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
sessions_dir = anyio.Path("sessions")
|
||||||
|
await sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
client = PyroClient("generated", workdir=".", load_handlers=False)
|
||||||
|
await client.start()
|
||||||
|
user_id = client.me.id if client.me else "unknown"
|
||||||
|
await client.stop()
|
||||||
|
|
||||||
|
await anyio.Path("generated.session").rename(sessions_dir / f"{user_id}.session")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
{% if database == 'postgres' -%}
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.json import JSON
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from dependencies.container import container
|
||||||
|
from utils.db.models.config import DynamicConfigBase
|
||||||
|
from utils.db.repositories import ConfigRepository
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def get_repo() -> AsyncGenerator[ConfigRepository]:
|
||||||
|
repo = await container.get(ConfigRepository)
|
||||||
|
try:
|
||||||
|
yield repo
|
||||||
|
finally:
|
||||||
|
await container.close()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_value(raw: str) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
lowered = raw.lower()
|
||||||
|
if lowered in {"true", "false"}:
|
||||||
|
return lowered == "true"
|
||||||
|
if lowered in {"null", "none"}:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def dig(data: dict[str, Any], path: str) -> Any:
|
||||||
|
current: Any = data
|
||||||
|
for key in path.split("."):
|
||||||
|
if not isinstance(current, dict) or key not in current:
|
||||||
|
return _MISSING
|
||||||
|
current = current[key]
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def set_path(data: dict[str, Any], path: str, value: Any) -> None:
|
||||||
|
keys = path.split(".")
|
||||||
|
current = data
|
||||||
|
for key in keys[:-1]:
|
||||||
|
nxt = current.get(key)
|
||||||
|
if not isinstance(nxt, dict):
|
||||||
|
nxt = {}
|
||||||
|
current[key] = nxt
|
||||||
|
current = nxt
|
||||||
|
current[keys[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
|
def unset_path(data: dict[str, Any], path: str) -> bool:
|
||||||
|
keys = path.split(".")
|
||||||
|
current = data
|
||||||
|
for key in keys[:-1]:
|
||||||
|
nxt = current.get(key)
|
||||||
|
if not isinstance(nxt, dict):
|
||||||
|
return False
|
||||||
|
current = nxt
|
||||||
|
return current.pop(keys[-1], _MISSING) is not _MISSING
|
||||||
|
|
||||||
|
|
||||||
|
def validate(data: dict[str, Any]) -> DynamicConfigBase:
|
||||||
|
try:
|
||||||
|
return DynamicConfigBase.model_validate(data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
console.print("[bold red]✗ Invalid config[/]")
|
||||||
|
for error in exc.errors():
|
||||||
|
loc = ".".join(str(part) for part in error["loc"])
|
||||||
|
console.print(f" [red]{loc or '<root>'}[/]: {error['msg']}")
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def render(config: DynamicConfigBase) -> None:
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
JSON(config.model_dump_json(indent=2)),
|
||||||
|
title="[bold cyan]DynamicConfig[/]",
|
||||||
|
border_style="cyan",
|
||||||
|
expand=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_show() -> None:
|
||||||
|
async with get_repo() as repo:
|
||||||
|
render(await repo.get())
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_get(path: str) -> None:
|
||||||
|
async with get_repo() as repo:
|
||||||
|
config = await repo.get()
|
||||||
|
value = dig(config.model_dump(), path)
|
||||||
|
if value is _MISSING:
|
||||||
|
console.print(f"[red]✗[/] no such field: [bold]{path}[/]")
|
||||||
|
raise SystemExit(1)
|
||||||
|
table = Table(show_header=False, box=None)
|
||||||
|
table.add_column(style="cyan")
|
||||||
|
table.add_column(style="white")
|
||||||
|
table.add_row(path, json.dumps(value, ensure_ascii=False))
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_set(path: str, raw: str) -> None:
|
||||||
|
async with get_repo() as repo:
|
||||||
|
data = (await repo.get()).model_dump()
|
||||||
|
value = parse_value(raw)
|
||||||
|
set_path(data, path, value)
|
||||||
|
config = await repo.save(validate(data))
|
||||||
|
shown = json.dumps(value, ensure_ascii=False)
|
||||||
|
console.print(f"[green]✓[/] set [cyan]{path}[/] = {shown}")
|
||||||
|
render(config)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_unset(path: str) -> None:
|
||||||
|
async with get_repo() as repo:
|
||||||
|
data = (await repo.get()).model_dump()
|
||||||
|
if not unset_path(data, path):
|
||||||
|
console.print(f"[red]✗[/] no such field: [bold]{path}[/]")
|
||||||
|
raise SystemExit(1)
|
||||||
|
config = await repo.save(validate(data))
|
||||||
|
console.print(f"[green]✓[/] unset [cyan]{path}[/] (reset to default)")
|
||||||
|
render(config)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mutate_list(path: str, raw: str, *, add: bool) -> None:
|
||||||
|
async with get_repo() as repo:
|
||||||
|
data = (await repo.get()).model_dump()
|
||||||
|
current = dig(data, path)
|
||||||
|
items = list(current) if isinstance(current, list) else []
|
||||||
|
value = parse_value(raw)
|
||||||
|
if add:
|
||||||
|
if value not in items:
|
||||||
|
items.append(value)
|
||||||
|
else:
|
||||||
|
items = [item for item in items if item != value]
|
||||||
|
set_path(data, path, items)
|
||||||
|
config = await repo.save(validate(data))
|
||||||
|
verb = "added to" if add else "removed from"
|
||||||
|
shown = json.dumps(value, ensure_ascii=False)
|
||||||
|
console.print(f"[green]✓[/] {shown} {verb} [cyan]{path}[/]")
|
||||||
|
render(config)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_reset() -> None:
|
||||||
|
async with get_repo() as repo:
|
||||||
|
config = await repo.reset()
|
||||||
|
console.print("[yellow]↺[/] config reset to defaults")
|
||||||
|
render(config)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Manage DynamicConfig in Postgres")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("show", help="Show the current config")
|
||||||
|
|
||||||
|
get_p = sub.add_parser("get", help="Show a single field")
|
||||||
|
get_p.add_argument("path", help="Dotted field path, e.g. bot.admins")
|
||||||
|
|
||||||
|
set_p = sub.add_parser("set", help="Set a field (value is smart-parsed)")
|
||||||
|
set_p.add_argument("path", help="Dotted field path, e.g. bot.admins")
|
||||||
|
set_p.add_argument("value", help="New value (JSON or scalar)")
|
||||||
|
|
||||||
|
unset_p = sub.add_parser("unset", help="Remove a field (falls back to default)")
|
||||||
|
unset_p.add_argument("path")
|
||||||
|
|
||||||
|
add_p = sub.add_parser("add", help="Append an item to a list field")
|
||||||
|
add_p.add_argument("path", help="Dotted path to a list, e.g. bot.admins")
|
||||||
|
add_p.add_argument("value")
|
||||||
|
|
||||||
|
rm_p = sub.add_parser("remove", help="Remove an item from a list field")
|
||||||
|
rm_p.add_argument("path", help="Dotted path to a list, e.g. bot.admins")
|
||||||
|
rm_p.add_argument("value")
|
||||||
|
|
||||||
|
sub.add_parser("reset", help="Reset the whole config to defaults")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
async def run(args: argparse.Namespace) -> None:
|
||||||
|
match args.command:
|
||||||
|
case "show":
|
||||||
|
await cmd_show()
|
||||||
|
case "get":
|
||||||
|
await cmd_get(args.path)
|
||||||
|
case "set":
|
||||||
|
await cmd_set(args.path, args.value)
|
||||||
|
case "unset":
|
||||||
|
await cmd_unset(args.path)
|
||||||
|
case "add":
|
||||||
|
await _mutate_list(args.path, args.value, add=True)
|
||||||
|
case "remove":
|
||||||
|
await _mutate_list(args.path, args.value, add=False)
|
||||||
|
case "reset":
|
||||||
|
await cmd_reset()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
asyncio.run(run(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
{%- else -%}
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.json import JSON
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from utils.db import client, init_db
|
||||||
|
from utils.db.models.config import DynamicConfig, DynamicConfigBase
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
|
async def read_config() -> DynamicConfigBase:
|
||||||
|
await init_db()
|
||||||
|
doc = await DynamicConfig.get_or_create()
|
||||||
|
return DynamicConfigBase.model_validate(doc, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def write_config(config: DynamicConfigBase) -> DynamicConfigBase:
|
||||||
|
await init_db()
|
||||||
|
doc = await DynamicConfig.get_or_create()
|
||||||
|
for name in DynamicConfigBase.model_fields:
|
||||||
|
setattr(doc, name, getattr(config, name))
|
||||||
|
await doc.save()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def parse_value(raw: str) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
lowered = raw.lower()
|
||||||
|
if lowered in {"true", "false"}:
|
||||||
|
return lowered == "true"
|
||||||
|
if lowered in {"null", "none"}:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def dig(data: dict[str, Any], path: str) -> Any:
|
||||||
|
current: Any = data
|
||||||
|
for key in path.split("."):
|
||||||
|
if not isinstance(current, dict) or key not in current:
|
||||||
|
return _MISSING
|
||||||
|
current = current[key]
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def set_path(data: dict[str, Any], path: str, value: Any) -> None:
|
||||||
|
keys = path.split(".")
|
||||||
|
current = data
|
||||||
|
for key in keys[:-1]:
|
||||||
|
nxt = current.get(key)
|
||||||
|
if not isinstance(nxt, dict):
|
||||||
|
nxt = {}
|
||||||
|
current[key] = nxt
|
||||||
|
current = nxt
|
||||||
|
current[keys[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
|
def unset_path(data: dict[str, Any], path: str) -> bool:
|
||||||
|
keys = path.split(".")
|
||||||
|
current = data
|
||||||
|
for key in keys[:-1]:
|
||||||
|
nxt = current.get(key)
|
||||||
|
if not isinstance(nxt, dict):
|
||||||
|
return False
|
||||||
|
current = nxt
|
||||||
|
return current.pop(keys[-1], _MISSING) is not _MISSING
|
||||||
|
|
||||||
|
|
||||||
|
def validate(data: dict[str, Any]) -> DynamicConfigBase:
|
||||||
|
try:
|
||||||
|
return DynamicConfigBase.model_validate(data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
console.print("[bold red]✗ Invalid config[/]")
|
||||||
|
for error in exc.errors():
|
||||||
|
loc = ".".join(str(part) for part in error["loc"])
|
||||||
|
console.print(f" [red]{loc or '<root>'}[/]: {error['msg']}")
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def render(config: DynamicConfigBase) -> None:
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
JSON(config.model_dump_json(indent=2)),
|
||||||
|
title="[bold cyan]DynamicConfig[/]",
|
||||||
|
border_style="cyan",
|
||||||
|
expand=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_show() -> None:
|
||||||
|
render(await read_config())
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_get(path: str) -> None:
|
||||||
|
config = await read_config()
|
||||||
|
value = dig(config.model_dump(), path)
|
||||||
|
if value is _MISSING:
|
||||||
|
console.print(f"[red]✗[/] no such field: [bold]{path}[/]")
|
||||||
|
raise SystemExit(1)
|
||||||
|
table = Table(show_header=False, box=None)
|
||||||
|
table.add_column(style="cyan")
|
||||||
|
table.add_column(style="white")
|
||||||
|
table.add_row(path, json.dumps(value, ensure_ascii=False))
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_set(path: str, raw: str) -> None:
|
||||||
|
data = (await read_config()).model_dump()
|
||||||
|
value = parse_value(raw)
|
||||||
|
set_path(data, path, value)
|
||||||
|
config = await write_config(validate(data))
|
||||||
|
shown = json.dumps(value, ensure_ascii=False)
|
||||||
|
console.print(f"[green]✓[/] set [cyan]{path}[/] = {shown}")
|
||||||
|
render(config)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_unset(path: str) -> None:
|
||||||
|
data = (await read_config()).model_dump()
|
||||||
|
if not unset_path(data, path):
|
||||||
|
console.print(f"[red]✗[/] no such field: [bold]{path}[/]")
|
||||||
|
raise SystemExit(1)
|
||||||
|
config = await write_config(validate(data))
|
||||||
|
console.print(f"[green]✓[/] unset [cyan]{path}[/] (reset to default)")
|
||||||
|
render(config)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mutate_list(path: str, raw: str, *, add: bool) -> None:
|
||||||
|
data = (await read_config()).model_dump()
|
||||||
|
current = dig(data, path)
|
||||||
|
items = list(current) if isinstance(current, list) else []
|
||||||
|
value = parse_value(raw)
|
||||||
|
if add:
|
||||||
|
if value not in items:
|
||||||
|
items.append(value)
|
||||||
|
else:
|
||||||
|
items = [item for item in items if item != value]
|
||||||
|
set_path(data, path, items)
|
||||||
|
config = await write_config(validate(data))
|
||||||
|
verb = "added to" if add else "removed from"
|
||||||
|
shown = json.dumps(value, ensure_ascii=False)
|
||||||
|
console.print(f"[green]✓[/] {shown} {verb} [cyan]{path}[/]")
|
||||||
|
render(config)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_reset() -> None:
|
||||||
|
config = await write_config(DynamicConfigBase())
|
||||||
|
console.print("[yellow]↺[/] config reset to defaults")
|
||||||
|
render(config)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Manage DynamicConfig in MongoDB")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("show", help="Show the current config")
|
||||||
|
|
||||||
|
get_p = sub.add_parser("get", help="Show a single field")
|
||||||
|
get_p.add_argument("path", help="Dotted field path, e.g. bot.admins")
|
||||||
|
|
||||||
|
set_p = sub.add_parser("set", help="Set a field (value is smart-parsed)")
|
||||||
|
set_p.add_argument("path", help="Dotted field path, e.g. bot.admins")
|
||||||
|
set_p.add_argument("value", help="New value (JSON or scalar)")
|
||||||
|
|
||||||
|
unset_p = sub.add_parser("unset", help="Remove a field (falls back to default)")
|
||||||
|
unset_p.add_argument("path")
|
||||||
|
|
||||||
|
add_p = sub.add_parser("add", help="Append an item to a list field")
|
||||||
|
add_p.add_argument("path", help="Dotted path to a list, e.g. bot.admins")
|
||||||
|
add_p.add_argument("value")
|
||||||
|
|
||||||
|
rm_p = sub.add_parser("remove", help="Remove an item from a list field")
|
||||||
|
rm_p.add_argument("path", help="Dotted path to a list, e.g. bot.admins")
|
||||||
|
rm_p.add_argument("value")
|
||||||
|
|
||||||
|
sub.add_parser("reset", help="Reset the whole config to defaults")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
async def run(args: argparse.Namespace) -> None:
|
||||||
|
try:
|
||||||
|
match args.command:
|
||||||
|
case "show":
|
||||||
|
await cmd_show()
|
||||||
|
case "get":
|
||||||
|
await cmd_get(args.path)
|
||||||
|
case "set":
|
||||||
|
await cmd_set(args.path, args.value)
|
||||||
|
case "unset":
|
||||||
|
await cmd_unset(args.path)
|
||||||
|
case "add":
|
||||||
|
await _mutate_list(args.path, args.value, add=True)
|
||||||
|
case "remove":
|
||||||
|
await _mutate_list(args.path, args.value, add=False)
|
||||||
|
case "reset":
|
||||||
|
await cmd_reset()
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
asyncio.run(run(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
{%- endif %}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{% if dynamic_config %}from .config import config
|
||||||
|
{% endif %}from .env import env
|
||||||
|
from .logging import logger
|
||||||
|
|
||||||
|
__all__ = [{% if dynamic_config %}"config", {% endif %}"env", "logger"]
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{% if database != 'none' or use_redis %}import os
|
||||||
|
|
||||||
|
{% endif %}from pydantic import Field{% if database != 'none' or 'aiogram_bot' in backend_services or include_payments or use_llm or use_redis %}, SecretStr{% endif %}
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
{% if database != 'none' or use_redis %}
|
||||||
|
|
||||||
|
def is_prod() -> bool:
|
||||||
|
return os.getenv("RUN_ENVIRONMENT") == "prod"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
class LogSettings(BaseSettings):
|
||||||
|
level: str = "INFO"
|
||||||
|
level_external: str = "WARNING"
|
||||||
|
show_time: bool = False
|
||||||
|
console_width: int = 150
|
||||||
|
{% if database == 'mongo' %}
|
||||||
|
|
||||||
|
class DatabaseSettings(BaseSettings):
|
||||||
|
host: str = "mongodb"
|
||||||
|
port: int = 27017
|
||||||
|
user: str = "{{ project_slug }}"
|
||||||
|
password: SecretStr = SecretStr("{{ project_slug }}")
|
||||||
|
db_name: str = "{{ project_slug }}"
|
||||||
|
connection_params: str = "?authSource=admin"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connection_url(self) -> str:
|
||||||
|
host = self.host if is_prod() else "localhost"
|
||||||
|
password = self.password.get_secret_value()
|
||||||
|
return f"mongodb://{self.user}:{password}@{host}:{self.port}/{self.db_name}{self.connection_params}"
|
||||||
|
{% elif database == 'postgres' %}
|
||||||
|
|
||||||
|
class DatabaseSettings(BaseSettings):
|
||||||
|
host: str = "postgres"
|
||||||
|
port: int = 5432
|
||||||
|
user: str = "{{ project_slug }}"
|
||||||
|
password: SecretStr = SecretStr("{{ project_slug }}")
|
||||||
|
db_name: str = "{{ project_slug }}"
|
||||||
|
min_pool_size: int = 5
|
||||||
|
max_pool_size: int = 20
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connection_url(self) -> str:
|
||||||
|
host = self.host if is_prod() else "localhost"
|
||||||
|
password = self.password.get_secret_value()
|
||||||
|
return f"postgresql://{self.user}:{password}@{host}:{self.port}/{self.db_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def async_connection_url(self) -> str:
|
||||||
|
return self.connection_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
{% endif %}{% if use_redis %}
|
||||||
|
|
||||||
|
class RedisSettings(BaseSettings):
|
||||||
|
host: str = "redis"
|
||||||
|
port: int = 6379
|
||||||
|
db: int = 0
|
||||||
|
password: SecretStr | None = None
|
||||||
|
cache_ttl: int = 300
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
host = self.host if is_prod() else "localhost"
|
||||||
|
auth = f":{self.password.get_secret_value()}@" if self.password else ""
|
||||||
|
return f"redis://{auth}{host}:{self.port}/{self.db}"
|
||||||
|
{% endif %}{% if 'api' in backend_services %}
|
||||||
|
|
||||||
|
class ApiSettings(BaseSettings):
|
||||||
|
host: str = "0.0.0.0" # noqa: S104
|
||||||
|
port: int = 8080
|
||||||
|
workers: int = 1
|
||||||
|
{%- if serve_spa %}
|
||||||
|
static_dir: str = "static"
|
||||||
|
{%- endif %}
|
||||||
|
{% endif %}{% if 'aiogram_bot' in backend_services %}
|
||||||
|
|
||||||
|
class BotSettings(BaseSettings):
|
||||||
|
token: SecretStr = SecretStr("")
|
||||||
|
admins: list[int] = Field(default_factory=list)
|
||||||
|
{% endif %}{% if include_payments %}
|
||||||
|
|
||||||
|
class CryptoPaySettings(BaseSettings):
|
||||||
|
token: SecretStr = SecretStr("")
|
||||||
|
{% endif %}{% if use_llm %}
|
||||||
|
|
||||||
|
class LlmSettings(BaseSettings):
|
||||||
|
model: str = "google-gla:gemini-2.5-flash"
|
||||||
|
gemini_api_key: SecretStr = SecretStr("")
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
log: LogSettings = Field(default_factory=LogSettings)
|
||||||
|
{%- if database != 'none' %}
|
||||||
|
db: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||||
|
{%- endif %}
|
||||||
|
{%- if use_redis %}
|
||||||
|
redis: RedisSettings = Field(default_factory=RedisSettings)
|
||||||
|
{%- endif %}
|
||||||
|
{%- if 'api' in backend_services %}
|
||||||
|
api: ApiSettings = Field(default_factory=ApiSettings)
|
||||||
|
{%- endif %}
|
||||||
|
{%- if 'aiogram_bot' in backend_services %}
|
||||||
|
bot: BotSettings = Field(default_factory=BotSettings)
|
||||||
|
{%- endif %}
|
||||||
|
{%- if include_payments %}
|
||||||
|
crypto_pay: CryptoPaySettings = Field(default_factory=CryptoPaySettings)
|
||||||
|
{%- endif %}
|
||||||
|
{%- if use_llm %}
|
||||||
|
llm: LlmSettings = Field(default_factory=LlmSettings)
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
env = Settings()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.logging import RichHandler
|
||||||
|
from rich.traceback import install
|
||||||
|
|
||||||
|
from .env import env
|
||||||
|
|
||||||
|
console = Console(width=env.log.console_width, color_system="auto", force_terminal=True)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
{% if 'aiogram_bot' in backend_services %} from aiogram.dispatcher import router # noqa: PLC0415
|
||||||
|
|
||||||
|
{% endif %} logging.basicConfig(
|
||||||
|
level=env.log.level_external,
|
||||||
|
format="",
|
||||||
|
datefmt=None,
|
||||||
|
handlers=[
|
||||||
|
RichHandler(
|
||||||
|
console=console,
|
||||||
|
markup=True,
|
||||||
|
rich_tracebacks=True,
|
||||||
|
enable_link_path=False,
|
||||||
|
tracebacks_show_locals=True,
|
||||||
|
omit_repeated_times=False,
|
||||||
|
show_time=env.log.show_time,
|
||||||
|
{%- if 'aiogram_bot' in backend_services %}
|
||||||
|
tracebacks_suppress=[router],
|
||||||
|
{%- endif %}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
install(console=console, show_locals=True)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("{{ project_slug }}")
|
||||||
|
logger.setLevel(env.log.level)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% if database == 'postgres' -%}
|
||||||
|
async def init_db() -> None:
|
||||||
|
from . import models # noqa: F401, PLC0415
|
||||||
|
{%- else -%}
|
||||||
|
from beanie import init_beanie
|
||||||
|
from pymongo import AsyncMongoClient
|
||||||
|
|
||||||
|
from utils.env import env
|
||||||
|
|
||||||
|
client = AsyncMongoClient(env.db.connection_url)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
from .models import {% if dynamic_config %}DynamicConfig, {% endif %}User # noqa: PLC0415
|
||||||
|
|
||||||
|
{% if dynamic_config %} await init_beanie(
|
||||||
|
database=client[env.db.db_name], document_models=[DynamicConfig, User]
|
||||||
|
){% else %} await init_beanie(database=client[env.db.db_name], document_models=[User]){% endif %}
|
||||||
|
{%- endif %}
|
||||||
+4
@@ -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"]
|
||||||
+54
@@ -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 %}
|
||||||
+60
@@ -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"),
|
||||||
|
)
|
||||||
+58
@@ -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 %}
|
||||||
+5
@@ -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"]
|
||||||
+35
@@ -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
|
||||||
+88
@@ -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 %}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
ModelT = TypeVar("ModelT", bound=BaseModel)
|
||||||
|
NAMESPACE = "{{ project_slug }}"
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache:
|
||||||
|
def __init__(
|
||||||
|
self, redis: Redis, *, namespace: str = NAMESPACE, ttl: int = 300
|
||||||
|
) -> None:
|
||||||
|
self._redis = redis
|
||||||
|
self._namespace = namespace
|
||||||
|
self._ttl = ttl
|
||||||
|
|
||||||
|
def _key(self, key: str) -> str:
|
||||||
|
return f"{self._namespace}:{key}"
|
||||||
|
|
||||||
|
async def get_model(self, key: str, model: type[ModelT]) -> ModelT | None:
|
||||||
|
raw = await self._redis.get(self._key(key))
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return model.model_validate_json(raw)
|
||||||
|
|
||||||
|
async def set_model(
|
||||||
|
self, key: str, value: BaseModel, *, ttl: int | None = None
|
||||||
|
) -> None:
|
||||||
|
await self._redis.set(
|
||||||
|
self._key(key), value.model_dump_json(), ex=ttl or self._ttl
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_or_set_model(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
model: type[ModelT],
|
||||||
|
loader: Callable[[], Awaitable[ModelT]],
|
||||||
|
*,
|
||||||
|
ttl: int | None = None,
|
||||||
|
) -> ModelT:
|
||||||
|
cached = await self.get_model(key, model)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
value = await loader()
|
||||||
|
await self.set_model(key, value, ttl=ttl)
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def invalidate(self, *keys: str) -> None:
|
||||||
|
if keys:
|
||||||
|
await self._redis.delete(*(self._key(key) for key in keys))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% if database == 'postgres' -%}
|
||||||
|
from utils.db.models.config import DynamicConfigBase
|
||||||
|
from utils.db.repositories import ConfigRepository
|
||||||
|
|
||||||
|
|
||||||
|
async def config() -> DynamicConfigBase:
|
||||||
|
from dependencies.container import container # noqa: PLC0415
|
||||||
|
|
||||||
|
repo = await container.get(ConfigRepository)
|
||||||
|
return await repo.get()
|
||||||
|
{%- else -%}
|
||||||
|
from utils.db.models.config import DynamicConfig
|
||||||
|
|
||||||
|
config = DynamicConfig.get_or_create
|
||||||
|
{%- endif %}
|
||||||
+49
@@ -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.[/]")
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
from . import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+19
@@ -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"]
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from .admin import Admin
|
||||||
|
|
||||||
|
__all__ = ["Admin"]
|
||||||
+12
@@ -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 %}
|
||||||
+7
@@ -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 %}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from .initializer import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
+17
@@ -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...")
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from .start import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
+11
@@ -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())
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from .deposit import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
{% if database != 'mongo' %}from collections import defaultdict
|
||||||
|
|
||||||
|
{% endif %}from aiogram import F, Router, types
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiosend.types import Invoice
|
||||||
|
|
||||||
|
from bot.common import bot, cp
|
||||||
|
from bot.keyboards import deposit_amounts_kb, pay_invoice_kb
|
||||||
|
{% if database == 'mongo' %}from utils.db.models import User
|
||||||
|
{% endif %}
|
||||||
|
router = Router()
|
||||||
|
{% if database != 'mongo' %}balances: defaultdict[int, float] = defaultdict(float)
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
class DepositStates(StatesGroup):
|
||||||
|
waiting_custom_amount = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "Deposit")
|
||||||
|
async def on_deposit_menu(message: types.Message, state: FSMContext) -> None:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("Choose an amount:", reply_markup=deposit_amounts_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("deposit:"))
|
||||||
|
async def on_deposit_amount(callback: types.CallbackQuery, state: FSMContext) -> None:
|
||||||
|
if not callback.data or not callback.message or not callback.from_user:
|
||||||
|
return
|
||||||
|
|
||||||
|
amount_str = callback.data.split(":")[1]
|
||||||
|
|
||||||
|
if amount_str == "custom":
|
||||||
|
await state.set_state(DepositStates.waiting_custom_amount)
|
||||||
|
if isinstance(callback.message, types.Message):
|
||||||
|
await callback.message.edit_text("Enter an amount in USD (e.g. 2.5):")
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
await create_and_send_invoice(callback.from_user.id, float(amount_str))
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(DepositStates.waiting_custom_amount)
|
||||||
|
async def on_custom_amount(message: types.Message, state: FSMContext) -> None:
|
||||||
|
if not message.text or not message.from_user:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount = float(message.text.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("Invalid format. Enter a number (e.g. 2.5):")
|
||||||
|
return
|
||||||
|
|
||||||
|
if amount <= 0:
|
||||||
|
await message.answer("Amount must be greater than 0.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await create_and_send_invoice(message.from_user.id, amount)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_and_send_invoice(user_id: int, amount: float) -> None:
|
||||||
|
invoice = await cp.create_invoice(amount, "USDT")
|
||||||
|
invoice.poll(user_id=user_id)
|
||||||
|
|
||||||
|
await bot.send_message(
|
||||||
|
user_id,
|
||||||
|
f"💳 <b>Invoice for ${amount:.2f}</b>\n\nTap below to pay.",
|
||||||
|
reply_markup=pay_invoice_kb(invoice.bot_invoice_url),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cp.invoice_paid()
|
||||||
|
async def handle_payment(invoice: Invoice, user_id: int) -> None:
|
||||||
|
{%- if database == 'mongo' %}
|
||||||
|
user = await User.get_or_create(user_id)
|
||||||
|
await user.add_balance(float(invoice.amount))
|
||||||
|
new_balance = user.balance
|
||||||
|
{%- else %}
|
||||||
|
balances[user_id] += float(invoice.amount)
|
||||||
|
new_balance = balances[user_id]
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
await bot.send_message(
|
||||||
|
user_id,
|
||||||
|
f"✅ <b>Payment received!</b>\n\n"
|
||||||
|
f"Amount: ${float(invoice.amount):.2f}\n"
|
||||||
|
f"New balance: ${new_balance:.2f}",
|
||||||
|
)
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from .withdraw import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
from bot.common import cp
|
||||||
|
{% if database == 'mongo' %}from utils.db.models import User
|
||||||
|
{% else %}from bot.handlers.deposit.deposit import balances
|
||||||
|
{% endif %}
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
MIN_WITHDRAW = 1.2
|
||||||
|
WITHDRAW_FEE = 0.10
|
||||||
|
|
||||||
|
|
||||||
|
class WithdrawStates(StatesGroup):
|
||||||
|
waiting_amount = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "Withdraw")
|
||||||
|
async def on_withdraw_menu(message: types.Message, state: FSMContext) -> None:
|
||||||
|
if not message.from_user:
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await state.set_state(WithdrawStates.waiting_amount)
|
||||||
|
await message.answer(
|
||||||
|
f"💸 <b>Withdraw</b>\n\n"
|
||||||
|
f"Minimum: ${MIN_WITHDRAW:.2f}\n"
|
||||||
|
f"Fee: {int(WITHDRAW_FEE * 100)}%\n\n"
|
||||||
|
f"Enter an amount in USDT:"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(WithdrawStates.waiting_amount)
|
||||||
|
async def on_withdraw_amount(message: types.Message, state: FSMContext) -> None:
|
||||||
|
if not message.text or not message.from_user:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount = float(message.text.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("Invalid format. Enter a number (e.g. 1.5):")
|
||||||
|
return
|
||||||
|
|
||||||
|
if amount < MIN_WITHDRAW:
|
||||||
|
await message.answer(f"Minimum withdrawal: ${MIN_WITHDRAW:.2f}")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = message.from_user.id
|
||||||
|
{%- if database == 'mongo' %}
|
||||||
|
user = await User.get_by_id(user_id)
|
||||||
|
if not user or not await user.subtract_balance(amount):
|
||||||
|
await message.answer("Insufficient funds.")
|
||||||
|
return
|
||||||
|
{%- else %}
|
||||||
|
if balances[user_id] < amount:
|
||||||
|
await message.answer("Insufficient funds.")
|
||||||
|
return
|
||||||
|
balances[user_id] -= amount
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
fee = amount * WITHDRAW_FEE
|
||||||
|
payout = amount - fee
|
||||||
|
|
||||||
|
try:
|
||||||
|
await cp.transfer(
|
||||||
|
user_id=user_id, asset="USDT", amount=payout, spend_id=str(uuid.uuid4())
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
{%- if database == 'mongo' %}
|
||||||
|
await user.add_balance(amount)
|
||||||
|
{%- else %}
|
||||||
|
balances[user_id] += amount
|
||||||
|
{%- endif %}
|
||||||
|
await message.answer(f"❌ Withdrawal failed. Funds returned.\n<code>{e}</code>")
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ <b>Withdrawn!</b>\n\n"
|
||||||
|
f"Amount: ${amount:.2f}\n"
|
||||||
|
f"Fee: ${fee:.2f}\n"
|
||||||
|
f"Paid out: ${payout:.2f} USDT"
|
||||||
|
)
|
||||||
+33
@@ -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 %}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from utils.env import env
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
uvicorn.run(
|
||||||
|
"api.app:app", host=env.api.host, port=env.api.port, workers=env.api.workers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
{% if serve_spa %}from pathlib import Path
|
||||||
|
{% endif %}
|
||||||
|
{% if use_dishka %}from dishka.integrations.fastapi import setup_dishka
|
||||||
|
{% endif %}from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
{% if serve_spa %}from fastapi.responses import FileResponse
|
||||||
|
{% endif %}
|
||||||
|
from api import routers
|
||||||
|
{% if use_dishka %}from dependencies.container import container
|
||||||
|
{% endif %}{% if database != 'none' %}from utils.db import init_db
|
||||||
|
{% endif %}{% if serve_spa %}from utils.env import env
|
||||||
|
{% endif %}from utils.logging import setup_logging
|
||||||
|
{% if serve_spa %}
|
||||||
|
IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||||
|
NO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app_: FastAPI) -> AsyncGenerator[None]:
|
||||||
|
setup_logging()
|
||||||
|
{%- if database != 'none' %}
|
||||||
|
await init_db()
|
||||||
|
{%- endif %}
|
||||||
|
yield
|
||||||
|
{%- if use_dishka %}
|
||||||
|
await app_.state.dishka_container.close()
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="{{ project_name }} API", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(routers.router)
|
||||||
|
{% if serve_spa %}
|
||||||
|
spa_dir = Path(env.api.static_dir).resolve()
|
||||||
|
if spa_dir.is_dir():
|
||||||
|
|
||||||
|
@app.get("/{spa_path:path}")
|
||||||
|
async def serve_spa(spa_path: str) -> FileResponse:
|
||||||
|
candidate = (spa_dir / spa_path).resolve()
|
||||||
|
if spa_path and candidate.is_relative_to(spa_dir) and candidate.is_file():
|
||||||
|
immutable = spa_path.startswith("_app/immutable/")
|
||||||
|
headers = IMMUTABLE_HEADERS if immutable else NO_CACHE_HEADERS
|
||||||
|
return FileResponse(candidate, headers=headers)
|
||||||
|
return FileResponse(spa_dir / "index.html", headers=NO_CACHE_HEADERS)
|
||||||
|
{% endif %}
|
||||||
|
{% if use_dishka %}setup_dishka(container, app)
|
||||||
|
{% endif %}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import api
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(api.router, prefix="/api")
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import health
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(health.router, prefix="/health")
|
||||||
+38
@@ -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 -%}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from .modules.client import PyroClient
|
||||||
|
|
||||||
|
__all__ = ["PyroClient"]
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
from .runner import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from . import echo, private
|
||||||
|
|
||||||
|
handlers = echo.handlers + private.handlers
|
||||||
+12
@@ -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
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from . import pm_message
|
||||||
|
|
||||||
|
handlers = pm_message.handlers
|
||||||
+13
@@ -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
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
pass
|
||||||
+27
@@ -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"]
|
||||||
+62
@@ -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.[/]")
|
||||||
+40
@@ -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.[/]")
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
from . import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+23
@@ -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()
|
||||||
+54
@@ -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 %}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from . import example
|
||||||
|
|
||||||
|
__all__ = ["example"]
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
from utils.logging import logger
|
||||||
|
from worker.broker import broker
|
||||||
|
|
||||||
|
|
||||||
|
@broker.task{% if 'scheduler' in backend_services %}(schedule=[{"cron": "*/5 * * * *"}]){% endif %}
|
||||||
|
async def example() -> str:
|
||||||
|
logger.info("example task ran")
|
||||||
|
return "ok"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{%- set providers = [] -%}
|
||||||
|
{%- if database != 'none' %}{% set _ = providers.append('DbProvider()') %}{% endif -%}
|
||||||
|
{%- if dynamic_config %}{% set _ = providers.append('ConfigProvider()') %}{% endif -%}
|
||||||
|
{%- if database == 'postgres' %}{% set _ = providers.append('RepositoryProvider()') %}{% endif -%}
|
||||||
|
{%- if use_redis %}{% set _ = providers.append('RedisProvider()') %}{% endif -%}
|
||||||
|
{%- if use_llm %}{% set _ = providers.append('LlmProvider()') %}{% endif -%}
|
||||||
|
{%- if 'aiogram_bot' in backend_services %}{% set _ = providers.append('AiogramProvider()') %}{% endif -%}
|
||||||
|
{%- if 'api' in backend_services %}{% set _ = providers.append('FastapiProvider()') %}{% endif -%}
|
||||||
|
{%- set third = ['from dishka import make_async_container'] -%}
|
||||||
|
{%- if 'aiogram_bot' in backend_services %}{% set _ = third.append('from dishka.integrations.aiogram import AiogramProvider') %}{% endif -%}
|
||||||
|
{%- if 'api' in backend_services %}{% set _ = third.append('from dishka.integrations.fastapi import FastapiProvider') %}{% endif -%}
|
||||||
|
{%- set first = [] -%}
|
||||||
|
{%- if dynamic_config %}{% set _ = first.append('from dependencies.providers.config import ConfigProvider') %}{% endif -%}
|
||||||
|
{%- if database != 'none' %}{% set _ = first.append('from dependencies.providers.db import DbProvider') %}{% endif -%}
|
||||||
|
{%- if use_llm %}{% set _ = first.append('from dependencies.providers.llm import LlmProvider') %}{% endif -%}
|
||||||
|
{%- if use_redis %}{% set _ = first.append('from dependencies.providers.redis import RedisProvider') %}{% endif -%}
|
||||||
|
{%- if database == 'postgres' %}{% set _ = first.append('from dependencies.providers.repositories import RepositoryProvider') %}{% endif -%}
|
||||||
|
{%- set imports = (third | join('\n')) ~ (('\n\n' ~ (first | join('\n'))) if first else '') -%}
|
||||||
|
{%- set args = providers | join(', ') -%}
|
||||||
|
{%- set oneline = 'container = make_async_container(' ~ args ~ ')' -%}
|
||||||
|
{%- set hugged = 'container = make_async_container(\n ' ~ args ~ '\n)' -%}
|
||||||
|
{%- set indented = [] -%}
|
||||||
|
{%- for p in providers %}{% set _ = indented.append(' ' ~ p ~ ',') %}{% endfor -%}
|
||||||
|
{%- set exploded = 'container = make_async_container(\n' ~ (indented | join('\n')) ~ '\n)' -%}
|
||||||
|
{#- ruff-format order: one line, else all args on one continuation line, else one per line -#}
|
||||||
|
{%- set body = oneline if (oneline | length) <= 88 else (hugged if (args | length) + 4 <= 88 else exploded) -%}
|
||||||
|
{{ imports }}
|
||||||
|
|
||||||
|
{{ body }}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from . import asyncio
|
||||||
|
|
||||||
|
__all__ = ["asyncio"]
|
||||||
+25
@@ -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"]
|
||||||
+52
@@ -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 %}
|
||||||
+9
@@ -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)
|
||||||
+21
@@ -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 %}
|
||||||
+19
@@ -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)
|
||||||
+93
@@ -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 %}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
pass
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = src
|
||||||
|
path_separator = os
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
from utils.db import models # noqa: F401
|
||||||
|
from utils.env import env
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=env.db.connection_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
engine = create_async_engine(env.db.async_connection_url)
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[environment]
|
||||||
|
python = "backend/.venv"
|
||||||
|
|
||||||
|
[src]
|
||||||
|
exclude = ["backend/migrations"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ _copier_answers|to_nice_yaml -}}
|
||||||
Reference in New Issue
Block a user