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:
@@ -40,6 +40,22 @@ fix: ## Исправить код (ruff check --fix + format)
|
|||||||
uv run ruff check . --fix
|
uv run ruff check . --fix
|
||||||
uv run ruff format .
|
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
|
.PHONY: help
|
||||||
help: ## Показать список доступных команд
|
help: ## Показать список доступных команд
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
script_location = migrations/alembic
|
script_location = migrations/alembic
|
||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
version_path_separator = os
|
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]
|
[post_write_hooks]
|
||||||
|
|
||||||
|
|||||||
@@ -673,6 +673,7 @@ class Settings(BaseSettings):
|
|||||||
WEB_API_DEFAULT_TOKEN: str | None = None
|
WEB_API_DEFAULT_TOKEN: str | None = None
|
||||||
WEB_API_DEFAULT_TOKEN_NAME: str = 'Bootstrap Token'
|
WEB_API_DEFAULT_TOKEN_NAME: str = 'Bootstrap Token'
|
||||||
WEB_API_TOKEN_HASH_ALGORITHM: str = 'sha256'
|
WEB_API_TOKEN_HASH_ALGORITHM: str = 'sha256'
|
||||||
|
WEB_API_TOKEN_HMAC_SECRET: str | None = None
|
||||||
WEB_API_REQUEST_LOGGING: bool = True
|
WEB_API_REQUEST_LOGGING: bool = True
|
||||||
|
|
||||||
APP_CONFIG_PATH: str = 'app-config.json'
|
APP_CONFIG_PATH: str = 'app-config.json'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from .database import (
|
|||||||
get_db,
|
get_db,
|
||||||
get_db_read_only,
|
get_db_read_only,
|
||||||
get_pool_metrics,
|
get_pool_metrics,
|
||||||
init_db,
|
sync_postgres_sequences,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -20,5 +20,5 @@ __all__ = [
|
|||||||
'get_db',
|
'get_db',
|
||||||
'get_db_read_only',
|
'get_db_read_only',
|
||||||
'get_pool_metrics',
|
'get_pool_metrics',
|
||||||
'init_db',
|
'sync_postgres_sequences',
|
||||||
]
|
]
|
||||||
|
|||||||
+126
-98
@@ -1,24 +1,25 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TypeVar
|
from typing import ParamSpec, TypeVar
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from sqlalchemy import bindparam, event, inspect, text
|
from sqlalchemy import bindparam, event, text
|
||||||
from sqlalchemy.engine import Engine
|
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.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
|
from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database.models import Base
|
|
||||||
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
|
P = ParamSpec('P')
|
||||||
|
R = TypeVar('R')
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PRODUCTION-GRADE CONNECTION POOLING
|
# PRODUCTION-GRADE CONNECTION POOLING
|
||||||
@@ -67,7 +68,7 @@ _pg_connect_args = {
|
|||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
poolclass=poolclass,
|
poolclass=poolclass,
|
||||||
echo=settings.DEBUG,
|
echo='debug' if settings.DEBUG else False,
|
||||||
future=True,
|
future=True,
|
||||||
# Кеш скомпилированных запросов (правильное размещение)
|
# Кеш скомпилированных запросов (правильное размещение)
|
||||||
query_cache_size=500,
|
query_cache_size=500,
|
||||||
@@ -103,7 +104,7 @@ def with_db_retry(
|
|||||||
attempts: int = DEFAULT_RETRY_ATTEMPTS,
|
attempts: int = DEFAULT_RETRY_ATTEMPTS,
|
||||||
delay: float = DEFAULT_RETRY_DELAY,
|
delay: float = DEFAULT_RETRY_DELAY,
|
||||||
backoff: float = 2.0,
|
backoff: float = 2.0,
|
||||||
) -> Callable:
|
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
||||||
"""
|
"""
|
||||||
Декоратор для автоматического retry при сбоях подключения к БД.
|
Декоратор для автоматического retry при сбоях подключения к БД.
|
||||||
|
|
||||||
@@ -113,10 +114,10 @@ def with_db_retry(
|
|||||||
backoff: Множитель задержки для каждой следующей попытки
|
backoff: Множитель задержки для каждой следующей попытки
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func: Callable) -> Callable:
|
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(*args, **kwargs):
|
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
last_exception = None
|
last_exception: Exception | None = None
|
||||||
current_delay = delay
|
current_delay = delay
|
||||||
|
|
||||||
for attempt in range(1, attempts + 1):
|
for attempt in range(1, attempts + 1):
|
||||||
@@ -137,9 +138,9 @@ def with_db_retry(
|
|||||||
else:
|
else:
|
||||||
logger.error('Ошибка БД: все попыток исчерпаны. Последняя ошибка', attempts=attempts, e=str(e))
|
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
|
return decorator
|
||||||
|
|
||||||
@@ -150,7 +151,10 @@ async def execute_with_retry(
|
|||||||
attempts: int = DEFAULT_RETRY_ATTEMPTS,
|
attempts: int = DEFAULT_RETRY_ATTEMPTS,
|
||||||
):
|
):
|
||||||
"""Выполнение SQL с retry логикой."""
|
"""Выполнение 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
|
delay = DEFAULT_RETRY_DELAY
|
||||||
|
|
||||||
for attempt in range(1, attempts + 1):
|
for attempt in range(1, attempts + 1):
|
||||||
@@ -163,7 +167,7 @@ async def execute_with_retry(
|
|||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
delay *= 2
|
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
|
return None
|
||||||
# Простая проверка на валидный формат
|
# Простая проверка на валидный формат
|
||||||
if not ('://' in url or url.startswith('sqlite')):
|
if not ('://' in url or url.startswith('sqlite')):
|
||||||
logger.warning('Невалидный DATABASE_URL', url=url[:20])
|
logger.warning('Невалидный DATABASE_URL (не содержит ://)')
|
||||||
return None
|
return None
|
||||||
return url
|
return url
|
||||||
|
|
||||||
@@ -234,7 +238,10 @@ class DatabaseManager:
|
|||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
autoflush=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:
|
except Exception as e:
|
||||||
logger.error('Не удалось настроить read replica', e=e)
|
logger.error('Не удалось настроить read replica', e=e)
|
||||||
self.read_replica_engine = None
|
self.read_replica_engine = None
|
||||||
@@ -402,88 +409,7 @@ batch_ops = BatchOperations()
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def close_db() -> None:
|
||||||
"""Инициализация БД с оптимизациями"""
|
|
||||||
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():
|
|
||||||
"""Корректное закрытие всех соединений"""
|
"""Корректное закрытие всех соединений"""
|
||||||
logger.info('Закрытие соединений с БД...')
|
logger.info('Закрытие соединений с БД...')
|
||||||
|
|
||||||
@@ -495,6 +421,108 @@ async def close_db():
|
|||||||
logger.info('Все подключения к базе данных закрыты')
|
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 (для мониторинга)
|
# CONNECTION POOL METRICS (для мониторинга)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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
@@ -20,7 +20,7 @@ from app.database.crud.system_setting import (
|
|||||||
)
|
)
|
||||||
from app.database.database import AsyncSessionLocal
|
from app.database.database import AsyncSessionLocal
|
||||||
from app.database.models import SystemSetting
|
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__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|||||||
@@ -3,24 +3,121 @@ from __future__ import annotations
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database.crud import web_api_token as crud
|
from app.database.crud import web_api_token as crud
|
||||||
from app.database.models import WebApiToken
|
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
|
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:
|
class WebApiTokenService:
|
||||||
"""Сервис для управления токенами административного веб-API."""
|
"""Сервис для управления токенами административного веб-API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or 'sha256'
|
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:
|
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]
|
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(
|
async def authenticate(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -32,17 +129,13 @@ class WebApiTokenService:
|
|||||||
if not normalized_value:
|
if not normalized_value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _load_token(value: str) -> WebApiToken | None:
|
token = await self._load_token_with_fallback(db, normalized_value)
|
||||||
token_hash = self.hash_token(value)
|
|
||||||
return await crud.get_token_by_hash(db, token_hash)
|
|
||||||
|
|
||||||
token = await _load_token(normalized_value)
|
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or '').strip()
|
default_token = (settings.WEB_API_DEFAULT_TOKEN or '').strip()
|
||||||
if default_token and secrets.compare_digest(default_token, normalized_value):
|
if default_token and secrets.compare_digest(default_token, normalized_value):
|
||||||
await ensure_default_web_api_token()
|
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:
|
if not token or not token.is_active:
|
||||||
return None
|
return None
|
||||||
@@ -73,7 +166,7 @@ class WebApiTokenService:
|
|||||||
db,
|
db,
|
||||||
name=name,
|
name=name,
|
||||||
token_hash=token_hash,
|
token_hash=token_hash,
|
||||||
token_prefix=plain_token[:12],
|
token_prefix=plain_token[:8],
|
||||||
description=description,
|
description=description,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
|
|||||||
+19
-3
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
@@ -10,14 +11,29 @@ from typing import Literal
|
|||||||
HashAlgorithm = Literal['sha256', 'sha384', 'sha512']
|
HashAlgorithm = Literal['sha256', 'sha384', 'sha512']
|
||||||
|
|
||||||
|
|
||||||
def hash_api_token(token: str, algorithm: HashAlgorithm = 'sha256') -> str:
|
def hash_api_token(
|
||||||
"""Возвращает хеш токена в формате hex."""
|
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()
|
normalized = (algorithm or 'sha256').lower()
|
||||||
if normalized not in {'sha256', 'sha384', 'sha512'}:
|
if normalized not in {'sha256', 'sha384', 'sha512'}:
|
||||||
raise ValueError(f'Unsupported hash algorithm: {algorithm}')
|
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)
|
digest = getattr(hashlib, normalized)
|
||||||
return digest(token.encode('utf-8')).hexdigest()
|
return digest(token_bytes).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def generate_api_token(length: int = 48) -> str:
|
def generate_api_token(length: int = 48) -> str:
|
||||||
|
|||||||
@@ -62,9 +62,9 @@
|
|||||||
- `app/database/models.py` — Python-модуль
|
- `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 методов)
|
Классы: `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
|
#### app/database/crud
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,12 @@ API разворачивается вместе с ботом, использу
|
|||||||
## 3. Подготовка базы данных
|
## 3. Подготовка базы данных
|
||||||
|
|
||||||
1. Убедитесь, что настройки БД верны (`DATABASE_URL` или параметры PostgreSQL/SQLite).
|
1. Убедитесь, что настройки БД верны (`DATABASE_URL` или параметры PostgreSQL/SQLite).
|
||||||
2. При старте бота автоматически запускается универсальная миграция `run_universal_migration`, которая:
|
2. При старте бота автоматически запускаются Alembic-миграции (`alembic upgrade head`), которые создают все необходимые таблицы, включая `web_api_tokens`.
|
||||||
- создаёт таблицу `web_api_tokens`, если её нет;
|
3. Токен из `WEB_API_DEFAULT_TOKEN` активируется автоматически при запуске.
|
||||||
- активирует токен из `WEB_API_DEFAULT_TOKEN`, если он задан.
|
4. Если нужно запустить миграцию вручную, выполните:
|
||||||
3. Если нужно запустить миграцию вручную, выполните:
|
|
||||||
|
|
||||||
```bash
|
```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` — бот выполнит ту же процедуру автоматически.
|
Или просто запустите `python main.py` — бот выполнит ту же процедуру автоматически.
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ sys.path.append(str(Path(__file__).parent))
|
|||||||
|
|
||||||
from app.bot import setup_bot
|
from app.bot import setup_bot
|
||||||
from app.config import settings
|
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.models import PaymentMethod
|
||||||
from app.database.universal_migration import run_universal_migration
|
|
||||||
from app.localization.loader import ensure_locale_templates
|
from app.localization.loader import ensure_locale_templates
|
||||||
from app.logging_config import setup_logging
|
from app.logging_config import setup_logging
|
||||||
from app.services.backup_service import backup_service
|
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.system_settings_service import bot_configuration_service
|
||||||
from app.services.traffic_monitoring_service import traffic_monitoring_scheduler
|
from app.services.traffic_monitoring_service import traffic_monitoring_scheduler
|
||||||
from app.services.version_service import version_service
|
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.log_handlers import ExcludePaymentFilter, LevelFilterHandler
|
||||||
from app.utils.payment_logger import configure_payment_logger
|
from app.utils.payment_logger import configure_payment_logger
|
||||||
from app.utils.startup_timeline import StartupTimeline
|
from app.utils.startup_timeline import StartupTimeline
|
||||||
@@ -179,42 +180,43 @@ async def main():
|
|||||||
summary_logged = False
|
summary_logged = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with timeline.stage('Инициализация базы данных', '🗄️', success_message='База данных готова'):
|
|
||||||
await init_db()
|
|
||||||
|
|
||||||
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
|
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
|
||||||
|
|
||||||
if not skip_migration:
|
if not skip_migration:
|
||||||
async with timeline.stage(
|
async with timeline.stage(
|
||||||
'Проверка и миграция базы данных',
|
'Миграция базы данных (Alembic)',
|
||||||
'🧬',
|
'🧬',
|
||||||
success_message='Миграция завершена успешно',
|
success_message='Миграция завершена успешно',
|
||||||
) as stage:
|
) as stage:
|
||||||
try:
|
try:
|
||||||
migration_log = logging.getLogger('app.database.universal_migration')
|
await run_alembic_upgrade()
|
||||||
original_level = migration_log.level
|
stage.success('Миграция завершена успешно')
|
||||||
migration_log.setLevel(logging.WARNING)
|
|
||||||
try:
|
|
||||||
migration_success = await run_universal_migration()
|
|
||||||
finally:
|
|
||||||
migration_log.setLevel(original_level)
|
|
||||||
if migration_success:
|
|
||||||
stage.success('Миграция завершена успешно')
|
|
||||||
else:
|
|
||||||
stage.warning('Миграция завершилась с предупреждениями, запуск продолжится')
|
|
||||||
logger.warning('⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск')
|
|
||||||
except Exception as migration_error:
|
except Exception as migration_error:
|
||||||
stage.warning(f'Ошибка выполнения миграции: {migration_error}')
|
allow_failure = os.getenv('ALLOW_MIGRATION_FAILURE', 'false').lower() == 'true'
|
||||||
logger.error('❌ Ошибка выполнения миграции', migration_error=migration_error)
|
logger.error('Ошибка выполнения миграции', migration_error=migration_error)
|
||||||
logger.warning('⚠️ Продолжаем запуск без миграции')
|
if not allow_failure:
|
||||||
|
raise
|
||||||
|
stage.warning(f'Ошибка миграции: {migration_error} (ALLOW_MIGRATION_FAILURE=true)')
|
||||||
else:
|
else:
|
||||||
timeline.add_manual_step(
|
timeline.add_manual_step(
|
||||||
'Проверка и миграция базы данных',
|
'Миграция базы данных (Alembic)',
|
||||||
'⏭️',
|
'⏭️',
|
||||||
'Пропущено',
|
'Пропущено',
|
||||||
'SKIP_MIGRATION=true',
|
'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(
|
async with timeline.stage(
|
||||||
'Синхронизация тарифов из конфига',
|
'Синхронизация тарифов из конфига',
|
||||||
'💰',
|
'💰',
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -17,11 +17,13 @@ from app.config import settings
|
|||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
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
|
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:
|
def run_migrations_offline() -> None:
|
||||||
@@ -58,6 +60,10 @@ async def run_async_migrations() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> 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())
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
|
||||||
Reference in New Issue
Block a user