refactor: replace universal_migration.py with Alembic

Remove the 7,791-line universal_migration.py and 16 incomplete individual
Alembic migrations. Replace with a single initial schema migration using
Base.metadata.create_all(checkfirst=True).

Changes:
- Add programmatic Alembic runner (app/database/migrations.py) with
  auto-stamp logic for existing databases transitioning from
  universal_migration
- Extract ensure_default_web_api_token() to web_api_token_service.py
- Extract sync_postgres_sequences() to database.py with SQL injection
  prevention via _quote_ident()
- Add HMAC token hashing support with backward-compatible dual-hash
  fallback and automatic rehashing
- Remove dead init_db() function and unused imports
- Add Makefile targets: migrate, migration, migrate-stamp, migrate-history
- Fix fileConfig() destroying structlog config (disable_existing_loggers)
- Remove duplicate migrations/alembic/alembic.ini with credentials
- Add script.py.mako template for future migration generation
- Update startup flow: alembic upgrade → sync sequences → ensure token
- Harden database.py: ParamSpec for retry decorator, safe URL logging,
  echo='debug' mode, execute_with_retry validation
- Update documentation references

31 files changed, 302 insertions(+), 9,226 deletions(-)
This commit is contained in:
Fringg
2026-02-18 08:10:20 +03:00
parent b4b10c998c
commit 784616b349
34 changed files with 443 additions and 9229 deletions
+16
View File
@@ -40,6 +40,22 @@ fix: ## Исправить код (ruff check --fix + format)
uv run ruff check . --fix
uv run ruff format .
.PHONY: migrate
migrate: ## Применить миграции (alembic upgrade head)
uv run alembic upgrade head
.PHONY: migration
migration: ## Создать миграцию (usage: make migration m="description")
uv run alembic revision --autogenerate -m "$(m)"
.PHONY: migrate-stamp
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
uv run alembic stamp head
.PHONY: migrate-history
migrate-history: ## Показать историю миграций
uv run alembic history --verbose
.PHONY: help
help: ## Показать список доступных команд
@echo ""
+1 -1
View File
@@ -2,7 +2,7 @@
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
+1
View File
@@ -673,6 +673,7 @@ class Settings(BaseSettings):
WEB_API_DEFAULT_TOKEN: str | None = None
WEB_API_DEFAULT_TOKEN_NAME: str = 'Bootstrap Token'
WEB_API_TOKEN_HASH_ALGORITHM: str = 'sha256'
WEB_API_TOKEN_HMAC_SECRET: str | None = None
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = 'app-config.json'
+2 -2
View File
@@ -8,7 +8,7 @@ from .database import (
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
sync_postgres_sequences,
)
@@ -20,5 +20,5 @@ __all__ = [
'get_db',
'get_db_read_only',
'get_pool_metrics',
'init_db',
'sync_postgres_sequences',
]
+126 -98
View File
@@ -1,24 +1,25 @@
import asyncio
import time
from collections.abc import AsyncGenerator, Callable
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from functools import wraps
from typing import TypeVar
from typing import ParamSpec, TypeVar
import structlog
from sqlalchemy import bindparam, event, inspect, text
from sqlalchemy import bindparam, event, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import InterfaceError, OperationalError, ProgrammingError
from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
from app.config import settings
from app.database.models import Base
logger = structlog.get_logger(__name__)
T = TypeVar('T')
P = ParamSpec('P')
R = TypeVar('R')
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
@@ -67,7 +68,7 @@ _pg_connect_args = {
engine = create_async_engine(
DATABASE_URL,
poolclass=poolclass,
echo=settings.DEBUG,
echo='debug' if settings.DEBUG else False,
future=True,
# Кеш скомпилированных запросов (правильное размещение)
query_cache_size=500,
@@ -103,7 +104,7 @@ def with_db_retry(
attempts: int = DEFAULT_RETRY_ATTEMPTS,
delay: float = DEFAULT_RETRY_DELAY,
backoff: float = 2.0,
) -> Callable:
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
"""
Декоратор для автоматического retry при сбоях подключения к БД.
@@ -113,10 +114,10 @@ def with_db_retry(
backoff: Множитель задержки для каждой следующей попытки
"""
def decorator(func: Callable) -> Callable:
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exception: Exception | None = None
current_delay = delay
for attempt in range(1, attempts + 1):
@@ -137,9 +138,9 @@ def with_db_retry(
else:
logger.error('Ошибка БД: все попыток исчерпаны. Последняя ошибка', attempts=attempts, e=str(e))
raise last_exception
raise last_exception # type: ignore[misc]
return wrapper
return wrapper # type: ignore[return-value]
return decorator
@@ -150,7 +151,10 @@ async def execute_with_retry(
attempts: int = DEFAULT_RETRY_ATTEMPTS,
):
"""Выполнение SQL с retry логикой."""
last_exception = None
if attempts < 1:
raise ValueError(f'attempts must be >= 1, got {attempts}')
last_exception: Exception | None = None
delay = DEFAULT_RETRY_DELAY
for attempt in range(1, attempts + 1):
@@ -163,7 +167,7 @@ async def execute_with_retry(
await asyncio.sleep(delay)
delay *= 2
raise last_exception
raise last_exception # type: ignore[misc]
# ============================================================================
@@ -201,7 +205,7 @@ def _validate_database_url(url: str | None) -> str | None:
return None
# Простая проверка на валидный формат
if not ('://' in url or url.startswith('sqlite')):
logger.warning('Невалидный DATABASE_URL', url=url[:20])
logger.warning('Невалидный DATABASE_URL (не содержит ://)')
return None
return url
@@ -234,7 +238,10 @@ class DatabaseManager:
expire_on_commit=False,
autoflush=False,
)
logger.info('Read replica настроена', replica_url=replica_url[:30] + '...')
from sqlalchemy.engine import make_url
safe_url = make_url(replica_url).render_as_string(hide_password=True)
logger.info('Read replica настроена', replica_url=safe_url)
except Exception as e:
logger.error('Не удалось настроить read replica', e=e)
self.read_replica_engine = None
@@ -402,88 +409,7 @@ batch_ops = BatchOperations()
# ============================================================================
async def init_db():
"""Инициализация БД с оптимизациями"""
logger.info('🚀 Создание таблиц базы данных...')
try:
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, checkfirst=True))
except (ProgrammingError, Exception) as e:
# Игнорируем ошибки дублирования индексов/таблиц - они уже существуют
# Это может произойти если таблицы были созданы вручную или через миграции
error_str = str(e).lower()
error_type = type(e).__name__.lower()
# Проверяем оригинальную ошибку для asyncpg
orig_error = getattr(e, 'orig', None)
if orig_error:
orig_type = type(orig_error).__name__.lower()
if 'duplicatetableerror' in orig_type or 'duplicatekeyerror' in orig_type:
logger.warning(
'⚠️ Некоторые индексы/таблицы уже существуют в БД, это нормально. Продолжаем инициализацию...'
)
return
# Проверяем, является ли это ошибкой дублирования
is_duplicate_error = (
'already exists' in error_str
or 'duplicate' in error_str
or 'duplicatetableerror' in error_type
or 'duplicatekeyerror' in error_type
)
if is_duplicate_error:
logger.warning(
'⚠️ Некоторые объекты БД уже существуют (таблицы/индексы), это нормально. Продолжаем инициализацию...'
)
# Продолжаем выполнение, так как основные таблицы могут быть созданы
else:
# Для других ошибок пробрасываем исключение
logger.error('❌ Ошибка при создании таблиц', error=e)
raise
if not IS_SQLITE:
logger.info('Создание индексов для оптимизации...')
async with engine.begin() as conn:
indexes = [
('users', 'CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)'),
(
'subscriptions',
'CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)',
),
(
'subscriptions',
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
),
(
'payments',
'CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)',
),
]
for table_name, index_sql in indexes:
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
if not table_exists:
logger.debug(
'Пропускаем создание индекса : таблица отсутствует', index_sql=index_sql, table_name=table_name
)
continue
try:
await conn.execute(text(index_sql))
except Exception as e:
logger.debug('Index creation skipped for', table_name=table_name, e=e)
logger.info('База данных успешно инициализирована')
health = await db_manager.health_check()
logger.info('Database health', health=health)
async def close_db():
async def close_db() -> None:
"""Корректное закрытие всех соединений"""
logger.info('Закрытие соединений с БД...')
@@ -495,6 +421,108 @@ async def close_db():
logger.info('Все подключения к базе данных закрыты')
# ============================================================================
# SEQUENCE SYNCHRONIZATION (after DB restores)
# ============================================================================
def _quote_ident(name: str) -> str:
"""Quote a PostgreSQL identifier to prevent SQL injection."""
return '"' + name.replace('"', '""') + '"'
async def sync_postgres_sequences() -> bool:
"""Ensure PostgreSQL sequences match the current max values after restores."""
if IS_SQLITE:
logger.debug('Пропускаем синхронизацию последовательностей: SQLite')
return True
try:
async with engine.begin() as conn:
result = await conn.execute(
text(
"""
SELECT
cols.table_schema,
cols.table_name,
cols.column_name,
pg_get_serial_sequence(
format('%I.%I', cols.table_schema, cols.table_name),
cols.column_name
) AS sequence_path
FROM information_schema.columns AS cols
WHERE cols.column_default LIKE 'nextval(%'
AND cols.table_schema NOT IN ('pg_catalog', 'information_schema')
"""
)
)
sequences = result.fetchall()
if not sequences:
logger.info('Не найдено последовательностей PostgreSQL для синхронизации')
return True
for table_schema, table_name, column_name, sequence_path in sequences:
if not sequence_path:
continue
q_col = _quote_ident(column_name)
q_schema = _quote_ident(table_schema)
q_table = _quote_ident(table_name)
max_result = await conn.execute(
text(f'SELECT COALESCE(MAX({q_col}), 0) FROM {q_schema}.{q_table}')
)
max_value = max_result.scalar() or 0
# pg_get_serial_sequence returns e.g. '"public"."users_id_seq"'.
# Split on '"."' to handle quoted identifiers that may contain dots.
if '"."' in sequence_path:
seq_schema, seq_name = sequence_path.split('"."', 1)
seq_schema = seq_schema.strip('"')
seq_name = seq_name.strip('"')
else:
parts = sequence_path.split('.')
if len(parts) == 2:
seq_schema, seq_name = parts
else:
seq_schema, seq_name = 'public', parts[-1]
q_seq_schema = _quote_ident(seq_schema)
q_seq_name = _quote_ident(seq_name)
current_result = await conn.execute(
text(f'SELECT last_value, is_called FROM {q_seq_schema}.{q_seq_name}')
)
current_row = current_result.fetchone()
if current_row:
current_last, is_called = current_row
current_next = current_last + 1 if is_called else current_last
if current_next > max_value:
continue
await conn.execute(
text(
"""
SELECT setval(:sequence_name, :new_value, TRUE)
"""
),
{'sequence_name': sequence_path, 'new_value': max_value},
)
logger.info(
'Последовательность синхронизирована',
sequence_path=sequence_path,
max_value=max_value,
next_id=max_value + 1,
)
return True
except Exception as error:
logger.error('Ошибка синхронизации последовательностей PostgreSQL', error=error)
return False
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
# ============================================================================
+72
View File
@@ -0,0 +1,72 @@
"""Programmatic Alembic migration runner for bot startup."""
from pathlib import Path
import structlog
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect
logger = structlog.get_logger(__name__)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_ALEMBIC_INI = _PROJECT_ROOT / 'alembic.ini'
def _get_alembic_config() -> Config:
"""Build Alembic Config pointing at the project root."""
from app.config import settings
cfg = Config(str(_ALEMBIC_INI))
cfg.set_main_option('sqlalchemy.url', settings.get_database_url())
return cfg
async def _needs_auto_stamp() -> bool:
"""Check if DB has existing tables but no alembic_version (transition from universal_migration)."""
from app.database.database import engine
async with engine.connect() as conn:
has_alembic = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('alembic_version'))
if has_alembic:
return False
has_users = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('users'))
return has_users
_INITIAL_REVISION = '0001'
async def run_alembic_upgrade() -> None:
"""Run ``alembic upgrade head``, auto-stamping existing databases first."""
import asyncio
if await _needs_auto_stamp():
logger.warning(
'Обнаружена существующая БД без alembic_version — '
'автоматический stamp 0001 (переход с universal_migration)'
)
await _stamp_alembic_revision(_INITIAL_REVISION)
cfg = _get_alembic_config()
loop = asyncio.get_running_loop()
# run_in_executor offloads to a thread where env.py can safely
# call asyncio.run() to create its own event loop.
await loop.run_in_executor(None, command.upgrade, cfg, 'head')
logger.info('Alembic миграции применены')
async def stamp_alembic_head() -> None:
"""Stamp the DB as being at head without running migrations (for existing DBs)."""
await _stamp_alembic_revision('head')
async def _stamp_alembic_revision(revision: str) -> None:
"""Stamp the DB at a specific revision without running migrations."""
import asyncio
cfg = _get_alembic_config()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, command.stamp, cfg, revision)
logger.info('Alembic: база отмечена как актуальная', revision=revision)
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -20,7 +20,7 @@ from app.database.crud.system_setting import (
)
from app.database.database import AsyncSessionLocal
from app.database.models import SystemSetting
from app.database.universal_migration import ensure_default_web_api_token
from app.services.web_api_token_service import ensure_default_web_api_token
logger = structlog.get_logger(__name__)
+101 -8
View File
@@ -3,24 +3,121 @@ from __future__ import annotations
import secrets
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud import web_api_token as crud
from app.database.models import WebApiToken
from app.database.universal_migration import ensure_default_web_api_token
from app.utils.security import generate_api_token, hash_api_token
logger = structlog.get_logger(__name__)
async def ensure_default_web_api_token() -> bool:
"""Ensure the bootstrap web API token from config exists in the DB."""
from app.database.database import AsyncSessionLocal
default_token = (settings.WEB_API_DEFAULT_TOKEN or '').strip()
if not default_token:
return True
token_name = (settings.WEB_API_DEFAULT_TOKEN_NAME or 'Bootstrap Token').strip()
try:
async with AsyncSessionLocal() as session:
algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM
hmac_secret = settings.WEB_API_TOKEN_HMAC_SECRET
token_hash = hash_api_token(default_token, algorithm, hmac_secret=hmac_secret)
result = await session.execute(select(WebApiToken).where(WebApiToken.token_hash == token_hash))
existing = result.scalar_one_or_none()
# Fallback: if HMAC enabled, try plain hash (legacy token) and rehash
if not existing and hmac_secret:
plain_hash = hash_api_token(default_token, algorithm)
result = await session.execute(select(WebApiToken).where(WebApiToken.token_hash == plain_hash))
existing = result.scalar_one_or_none()
if existing:
existing.token_hash = token_hash
existing.updated_at = datetime.now(UTC)
await session.commit()
logger.info('Дефолтный токен перехеширован на HMAC')
return True
if existing:
updated = False
if not existing.is_active:
existing.is_active = True
updated = True
if token_name and existing.name != token_name:
existing.name = token_name
updated = True
if updated:
existing.updated_at = datetime.now(UTC)
await session.commit()
return True
token = WebApiToken(
name=token_name or 'Bootstrap Token',
token_hash=token_hash,
token_prefix=default_token[:8],
description='Автоматически создан при миграции',
created_by='migration',
is_active=True,
)
session.add(token)
await session.commit()
logger.info('Создан дефолтный токен веб-API из конфигурации')
return True
except Exception as error:
logger.error('Ошибка создания дефолтного веб-API токена', error=error)
return False
class WebApiTokenService:
"""Сервис для управления токенами административного веб-API."""
def __init__(self):
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or 'sha256'
self.hmac_secret = settings.WEB_API_TOKEN_HMAC_SECRET
def hash_token(self, token: str) -> str:
return hash_api_token(token, self.algorithm, hmac_secret=self.hmac_secret) # type: ignore[arg-type]
def _hash_token_plain(self, token: str) -> str:
"""Hash without HMAC (for legacy fallback)."""
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
async def _load_token_with_fallback(
self, db: AsyncSession, value: str
) -> WebApiToken | None:
"""Load token by hash, falling back to plain hash if HMAC is enabled.
When HMAC is newly enabled, existing tokens are stored with plain
hashes. This method tries HMAC first, then falls back to plain hash
and auto-rehashes the token for future lookups.
"""
token_hash = self.hash_token(value)
token = await crud.get_token_by_hash(db, token_hash)
if not token and self.hmac_secret:
plain_hash = self._hash_token_plain(value)
token = await crud.get_token_by_hash(db, plain_hash)
if token:
token.token_hash = token_hash
token.updated_at = datetime.now(UTC)
await db.flush()
logger.info('Токен автоматически перехеширован на HMAC', token_id=token.id)
return token
async def authenticate(
self,
db: AsyncSession,
@@ -32,17 +129,13 @@ class WebApiTokenService:
if not normalized_value:
return None
async def _load_token(value: str) -> WebApiToken | None:
token_hash = self.hash_token(value)
return await crud.get_token_by_hash(db, token_hash)
token = await _load_token(normalized_value)
token = await self._load_token_with_fallback(db, normalized_value)
if not token:
default_token = (settings.WEB_API_DEFAULT_TOKEN or '').strip()
if default_token and secrets.compare_digest(default_token, normalized_value):
await ensure_default_web_api_token()
token = await _load_token(default_token)
token = await self._load_token_with_fallback(db, default_token)
if not token or not token.is_active:
return None
@@ -73,7 +166,7 @@ class WebApiTokenService:
db,
name=name,
token_hash=token_hash,
token_prefix=plain_token[:12],
token_prefix=plain_token[:8],
description=description,
expires_at=expires_at,
created_by=created_by,
+19 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from typing import Literal
@@ -10,14 +11,29 @@ from typing import Literal
HashAlgorithm = Literal['sha256', 'sha384', 'sha512']
def hash_api_token(token: str, algorithm: HashAlgorithm = 'sha256') -> str:
"""Возвращает хеш токена в формате hex."""
def hash_api_token(
token: str,
algorithm: HashAlgorithm = 'sha256',
*,
hmac_secret: str | None = None,
) -> str:
"""Возвращает хеш токена в формате hex.
If ``hmac_secret`` is provided, uses HMAC with the given secret key
(recommended for production). Otherwise falls back to plain hash
(backward-compatible).
"""
normalized = (algorithm or 'sha256').lower()
if normalized not in {'sha256', 'sha384', 'sha512'}:
raise ValueError(f'Unsupported hash algorithm: {algorithm}')
token_bytes = token.encode('utf-8')
if hmac_secret:
return hmac.new(hmac_secret.encode('utf-8'), token_bytes, normalized).hexdigest()
digest = getattr(hashlib, normalized)
return digest(token.encode('utf-8')).hexdigest()
return digest(token_bytes).hexdigest()
def generate_api_token(length: int = 48) -> str:
+2 -2
View File
@@ -62,9 +62,9 @@
- `app/database/models.py` — Python-модуль
Классы: `UserStatus`, `SubscriptionStatus`, `TransactionType`, `PromoCodeType`, `PaymentMethod`, `MainMenuButtonActionType`, `MainMenuButtonVisibility`, `YooKassaPayment` (6 методов), `CryptoBotPayment` (5 методов), `MulenPayPayment` (2 методов), `Pal24Payment` (3 методов), `PromoGroup` (3 методов), `User` (5 методов), `Subscription` (11 методов), `Transaction` (1 методов), `SubscriptionConversion` (2 методов), `PromoCode` (2 методов), `PromoCodeUse`, `ReferralEarning` (1 методов), `Squad` (1 методов), `ServiceRule`, `PrivacyPolicy`, `PublicOffer`, `FaqSetting`, `FaqPage`, `SystemSetting`, `MonitoringLog`, `SentNotification`, `DiscountOffer`, `PromoOfferTemplate`, `SubscriptionTemporaryAccess`, `PromoOfferLog`, `BroadcastHistory`, `ServerSquad` (3 методов), `SubscriptionServer`, `SupportAuditLog`, `UserMessage` (1 методов), `WelcomeText`, `AdvertisingCampaign` (2 методов), `AdvertisingCampaignRegistration` (1 методов), `TicketStatus`, `Ticket` (8 методов), `TicketMessage` (3 методов), `WebApiToken` (1 методов), `MainMenuButton` (3 методов)
Функции: нет
- `app/database/universal_migration.py` — Python-модуль
- `app/database/migrations.py` — Programmatic Alembic migration runner
Классы: нет
Функции: нет
Функции: `run_alembic_upgrade`, `stamp_alembic_head`
#### app/database/crud
+4 -5
View File
@@ -41,13 +41,12 @@ API разворачивается вместе с ботом, использу
## 3. Подготовка базы данных
1. Убедитесь, что настройки БД верны (`DATABASE_URL` или параметры PostgreSQL/SQLite).
2. При старте бота автоматически запускается универсальная миграция `run_universal_migration`, которая:
- создаёт таблицу `web_api_tokens`, если её нет;
- активирует токен из `WEB_API_DEFAULT_TOKEN`, если он задан.
3. Если нужно запустить миграцию вручную, выполните:
2. При старте бота автоматически запускаются Alembic-миграции (`alembic upgrade head`), которые создают все необходимые таблицы, включая `web_api_tokens`.
3. Токен из `WEB_API_DEFAULT_TOKEN` активируется автоматически при запуске.
4. Если нужно запустить миграцию вручную, выполните:
```bash
python -c "import asyncio; from app.database.universal_migration import run_universal_migration; asyncio.run(run_universal_migration())"
make migrate # или: uv run alembic upgrade head
```
Или просто запустите `python main.py` — бот выполнит ту же процедуру автоматически.
+23 -21
View File
@@ -12,9 +12,9 @@ sys.path.append(str(Path(__file__).parent))
from app.bot import setup_bot
from app.config import settings
from app.database.database import init_db
from app.database.database import sync_postgres_sequences
from app.database.migrations import run_alembic_upgrade
from app.database.models import PaymentMethod
from app.database.universal_migration import run_universal_migration
from app.localization.loader import ensure_locale_templates
from app.logging_config import setup_logging
from app.services.backup_service import backup_service
@@ -41,6 +41,7 @@ from app.services.reporting_service import reporting_service
from app.services.system_settings_service import bot_configuration_service
from app.services.traffic_monitoring_service import traffic_monitoring_scheduler
from app.services.version_service import version_service
from app.services.web_api_token_service import ensure_default_web_api_token
from app.utils.log_handlers import ExcludePaymentFilter, LevelFilterHandler
from app.utils.payment_logger import configure_payment_logger
from app.utils.startup_timeline import StartupTimeline
@@ -179,42 +180,43 @@ async def main():
summary_logged = False
try:
async with timeline.stage('Инициализация базы данных', '🗄️', success_message='База данных готова'):
await init_db()
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
if not skip_migration:
async with timeline.stage(
'Проверка и миграция базы данных',
'Миграция базы данных (Alembic)',
'🧬',
success_message='Миграция завершена успешно',
) as stage:
try:
migration_log = logging.getLogger('app.database.universal_migration')
original_level = migration_log.level
migration_log.setLevel(logging.WARNING)
try:
migration_success = await run_universal_migration()
finally:
migration_log.setLevel(original_level)
if migration_success:
await run_alembic_upgrade()
stage.success('Миграция завершена успешно')
else:
stage.warning('Миграция завершилась с предупреждениями, запуск продолжится')
logger.warning('⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск')
except Exception as migration_error:
stage.warning(f'Ошибка выполнения миграции: {migration_error}')
logger.error('Ошибка выполнения миграции', migration_error=migration_error)
logger.warning('⚠️ Продолжаем запуск без миграции')
allow_failure = os.getenv('ALLOW_MIGRATION_FAILURE', 'false').lower() == 'true'
logger.error('Ошибка выполнения миграции', migration_error=migration_error)
if not allow_failure:
raise
stage.warning(f'Ошибка миграции: {migration_error} (ALLOW_MIGRATION_FAILURE=true)')
else:
timeline.add_manual_step(
'Проверка и миграция базы данных',
'Миграция базы данных (Alembic)',
'⏭️',
'Пропущено',
'SKIP_MIGRATION=true',
)
async with timeline.stage(
'Инициализация базы данных',
'🗄️',
success_message='База данных готова',
) as stage:
seq_ok = await sync_postgres_sequences()
token_ok = await ensure_default_web_api_token()
if not seq_ok:
stage.warning('Не удалось синхронизировать последовательности PostgreSQL')
if not token_ok:
stage.warning('Не удалось создать/проверить дефолтный веб-API токен')
async with timeline.stage(
'Синхронизация тарифов из конфига',
'💰',
-41
View File
@@ -1,41 +0,0 @@
[alembic]
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
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
+8 -2
View File
@@ -17,11 +17,13 @@ from app.config import settings
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
fileConfig(config.config_file_name, disable_existing_loggers=False)
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# URL also set in app/database/migrations.py for programmatic usage;
# this line is needed for CLI invocation (make migrate, make migration).
config.set_main_option("sqlalchemy.url", settings.get_database_url())
def run_migrations_offline() -> None:
@@ -58,6 +60,10 @@ async def run_async_migrations() -> None:
def run_migrations_online() -> None:
# asyncio.run() is safe here: when called programmatically via
# run_alembic_upgrade(), this runs inside run_in_executor() which
# creates a separate thread with no event loop, so asyncio.run()
# can create a fresh loop without conflict.
asyncio.run(run_async_migrations())
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[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,40 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-02-18
Creates all tables from SQLAlchemy models via metadata.create_all.
For existing databases, use ``alembic stamp head`` to mark as current.
NOTE: This migration uses create_all(checkfirst=True) which is coupled to
the current state of models.py. Future migrations MUST use explicit
op.create_table() / op.add_column() calls. If you need to bootstrap a
fresh database AND have later migrations, run this migration first,
then apply subsequent migrations normally checkfirst=True prevents
duplicate table errors.
"""
from typing import Sequence, Union
from alembic import op
from app.database.models import Base
# revision identifiers, used by Alembic.
revision: str = '0001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
Base.metadata.create_all(bind=bind, checkfirst=True)
def downgrade() -> None:
raise NotImplementedError(
'Downgrading the initial schema is not supported. '
'Restore from a database backup instead.'
)
@@ -1,57 +0,0 @@
"""add pinned start mode and user last pin
Revision ID: 1b2e3d4f5a6b
Revises: 7a3c0b8f5b84
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1b2e3d4f5a6b'
down_revision = '7a3c0b8f5b84'
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
columns = {col["name"] for col in inspector.get_columns(table_name)}
return column_name in columns
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "pinned_messages"):
if not _column_exists(inspector, "pinned_messages", "send_on_every_start"):
op.add_column(
'pinned_messages',
sa.Column('send_on_every_start', sa.Boolean(), nullable=False, server_default='1'),
)
if _table_exists(inspector, "users"):
if not _column_exists(inspector, "users", "last_pinned_message_id"):
op.add_column(
'users',
sa.Column('last_pinned_message_id', sa.Integer(), nullable=True),
)
def downgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if _column_exists(inspector, "users", "last_pinned_message_id"):
op.drop_column('users', 'last_pinned_message_id')
if _column_exists(inspector, "pinned_messages", "send_on_every_start"):
op.drop_column('pinned_messages', 'send_on_every_start')
@@ -1,224 +0,0 @@
"""add promo groups table and link users"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
PROMO_GROUPS_TABLE = "promo_groups"
USERS_TABLE = "users"
PROMO_GROUP_COLUMN = "promo_group_id"
PROMO_GROUP_INDEX = "ix_users_promo_group_id"
PROMO_GROUP_FK = "fk_users_promo_group_id_promo_groups"
DEFAULT_PROMO_GROUP_NAME = "Базовый юзер"
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
def _foreign_key_exists(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool:
return any(fk["name"] == fk_name for fk in inspector.get_foreign_keys(table_name))
revision: str = "1f5f3a3f5a4d"
down_revision: Union[str, None] = "cbd1be472f3d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, PROMO_GROUPS_TABLE):
op.create_table(
PROMO_GROUPS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column(
"server_discount_percent",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"traffic_discount_percent",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"device_discount_percent",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"is_default",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("name", name="uq_promo_groups_name"),
)
inspector = sa.inspect(bind)
if not _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
op.add_column(
USERS_TABLE,
sa.Column(PROMO_GROUP_COLUMN, sa.Integer(), nullable=True),
)
inspector = sa.inspect(bind)
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
if not _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
op.create_index(PROMO_GROUP_INDEX, USERS_TABLE, [PROMO_GROUP_COLUMN])
inspector = sa.inspect(bind)
if not _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
op.create_foreign_key(
PROMO_GROUP_FK,
USERS_TABLE,
PROMO_GROUPS_TABLE,
[PROMO_GROUP_COLUMN],
["id"],
ondelete="RESTRICT",
)
inspector = sa.inspect(bind)
if not _table_exists(inspector, PROMO_GROUPS_TABLE) or not _column_exists(
inspector, USERS_TABLE, PROMO_GROUP_COLUMN
):
return
promo_groups_table = sa.table(
PROMO_GROUPS_TABLE,
sa.column("id", sa.Integer()),
sa.column("name", sa.String()),
sa.column("server_discount_percent", sa.Integer()),
sa.column("traffic_discount_percent", sa.Integer()),
sa.column("device_discount_percent", sa.Integer()),
sa.column("is_default", sa.Boolean()),
)
connection = bind
existing_named_group = (
connection.execute(
sa.select(
promo_groups_table.c.id,
promo_groups_table.c.is_default,
)
.where(promo_groups_table.c.name == DEFAULT_PROMO_GROUP_NAME)
.limit(1)
)
.mappings()
.first()
)
if existing_named_group:
default_group_id = existing_named_group["id"]
if not existing_named_group["is_default"]:
connection.execute(
sa.update(promo_groups_table)
.where(promo_groups_table.c.id == default_group_id)
.values(is_default=True)
)
else:
default_group_id = connection.execute(
sa.select(promo_groups_table.c.id)
.where(promo_groups_table.c.is_default.is_(True))
.limit(1)
).scalar_one_or_none()
if default_group_id is None:
default_group_id = connection.execute(
sa.insert(promo_groups_table)
.values(
name=DEFAULT_PROMO_GROUP_NAME,
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
)
.returning(promo_groups_table.c.id)
).scalar_one()
users_table = sa.table(
USERS_TABLE,
sa.column("promo_group_id", sa.Integer()),
)
connection.execute(
sa.update(users_table)
.where(users_table.c.promo_group_id.is_(None))
.values(promo_group_id=default_group_id)
)
inspector = sa.inspect(bind)
column_info = next(
(col for col in inspector.get_columns(USERS_TABLE) if col["name"] == PROMO_GROUP_COLUMN),
None,
)
if column_info and column_info.get("nullable", True):
op.alter_column(
USERS_TABLE,
PROMO_GROUP_COLUMN,
existing_type=sa.Integer(),
nullable=False,
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
column_info = next(
(
col
for col in inspector.get_columns(USERS_TABLE)
if col["name"] == PROMO_GROUP_COLUMN
),
None,
)
if column_info and not column_info.get("nullable", False):
op.alter_column(
USERS_TABLE,
PROMO_GROUP_COLUMN,
existing_type=sa.Integer(),
nullable=True,
)
inspector = sa.inspect(bind)
if _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
op.drop_constraint(PROMO_GROUP_FK, USERS_TABLE, type_="foreignkey")
inspector = sa.inspect(bind)
if _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
op.drop_index(PROMO_GROUP_INDEX, table_name=USERS_TABLE)
op.drop_column(USERS_TABLE, PROMO_GROUP_COLUMN)
inspector = sa.inspect(bind)
if _table_exists(inspector, PROMO_GROUPS_TABLE):
op.drop_table(PROMO_GROUPS_TABLE)
@@ -1,95 +0,0 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "2b3c1d4e5f6a"
down_revision: Union[str, None] = "9f0f2d5a1c7b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"platega_payments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("platega_transaction_id", sa.String(length=255), nullable=True, unique=True),
sa.Column("correlation_id", sa.String(length=64), nullable=False, unique=True),
sa.Column("amount_kopeks", sa.Integer(), nullable=False),
sa.Column(
"currency",
sa.String(length=10),
nullable=False,
server_default="RUB",
),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("payment_method_code", sa.Integer(), nullable=False),
sa.Column(
"status",
sa.String(length=50),
nullable=False,
server_default="PENDING",
),
sa.Column(
"is_paid",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column("redirect_url", sa.Text(), nullable=True),
sa.Column("return_url", sa.Text(), nullable=True),
sa.Column("failed_url", sa.Text(), nullable=True),
sa.Column("payload", sa.String(length=255), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("callback_payload", sa.JSON(), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("transaction_id", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["transaction_id"], ["transactions.id"], ondelete="SET NULL"),
)
op.create_index("ix_platega_payments_id", "platega_payments", ["id"])
op.create_index("ix_platega_payments_user_id", "platega_payments", ["user_id"])
op.create_index(
"ix_platega_payments_platega_transaction_id",
"platega_payments",
["platega_transaction_id"],
)
op.create_index(
"ix_platega_payments_correlation_id",
"platega_payments",
["correlation_id"],
unique=True,
)
op.create_index(
"ix_platega_payments_transaction_id",
"platega_payments",
["transaction_id"],
)
def downgrade() -> None:
op.drop_index("ix_platega_payments_transaction_id", table_name="platega_payments")
op.drop_index("ix_platega_payments_correlation_id", table_name="platega_payments")
op.drop_index(
"ix_platega_payments_platega_transaction_id",
table_name="platega_payments",
)
op.drop_index("ix_platega_payments_user_id", table_name="platega_payments")
op.drop_index("ix_platega_payments_id", table_name="platega_payments")
op.drop_table("platega_payments")
@@ -1,29 +0,0 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "4b6b0f58c8f9"
down_revision: Union[str, None] = "1f5f3a3f5a4d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
dialect = bind.dialect.name if bind else ""
op.add_column(
"promo_groups",
sa.Column("period_discounts", sa.JSON(), nullable=True),
)
if dialect == "postgresql":
op.execute("UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL")
else:
op.execute("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL")
def downgrade() -> None:
op.drop_column("promo_groups", "period_discounts")
@@ -1,146 +0,0 @@
"""add advertising campaigns tables"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
CAMPAIGNS_TABLE = "advertising_campaigns"
CAMPAIGNS_START_INDEX = "ix_advertising_campaigns_start_parameter"
CAMPAIGNS_ID_INDEX = "ix_advertising_campaigns_id"
REGISTRATIONS_TABLE = "advertising_campaign_registrations"
REGISTRATIONS_ID_INDEX = "ix_advertising_campaign_registrations_id"
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
revision: str = "5d1f1f8b2e9a"
down_revision: Union[str, None] = "cbd1be472f3d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, CAMPAIGNS_TABLE):
op.create_table(
CAMPAIGNS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("start_parameter", sa.String(length=64), nullable=False),
sa.Column("bonus_type", sa.String(length=20), nullable=False),
sa.Column(
"balance_bonus_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True),
sa.Column("subscription_device_limit", sa.Integer(), nullable=True),
sa.Column("subscription_squads", sa.JSON(), nullable=True),
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
)
inspector = sa.inspect(bind)
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
op.create_index(
CAMPAIGNS_START_INDEX,
CAMPAIGNS_TABLE,
["start_parameter"],
unique=True,
)
inspector = sa.inspect(bind)
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
op.create_index(CAMPAIGNS_ID_INDEX, CAMPAIGNS_TABLE, ["id"])
inspector = sa.inspect(bind)
if not _table_exists(inspector, REGISTRATIONS_TABLE):
op.create_table(
REGISTRATIONS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("campaign_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("bonus_type", sa.String(length=20), nullable=False),
sa.Column(
"balance_bonus_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["campaign_id"],
[f"{CAMPAIGNS_TABLE}.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
)
inspector = sa.inspect(bind)
if not _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
op.create_index(
REGISTRATIONS_ID_INDEX,
REGISTRATIONS_TABLE,
["id"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
op.drop_index(REGISTRATIONS_ID_INDEX, table_name=REGISTRATIONS_TABLE)
inspector = sa.inspect(bind)
if _table_exists(inspector, REGISTRATIONS_TABLE):
op.drop_table(REGISTRATIONS_TABLE)
inspector = sa.inspect(bind)
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
op.drop_index(CAMPAIGNS_ID_INDEX, table_name=CAMPAIGNS_TABLE)
inspector = sa.inspect(bind)
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
op.drop_index(CAMPAIGNS_START_INDEX, table_name=CAMPAIGNS_TABLE)
inspector = sa.inspect(bind)
if _table_exists(inspector, CAMPAIGNS_TABLE):
op.drop_table(CAMPAIGNS_TABLE)
@@ -1,75 +0,0 @@
"""add media fields to pinned messages"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "5f2a3e099427"
down_revision: Union[str, None] = "c9c71d04f0a1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TABLE_NAME = "pinned_messages"
def _table_exists(inspector: sa.Inspector) -> bool:
return TABLE_NAME in inspector.get_table_names()
def _column_missing(inspector: sa.Inspector, column_name: str) -> bool:
columns = {column.get("name") for column in inspector.get_columns(TABLE_NAME)}
return column_name not in columns
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector):
return
if _column_missing(inspector, "media_type"):
op.add_column(
TABLE_NAME,
sa.Column("media_type", sa.String(length=32), nullable=True),
)
if _column_missing(inspector, "media_file_id"):
op.add_column(
TABLE_NAME,
sa.Column("media_file_id", sa.String(length=255), nullable=True),
)
# Ensure content has a default value for media-only messages
op.alter_column(
TABLE_NAME,
"content",
existing_type=sa.Text(),
nullable=False,
server_default="",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector):
return
if not _column_missing(inspector, "media_type"):
op.drop_column(TABLE_NAME, "media_type")
if not _column_missing(inspector, "media_file_id"):
op.drop_column(TABLE_NAME, "media_file_id")
op.alter_column(
TABLE_NAME,
"content",
existing_type=sa.Text(),
nullable=False,
server_default=None,
)
@@ -1,59 +0,0 @@
"""add send_before_menu to pinned messages
Revision ID: 7a3c0b8f5b84
Revises: 5f2a3e099427
Create Date: 2025-02-05 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "7a3c0b8f5b84"
down_revision = "5f2a3e099427"
branch_labels = None
depends_on = None
TABLE_NAME = "pinned_messages"
def _table_exists(inspector: sa.Inspector) -> bool:
return TABLE_NAME in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, column_name: str) -> bool:
if not _table_exists(inspector):
return False
columns = {col["name"] for col in inspector.get_columns(TABLE_NAME)}
return column_name in columns
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector):
return
if _column_exists(inspector, "send_before_menu"):
return
op.add_column(
TABLE_NAME,
sa.Column(
"send_before_menu",
sa.Boolean(),
nullable=False,
server_default=sa.text("1"),
),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _column_exists(inspector, "send_before_menu"):
op.drop_column(TABLE_NAME, "send_before_menu")
@@ -1,58 +0,0 @@
"""add sent notifications table"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
revision: str = '8fd1e338eb45'
down_revision: Union[str, None] = '3d9b35c6bd8f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TABLE_NAME = 'sent_notifications'
UNIQUE_CONSTRAINT_NAME = 'uq_sent_notifications'
UNIQUE_CONSTRAINT_COLUMNS = ['user_id', 'subscription_id', 'notification_type', 'days_before']
def _table_exists(inspector: Inspector) -> bool:
return TABLE_NAME in inspector.get_table_names()
def _unique_constraint_exists(inspector: Inspector) -> bool:
existing_constraints = {
constraint['name'] for constraint in inspector.get_unique_constraints(TABLE_NAME)
}
return UNIQUE_CONSTRAINT_NAME in existing_constraints
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector):
op.create_table(
TABLE_NAME,
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
sa.Column('notification_type', sa.String(length=50), nullable=False),
sa.Column('days_before', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.UniqueConstraint(*UNIQUE_CONSTRAINT_COLUMNS, name=UNIQUE_CONSTRAINT_NAME),
)
elif not _unique_constraint_exists(inspector):
op.create_unique_constraint(
UNIQUE_CONSTRAINT_NAME, TABLE_NAME, UNIQUE_CONSTRAINT_COLUMNS
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector):
op.drop_table(TABLE_NAME)
@@ -1,155 +0,0 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "9f0f2d5a1c7b"
down_revision: Union[str, None] = "8fd1e338eb45"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"polls",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"reward_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"reward_amount_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
)
op.create_index("ix_polls_id", "polls", ["id"])
op.create_table(
"poll_questions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("poll_id", sa.Integer(), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column(
"order",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
)
op.create_index("ix_poll_questions_id", "poll_questions", ["id"])
op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"])
op.create_table(
"poll_options",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("question_id", sa.Integer(), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column(
"order",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
)
op.create_index("ix_poll_options_id", "poll_options", ["id"])
op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"])
op.create_table(
"poll_responses",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("poll_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"sent_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column(
"reward_given",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"reward_amount_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
)
op.create_index("ix_poll_responses_id", "poll_responses", ["id"])
op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"])
op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"])
op.create_table(
"poll_answers",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("response_id", sa.Integer(), nullable=False),
sa.Column("question_id", sa.Integer(), nullable=False),
sa.Column("option_id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"),
sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
)
op.create_index("ix_poll_answers_id", "poll_answers", ["id"])
op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"])
op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"])
def downgrade() -> None:
op.drop_index("ix_poll_answers_question_id", table_name="poll_answers")
op.drop_index("ix_poll_answers_response_id", table_name="poll_answers")
op.drop_index("ix_poll_answers_id", table_name="poll_answers")
op.drop_table("poll_answers")
op.drop_index("ix_poll_responses_user_id", table_name="poll_responses")
op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses")
op.drop_index("ix_poll_responses_id", table_name="poll_responses")
op.drop_table("poll_responses")
op.drop_index("ix_poll_options_question_id", table_name="poll_options")
op.drop_index("ix_poll_options_id", table_name="poll_options")
op.drop_table("poll_options")
op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions")
op.drop_index("ix_poll_questions_id", table_name="poll_questions")
op.drop_table("poll_questions")
op.drop_index("ix_polls_id", table_name="polls")
op.drop_table("polls")
@@ -1,28 +0,0 @@
"""add purchased_traffic_gb to subscriptions
Revision ID: a1b2c3d4e5f6
Revises: f4a5b6c7d8e9
Create Date: 2024-12-25 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = 'f4a5b6c7d8e9'
branch_labels = None
depends_on = None
def upgrade():
# Добавляем колонку purchased_traffic_gb для отслеживания докупленного трафика
op.add_column('subscriptions', sa.Column('purchased_traffic_gb', sa.Integer(), nullable=True, server_default='0'))
# Устанавливаем NOT NULL после добавления значения по умолчанию
op.alter_column('subscriptions', 'purchased_traffic_gb', nullable=False, server_default=None)
def downgrade():
op.drop_column('subscriptions', 'purchased_traffic_gb')
@@ -1,100 +0,0 @@
"""Add webhooks and webhook_deliveries tables"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "e3c1e0b5b4a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
WEBHOOKS_TABLE = "webhooks"
DELIVERIES_TABLE = "webhook_deliveries"
def _table_exists(inspector: Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# Создаем таблицу webhooks
if not _table_exists(inspector, WEBHOOKS_TABLE):
op.create_table(
WEBHOOKS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("secret", sa.String(length=128), nullable=True),
sa.Column("event_type", sa.String(length=50), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_triggered_at", sa.DateTime(), nullable=True),
sa.Column("failure_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("success_count", sa.Integer(), nullable=False, server_default="0"),
)
op.create_index("ix_webhooks_event_type", WEBHOOKS_TABLE, ["event_type"])
op.create_index("ix_webhooks_is_active", WEBHOOKS_TABLE, ["is_active"])
# Создаем таблицу webhook_deliveries
if not _table_exists(inspector, DELIVERIES_TABLE):
op.create_table(
DELIVERIES_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"webhook_id",
sa.Integer(),
sa.ForeignKey("webhooks.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("event_type", sa.String(length=50), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("response_status", sa.Integer(), nullable=True),
sa.Column("response_body", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("attempt_number", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("delivered_at", sa.DateTime(), nullable=True),
sa.Column("next_retry_at", sa.DateTime(), nullable=True),
)
op.create_index(
"ix_webhook_deliveries_webhook_created",
DELIVERIES_TABLE,
["webhook_id", "created_at"],
)
op.create_index("ix_webhook_deliveries_status", DELIVERIES_TABLE, ["status"])
op.create_index("ix_webhook_deliveries_webhook_id", DELIVERIES_TABLE, ["webhook_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# Удаляем таблицу webhook_deliveries
if _table_exists(inspector, DELIVERIES_TABLE):
op.drop_index("ix_webhook_deliveries_webhook_id", table_name=DELIVERIES_TABLE)
op.drop_index("ix_webhook_deliveries_status", table_name=DELIVERIES_TABLE)
op.drop_index(
"ix_webhook_deliveries_webhook_created",
table_name=DELIVERIES_TABLE,
)
op.drop_table(DELIVERIES_TABLE)
# Удаляем таблицу webhooks
if _table_exists(inspector, WEBHOOKS_TABLE):
op.drop_index("ix_webhooks_is_active", table_name=WEBHOOKS_TABLE)
op.drop_index("ix_webhooks_event_type", table_name=WEBHOOKS_TABLE)
op.drop_table(WEBHOOKS_TABLE)
@@ -1,77 +0,0 @@
"""Add subscription_events table"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
revision: str = "c2f9c3b5f5c4"
down_revision: Union[str, None] = "9f0f2d5a1c7b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TABLE_NAME = "subscription_events"
def _table_exists(inspector: Inspector) -> bool:
return TABLE_NAME in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector):
return
op.create_table(
TABLE_NAME,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("event_type", sa.String(length=50), nullable=False),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"subscription_id",
sa.Integer(),
sa.ForeignKey("subscriptions.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"transaction_id",
sa.Integer(),
sa.ForeignKey("transactions.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("amount_kopeks", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=16), nullable=True),
sa.Column("message", sa.Text(), nullable=True),
sa.Column(
"occurred_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
),
sa.Column("extra", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index(
"ix_subscription_events_event_type", TABLE_NAME, ["event_type"]
)
op.create_index("ix_subscription_events_user_id", TABLE_NAME, ["user_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector):
return
op.drop_index("ix_subscription_events_user_id", table_name=TABLE_NAME)
op.drop_index("ix_subscription_events_event_type", table_name=TABLE_NAME)
op.drop_table(TABLE_NAME)
@@ -1,45 +0,0 @@
"""add pinned messages table"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c9c71d04f0a1"
down_revision: Union[str, None] = "e3c1e0b5b4a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TABLE_NAME = "pinned_messages"
def _table_exists(inspector: sa.Inspector) -> bool:
return TABLE_NAME in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector):
return
op.create_table(
TABLE_NAME,
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), default=True),
sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector):
op.drop_table(TABLE_NAME)
@@ -1,19 +0,0 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e3c1e0b5b4a7"
down_revision: Union[str, None] = "c2f9c3b5f5c4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("referral_commission_percent", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "referral_commission_percent")
@@ -1,19 +0,0 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f4a5b6c7d8e9"
down_revision: Union[str, None] = "e3c1e0b5b4a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("subscriptions", sa.Column("modem_enabled", sa.Boolean(), nullable=True, server_default="false"))
def downgrade() -> None:
op.drop_column("subscriptions", "modem_enabled")
@@ -1,45 +0,0 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'g5b6c7d8e9f0'
down_revision: Union[str, None] = 'f4a5b6c7d8e9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('google_id', sa.String(255), nullable=True))
op.add_column('users', sa.Column('yandex_id', sa.String(255), nullable=True))
op.add_column('users', sa.Column('discord_id', sa.String(255), nullable=True))
op.add_column('users', sa.Column('vk_id', sa.BigInteger(), nullable=True))
op.create_unique_constraint('uq_users_google_id', 'users', ['google_id'])
op.create_unique_constraint('uq_users_yandex_id', 'users', ['yandex_id'])
op.create_unique_constraint('uq_users_discord_id', 'users', ['discord_id'])
op.create_unique_constraint('uq_users_vk_id', 'users', ['vk_id'])
op.create_index('ix_users_google_id', 'users', ['google_id'])
op.create_index('ix_users_yandex_id', 'users', ['yandex_id'])
op.create_index('ix_users_discord_id', 'users', ['discord_id'])
op.create_index('ix_users_vk_id', 'users', ['vk_id'])
def downgrade() -> None:
op.drop_index('ix_users_vk_id', table_name='users')
op.drop_index('ix_users_discord_id', table_name='users')
op.drop_index('ix_users_yandex_id', table_name='users')
op.drop_index('ix_users_google_id', table_name='users')
op.drop_constraint('uq_users_vk_id', 'users', type_='unique')
op.drop_constraint('uq_users_discord_id', 'users', type_='unique')
op.drop_constraint('uq_users_yandex_id', 'users', type_='unique')
op.drop_constraint('uq_users_google_id', 'users', type_='unique')
op.drop_column('users', 'vk_id')
op.drop_column('users', 'discord_id')
op.drop_column('users', 'yandex_id')
op.drop_column('users', 'google_id')
@@ -1,22 +0,0 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'h6c7d8e9f0g1'
down_revision: Union[str, None] = 'g5b6c7d8e9f0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'broadcast_history',
sa.Column('blocked_count', sa.Integer(), server_default='0', nullable=False),
)
def downgrade() -> None:
op.drop_column('broadcast_history', 'blocked_count')