feat: добавлена система миграций БД и CI/CD пайплайны
- Создана система отслеживания миграций (MigrationRepository, таблица migrations) - Добавлен скрипт apply_migrations.py для автоматического применения миграций - Созданы CI/CD пайплайны (.github/workflows/ci.yml, deploy.yml) - Обновлена документация по миграциям в database-patterns.md - Миграции применяются автоматически при деплое в продакшн
This commit is contained in:
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт миграции для добавления колонки ban_author в таблицу blacklist.
|
||||
Колонка хранит user_id администратора, инициировавшего бан.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from logs.custom_logger import logger # noqa: E402
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
|
||||
def _column_exists(rows: list, name: str) -> bool:
|
||||
"""PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
|
||||
for row in rows:
|
||||
if row[1] == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def main(db_path: str) -> None:
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
logger.error("База данных не найдена: %s", db_path)
|
||||
print(f"Ошибка: база данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Проверяем наличие колонки ban_author
|
||||
cursor = await conn.execute("PRAGMA table_info(blacklist)")
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
if not _column_exists(rows, "ban_author"):
|
||||
logger.info("Добавление колонки ban_author в blacklist")
|
||||
await conn.execute(
|
||||
"ALTER TABLE blacklist "
|
||||
"ADD COLUMN ban_author INTEGER REFERENCES our_users (user_id) ON DELETE SET NULL"
|
||||
)
|
||||
await conn.commit()
|
||||
print("Колонка ban_author добавлена в таблицу blacklist.")
|
||||
else:
|
||||
print("Колонка ban_author уже существует в таблице blacklist.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Добавление колонки ban_author в blacklist"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД (или DB_PATH)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db))
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт миграции для добавления колонки is_anonymous в таблицу post_from_telegram_suggest.
|
||||
Для существующих записей определяет is_anonymous на основе текста или устанавливает NULL.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from logs.custom_logger import logger
|
||||
from helper_bot.utils.helper_func import determine_anonymity
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
|
||||
def _column_exists(rows: list, name: str) -> bool:
|
||||
"""PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
|
||||
for row in rows:
|
||||
if row[1] == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def main(db_path: str) -> None:
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
logger.error("База данных не найдена: %s", db_path)
|
||||
print(f"Ошибка: база данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Проверяем наличие колонки is_anonymous
|
||||
cursor = await conn.execute(
|
||||
"PRAGMA table_info(post_from_telegram_suggest)"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
if not _column_exists(rows, "is_anonymous"):
|
||||
logger.info("Добавление колонки is_anonymous в post_from_telegram_suggest")
|
||||
await conn.execute(
|
||||
"ALTER TABLE post_from_telegram_suggest "
|
||||
"ADD COLUMN is_anonymous INTEGER"
|
||||
)
|
||||
await conn.commit()
|
||||
print("Колонка is_anonymous добавлена.")
|
||||
else:
|
||||
print("Колонка is_anonymous уже существует.")
|
||||
|
||||
# Получаем все записи с текстом для обновления
|
||||
cursor = await conn.execute(
|
||||
"SELECT message_id, text FROM post_from_telegram_suggest WHERE text IS NOT NULL"
|
||||
)
|
||||
posts = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
updated_count = 0
|
||||
null_count = 0
|
||||
|
||||
# Обновляем каждую запись
|
||||
for message_id, text in posts:
|
||||
try:
|
||||
# Определяем is_anonymous на основе текста
|
||||
# Если текст пустой или None, устанавливаем NULL (legacy)
|
||||
if not text or not text.strip():
|
||||
is_anonymous = None
|
||||
else:
|
||||
is_anonymous = determine_anonymity(text)
|
||||
|
||||
# Преобразуем bool в int для SQLite (True -> 1, False -> 0, None -> None)
|
||||
is_anonymous_int = None if is_anonymous is None else (1 if is_anonymous else 0)
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE post_from_telegram_suggest SET is_anonymous = ? WHERE message_id = ?",
|
||||
(is_anonymous_int, message_id)
|
||||
)
|
||||
|
||||
if is_anonymous is not None:
|
||||
updated_count += 1
|
||||
else:
|
||||
null_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке поста message_id={message_id}: {e}")
|
||||
# В случае ошибки устанавливаем NULL
|
||||
await conn.execute(
|
||||
"UPDATE post_from_telegram_suggest SET is_anonymous = NULL WHERE message_id = ?",
|
||||
(message_id,)
|
||||
)
|
||||
null_count += 1
|
||||
|
||||
# Обновляем записи без текста (устанавливаем NULL)
|
||||
cursor = await conn.execute(
|
||||
"SELECT COUNT(*) FROM post_from_telegram_suggest WHERE text IS NULL"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
posts_without_text = row[0] if row else 0
|
||||
await cursor.close()
|
||||
|
||||
if posts_without_text > 0:
|
||||
await conn.execute(
|
||||
"UPDATE post_from_telegram_suggest SET is_anonymous = NULL WHERE text IS NULL"
|
||||
)
|
||||
null_count += posts_without_text
|
||||
|
||||
await conn.commit()
|
||||
|
||||
total_updated = updated_count + null_count
|
||||
logger.info(
|
||||
f"Миграция завершена. Обновлено записей: {total_updated} "
|
||||
f"(определено: {updated_count}, установлено NULL: {null_count})"
|
||||
)
|
||||
print(f"Миграция завершена.")
|
||||
print(f"Обновлено записей: {total_updated}")
|
||||
print(f" - Определено is_anonymous: {updated_count}")
|
||||
print(f" - Установлено NULL: {null_count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Добавление колонки is_anonymous в post_from_telegram_suggest"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД (или DB_PATH)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db))
|
||||
@@ -1,166 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт миграции для добавления поддержки опубликованных постов:
|
||||
1. Добавляет колонку published_message_id в таблицу post_from_telegram_suggest
|
||||
2. Создает таблицу published_post_content для хранения медиафайлов опубликованных постов
|
||||
3. Создает индексы для производительности
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from logs.custom_logger import logger
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
|
||||
def _column_exists(rows: list, name: str) -> bool:
|
||||
"""Проверяет существование колонки в таблице.
|
||||
PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
|
||||
for row in rows:
|
||||
if row[1] == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
async def main(db_path: str, dry_run: bool = False) -> None:
|
||||
"""Выполняет миграцию БД для поддержки опубликованных постов."""
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
logger.error("База данных не найдена: %s", db_path)
|
||||
print(f"Ошибка: база данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
changes_made = []
|
||||
|
||||
# 1. Проверяем и добавляем колонку published_message_id
|
||||
cursor = await conn.execute(
|
||||
"PRAGMA table_info(post_from_telegram_suggest)"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
if not _column_exists(rows, "published_message_id"):
|
||||
if dry_run:
|
||||
print("DRY RUN: Будет добавлена колонка published_message_id в post_from_telegram_suggest")
|
||||
changes_made.append("Добавление колонки published_message_id")
|
||||
else:
|
||||
logger.info("Добавление колонки published_message_id в post_from_telegram_suggest")
|
||||
await conn.execute(
|
||||
"ALTER TABLE post_from_telegram_suggest "
|
||||
"ADD COLUMN published_message_id INTEGER"
|
||||
)
|
||||
await conn.commit()
|
||||
print("✓ Колонка published_message_id добавлена в post_from_telegram_suggest")
|
||||
changes_made.append("Добавлена колонка published_message_id")
|
||||
else:
|
||||
print("✓ Колонка published_message_id уже существует в post_from_telegram_suggest")
|
||||
|
||||
# 2. Проверяем и создаем таблицу published_post_content
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='published_post_content'"
|
||||
)
|
||||
table_exists = await cursor.fetchone()
|
||||
await cursor.close()
|
||||
|
||||
if not table_exists:
|
||||
if dry_run:
|
||||
print("DRY RUN: Будет создана таблица published_post_content")
|
||||
changes_made.append("Создание таблицы published_post_content")
|
||||
else:
|
||||
logger.info("Создание таблицы published_post_content")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS published_post_content (
|
||||
published_message_id INTEGER NOT NULL,
|
||||
content_name TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
published_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (published_message_id, content_name)
|
||||
)
|
||||
""")
|
||||
await conn.commit()
|
||||
print("✓ Таблица published_post_content создана")
|
||||
changes_made.append("Создана таблица published_post_content")
|
||||
else:
|
||||
print("✓ Таблица published_post_content уже существует")
|
||||
|
||||
# 3. Проверяем и создаем индексы
|
||||
indexes = [
|
||||
("idx_published_post_content_message_id",
|
||||
"CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id "
|
||||
"ON published_post_content(published_message_id)"),
|
||||
("idx_post_from_telegram_suggest_published",
|
||||
"CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published "
|
||||
"ON post_from_telegram_suggest(published_message_id)")
|
||||
]
|
||||
|
||||
for index_name, index_sql in indexes:
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name=?",
|
||||
(index_name,)
|
||||
)
|
||||
index_exists = await cursor.fetchone()
|
||||
await cursor.close()
|
||||
|
||||
if not index_exists:
|
||||
if dry_run:
|
||||
print(f"DRY RUN: Будет создан индекс {index_name}")
|
||||
changes_made.append(f"Создание индекса {index_name}")
|
||||
else:
|
||||
logger.info(f"Создание индекса {index_name}")
|
||||
await conn.execute(index_sql)
|
||||
await conn.commit()
|
||||
print(f"✓ Индекс {index_name} создан")
|
||||
changes_made.append(f"Создан индекс {index_name}")
|
||||
else:
|
||||
print(f"✓ Индекс {index_name} уже существует")
|
||||
|
||||
# Финальная статистика
|
||||
if dry_run:
|
||||
if changes_made:
|
||||
print("\n" + "="*60)
|
||||
print("DRY RUN: Следующие изменения будут выполнены:")
|
||||
for change in changes_made:
|
||||
print(f" - {change}")
|
||||
print("="*60)
|
||||
else:
|
||||
print("\n✓ Все необходимые изменения уже применены. Ничего делать не нужно.")
|
||||
else:
|
||||
if changes_made:
|
||||
logger.info(f"Миграция завершена. Выполнено изменений: {len(changes_made)}")
|
||||
print(f"\n✓ Миграция завершена успешно!")
|
||||
print(f"Выполнено изменений: {len(changes_made)}")
|
||||
for change in changes_made:
|
||||
print(f" - {change}")
|
||||
else:
|
||||
print("\n✓ Все необходимые изменения уже применены. Ничего делать не нужно.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Добавление поддержки опубликованных постов в БД"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД (или переменная окружения DB_PATH)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Показать что будет сделано без выполнения изменений",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db, dry_run=args.dry_run))
|
||||
241
scripts/apply_migrations.py
Normal file
241
scripts/apply_migrations.py
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для автоматического применения миграций базы данных.
|
||||
|
||||
Сканирует папку scripts/ и применяет все новые миграции, которые еще не были применены.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
# Исключаем служебные скрипты из миграций
|
||||
EXCLUDED_SCRIPTS = {
|
||||
'apply_migrations.py',
|
||||
'test_s3_connection.py',
|
||||
'voice_cleanup.py',
|
||||
}
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
|
||||
def get_migration_scripts(scripts_dir: Path) -> List[Tuple[str, Path]]:
|
||||
"""
|
||||
Получает список скриптов миграций из папки scripts.
|
||||
|
||||
Возвращает список кортежей (имя_файла, путь_к_файлу), отсортированный по имени файла.
|
||||
"""
|
||||
scripts = []
|
||||
for script_file in sorted(scripts_dir.glob("*.py")):
|
||||
if script_file.name not in EXCLUDED_SCRIPTS:
|
||||
scripts.append((script_file.name, script_file))
|
||||
return scripts
|
||||
|
||||
|
||||
async def is_migration_script(script_path: Path) -> bool:
|
||||
"""
|
||||
Проверяет, является ли скрипт миграцией.
|
||||
|
||||
Миграция должна иметь функцию main() с параметром db_path.
|
||||
"""
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("migration_script", script_path)
|
||||
if spec is None or spec.loader is None:
|
||||
return False
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Проверяем наличие функции main
|
||||
if hasattr(module, 'main'):
|
||||
import inspect
|
||||
sig = inspect.signature(module.main)
|
||||
# Проверяем, что функция принимает db_path
|
||||
params = list(sig.parameters.keys())
|
||||
return 'db_path' in params
|
||||
return False
|
||||
except Exception:
|
||||
# Если не удалось проверить, считаем что это не миграция
|
||||
return False
|
||||
|
||||
|
||||
async def apply_migration(script_path: Path, db_path: str) -> bool:
|
||||
"""
|
||||
Применяет миграцию, запуская скрипт.
|
||||
|
||||
Returns:
|
||||
True если миграция применена успешно, False в противном случае.
|
||||
"""
|
||||
script_name = script_path.name
|
||||
|
||||
try:
|
||||
# Запускаем скрипт как отдельный процесс
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script_path), "--db", db_path],
|
||||
cwd=script_path.parent.parent,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 минут максимум на миграцию
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
if result.stdout:
|
||||
print(f" {result.stdout.strip()}")
|
||||
return True
|
||||
else:
|
||||
print(f" ❌ Ошибка:")
|
||||
if result.stdout:
|
||||
print(f" STDOUT: {result.stdout}")
|
||||
if result.stderr:
|
||||
print(f" STDERR: {result.stderr}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f" ❌ Превышен лимит времени (5 минут)")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ❌ Ошибка: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main(db_path: str, dry_run: bool = False) -> None:
|
||||
"""
|
||||
Основная функция для применения миграций.
|
||||
|
||||
Args:
|
||||
db_path: Путь к базе данных
|
||||
dry_run: Если True, только показывает какие миграции будут применены
|
||||
"""
|
||||
# Импортируем зависимости только когда они действительно нужны
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# Проверяем наличие необходимых зависимостей
|
||||
try:
|
||||
import aiosqlite
|
||||
except ImportError:
|
||||
print("❌ Ошибка: модуль aiosqlite не установлен.")
|
||||
print("💡 Установите зависимости: pip install -r requirements.txt")
|
||||
sys.exit(1)
|
||||
|
||||
# Импортируем logger
|
||||
try:
|
||||
from logs.custom_logger import logger
|
||||
except ImportError:
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Импортируем MigrationRepository напрямую из файла
|
||||
migration_repo_path = project_root / "database" / "repositories" / "migration_repository.py"
|
||||
if not migration_repo_path.exists():
|
||||
print(f"❌ Файл migration_repository.py не найден: {migration_repo_path}")
|
||||
sys.exit(1)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("migration_repository", migration_repo_path)
|
||||
if spec is None or spec.loader is None:
|
||||
print("❌ Не удалось загрузить модуль migration_repository")
|
||||
sys.exit(1)
|
||||
|
||||
migration_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migration_module)
|
||||
MigrationRepository = migration_module.MigrationRepository
|
||||
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
logger.error(f"База данных не найдена: {db_path}")
|
||||
print(f"❌ Ошибка: база данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
scripts_dir = project_root / "scripts"
|
||||
if not scripts_dir.exists():
|
||||
logger.error(f"Папка scripts не найдена: {scripts_dir}")
|
||||
print(f"❌ Ошибка: папка scripts не найдена: {scripts_dir}")
|
||||
return
|
||||
|
||||
# Инициализируем репозиторий миграций напрямую
|
||||
migration_repo = MigrationRepository(db_path)
|
||||
await migration_repo.create_table()
|
||||
|
||||
# Получаем список примененных миграций
|
||||
applied_migrations = await migration_repo.get_applied_migrations()
|
||||
logger.info(f"Примененных миграций: {len(applied_migrations)}")
|
||||
|
||||
# Получаем все скрипты миграций
|
||||
all_scripts = get_migration_scripts(scripts_dir)
|
||||
|
||||
# Фильтруем только миграции
|
||||
migration_scripts = []
|
||||
for script_name, script_path in all_scripts:
|
||||
if await is_migration_script(script_path):
|
||||
migration_scripts.append((script_name, script_path))
|
||||
else:
|
||||
logger.debug(f"Скрипт {script_name} не является миграцией, пропускаем")
|
||||
|
||||
# Находим новые миграции
|
||||
new_migrations = [
|
||||
(name, path) for name, path in migration_scripts
|
||||
if name not in applied_migrations
|
||||
]
|
||||
|
||||
if not new_migrations:
|
||||
print("✅ Все миграции уже применены")
|
||||
logger.info("Новых миграций не найдено")
|
||||
return
|
||||
|
||||
print(f"📋 Найдено новых миграций: {len(new_migrations)}")
|
||||
for name, _ in new_migrations:
|
||||
print(f" - {name}")
|
||||
|
||||
if dry_run:
|
||||
print("\n🔍 DRY RUN: миграции не будут применены")
|
||||
return
|
||||
|
||||
# Применяем миграции по порядку
|
||||
print("\n🚀 Применение миграций...")
|
||||
failed_migrations = []
|
||||
|
||||
for script_name, script_path in new_migrations:
|
||||
print(f"📝 {script_name}...", end=" ", flush=True)
|
||||
success = await apply_migration(script_path, db_path)
|
||||
if success:
|
||||
# Отмечаем миграцию как примененную
|
||||
await migration_repo.mark_migration_applied(script_name)
|
||||
print("✅")
|
||||
else:
|
||||
failed_migrations.append(script_name)
|
||||
print("❌")
|
||||
logger.error(f"Не удалось применить миграцию: {script_name}")
|
||||
# Прерываем выполнение при ошибке
|
||||
print(f"\n⚠️ Прерывание: миграция {script_name} завершилась с ошибкой")
|
||||
break
|
||||
|
||||
if failed_migrations:
|
||||
print(f"\n❌ Не удалось применить {len(failed_migrations)} миграций:")
|
||||
for name in failed_migrations:
|
||||
print(f" - {name}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"\n✅ Все миграции применены успешно ({len(new_migrations)} шт.)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Применение миграций базы данных"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД (или DATABASE_PATH из env)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Показать какие миграции будут применены без фактического применения",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db, args.dry_run))
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для проставления status='legacy' всем существующим записям в post_from_telegram_suggest.
|
||||
Добавляет колонку status, если её нет, затем обновляет все строки.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from logs.custom_logger import logger
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
|
||||
def _column_exists(rows: list, name: str) -> bool:
|
||||
"""PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)."""
|
||||
for row in rows:
|
||||
if row[1] == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def main(db_path: str) -> None:
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
logger.error("База данных не найдена: %s", db_path)
|
||||
print(f"Ошибка: база данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Проверяем наличие колонки status
|
||||
cursor = await conn.execute(
|
||||
"PRAGMA table_info(post_from_telegram_suggest)"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
if not _column_exists(rows, "status"):
|
||||
logger.info("Добавление колонки status в post_from_telegram_suggest")
|
||||
await conn.execute(
|
||||
"ALTER TABLE post_from_telegram_suggest "
|
||||
"ADD COLUMN status TEXT NOT NULL DEFAULT 'suggest'"
|
||||
)
|
||||
await conn.commit()
|
||||
print("Колонка status добавлена.")
|
||||
else:
|
||||
print("Колонка status уже существует.")
|
||||
|
||||
# Обновляем все существующие записи на legacy
|
||||
await conn.execute(
|
||||
"UPDATE post_from_telegram_suggest SET status = 'legacy'"
|
||||
)
|
||||
await conn.commit()
|
||||
cursor = await conn.execute("SELECT changes()")
|
||||
row = await cursor.fetchone()
|
||||
updated = row[0] if row else 0
|
||||
await cursor.close()
|
||||
|
||||
logger.info("Обновлено записей в post_from_telegram_suggest: %d", updated)
|
||||
print(f"Обновлено записей: {updated}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Backfill status='legacy' для post_from_telegram_suggest"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД (или DB_PATH)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db))
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для приведения текста постов к "сырому" виду.
|
||||
Удаляет форматирование, добавленное функцией get_text_message(), оставляя только исходный текст.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from logs.custom_logger import logger
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
# Паттерны для определения форматированного текста
|
||||
PREFIX = "Пост из ТГ:\n"
|
||||
ANONYMOUS_SUFFIX = "\n\nПост опубликован анонимно"
|
||||
AUTHOR_SUFFIX_PATTERN = re.compile(r"\n\nАвтор поста: .+$")
|
||||
|
||||
|
||||
def extract_raw_text(formatted_text: str) -> str:
|
||||
"""
|
||||
Извлекает сырой текст из форматированного текста поста.
|
||||
|
||||
Args:
|
||||
formatted_text: Форматированный текст поста
|
||||
|
||||
Returns:
|
||||
str: Сырой текст или исходный текст, если форматирование не обнаружено
|
||||
"""
|
||||
if not formatted_text:
|
||||
return ""
|
||||
|
||||
# Проверяем, начинается ли текст с префикса
|
||||
if not formatted_text.startswith(PREFIX):
|
||||
# Текст уже в сыром виде или имеет другой формат
|
||||
return formatted_text
|
||||
|
||||
# Извлекаем текст после префикса
|
||||
text_after_prefix = formatted_text[len(PREFIX):]
|
||||
|
||||
# Проверяем, заканчивается ли текст на "Пост опубликован анонимно"
|
||||
if text_after_prefix.endswith(ANONYMOUS_SUFFIX):
|
||||
raw_text = text_after_prefix[:-len(ANONYMOUS_SUFFIX)]
|
||||
# Проверяем, заканчивается ли текст на "Автор поста: ..."
|
||||
elif AUTHOR_SUFFIX_PATTERN.search(text_after_prefix):
|
||||
raw_text = AUTHOR_SUFFIX_PATTERN.sub("", text_after_prefix)
|
||||
else:
|
||||
# Не удалось определить формат, возвращаем текст без префикса
|
||||
raw_text = text_after_prefix
|
||||
|
||||
# Декодируем HTML-экранирование
|
||||
raw_text = html.unescape(raw_text)
|
||||
|
||||
return raw_text
|
||||
|
||||
|
||||
async def main(db_path: str, dry_run: bool = False) -> None:
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
logger.error("База данных не найдена: %s", db_path)
|
||||
print(f"Ошибка: база данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Получаем все записи с текстом
|
||||
cursor = await conn.execute(
|
||||
"SELECT message_id, text FROM post_from_telegram_suggest WHERE text IS NOT NULL AND text != ''"
|
||||
)
|
||||
posts = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
print(f"Найдено записей для обработки: {len(posts)}")
|
||||
if dry_run:
|
||||
print("РЕЖИМ ПРОВЕРКИ (dry-run): изменения не будут сохранены")
|
||||
|
||||
# Обрабатываем каждую запись
|
||||
for message_id, formatted_text in posts:
|
||||
try:
|
||||
# Извлекаем сырой текст
|
||||
raw_text = extract_raw_text(formatted_text)
|
||||
|
||||
# Проверяем, изменился ли текст
|
||||
if raw_text == formatted_text:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f"\n[DRY-RUN] message_id={message_id}:")
|
||||
print(f" Было: {formatted_text[:100]}...")
|
||||
print(f" Станет: {raw_text[:100]}...")
|
||||
else:
|
||||
# Обновляем запись
|
||||
await conn.execute(
|
||||
"UPDATE post_from_telegram_suggest SET text = ? WHERE message_id = ?",
|
||||
(raw_text, message_id)
|
||||
)
|
||||
updated_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке поста message_id={message_id}: {e}")
|
||||
error_count += 1
|
||||
|
||||
if not dry_run:
|
||||
await conn.commit()
|
||||
|
||||
total_processed = updated_count + skipped_count + error_count
|
||||
logger.info(
|
||||
f"Обработка завершена. Всего записей: {total_processed}, "
|
||||
f"обновлено: {updated_count}, пропущено: {skipped_count}, ошибок: {error_count}"
|
||||
)
|
||||
print(f"\nОбработка завершена:")
|
||||
print(f" - Всего записей: {total_processed}")
|
||||
print(f" - Обновлено: {updated_count}")
|
||||
print(f" - Пропущено (уже в сыром виде): {skipped_count}")
|
||||
print(f" - Ошибок: {error_count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Приведение текста постов к 'сырому' виду"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД (или DB_PATH)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Режим проверки без сохранения изменений",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db, args.dry_run))
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт миграции для создания таблицы blacklist_history.
|
||||
Таблица хранит историю всех операций бана/разбана пользователей.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from logs.custom_logger import logger
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
|
||||
def _table_exists(rows: list, table_name: str) -> bool:
|
||||
"""Проверяет существование таблицы по результатам PRAGMA table_list."""
|
||||
for row in rows:
|
||||
if row[1] == table_name: # name column
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def main(db_path: str) -> None:
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
logger.error("База данных не найдена: %s", db_path)
|
||||
print(f"Ошибка: база данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Проверяем наличие таблицы blacklist_history
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='blacklist_history'"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
if not rows:
|
||||
logger.info("Создание таблицы blacklist_history")
|
||||
|
||||
# Создаем таблицу
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS blacklist_history (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
message_for_user TEXT,
|
||||
date_ban INTEGER NOT NULL,
|
||||
date_unban INTEGER,
|
||||
ban_author INTEGER,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Создаем индексы
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)"
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
logger.info("Таблица blacklist_history и индексы успешно созданы")
|
||||
print("Таблица blacklist_history и индексы успешно созданы.")
|
||||
else:
|
||||
print("Таблица blacklist_history уже существует.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Создание таблицы blacklist_history для истории банов/разбанов"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД (или DB_PATH)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db))
|
||||
@@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт миграции для переноса записей из blacklist в blacklist_history.
|
||||
Переносит все существующие записи из таблицы blacklist в таблицу blacklist_history.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from logs.custom_logger import logger
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
|
||||
async def main(db_path: str) -> None:
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not os.path.exists(db_path):
|
||||
logger.error("База данных не найдена: %s", db_path)
|
||||
print(f"Ошибка: база данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Проверяем наличие таблицы blacklist_history
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='blacklist_history'"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
if not rows:
|
||||
logger.error("Таблица blacklist_history не найдена. Сначала запустите create_blacklist_history_table.py")
|
||||
print("Ошибка: таблица blacklist_history не найдена. Сначала запустите create_blacklist_history_table.py")
|
||||
return
|
||||
|
||||
# Получаем все записи из blacklist
|
||||
cursor = await conn.execute(
|
||||
"SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist"
|
||||
)
|
||||
blacklist_records = await cursor.fetchall()
|
||||
await cursor.close()
|
||||
|
||||
if not blacklist_records:
|
||||
print("В таблице blacklist нет записей для переноса.")
|
||||
logger.info("В таблице blacklist нет записей для переноса")
|
||||
return
|
||||
|
||||
logger.info("Найдено записей в blacklist для переноса: %d", len(blacklist_records))
|
||||
print(f"Найдено записей в blacklist для переноса: {len(blacklist_records)}")
|
||||
|
||||
# Получаем текущее время в Unix timestamp
|
||||
current_time = int(datetime.now().timestamp())
|
||||
|
||||
# Переносим записи в blacklist_history
|
||||
migrated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for record in blacklist_records:
|
||||
user_id, message_for_user, date_to_unban, created_at, ban_author = record
|
||||
|
||||
# Проверяем, нет ли уже записи для этого user_id с таким же date_ban
|
||||
# (чтобы избежать дубликатов при повторном запуске)
|
||||
date_ban = created_at if created_at is not None else current_time
|
||||
|
||||
check_cursor = await conn.execute(
|
||||
"SELECT id FROM blacklist_history WHERE user_id = ? AND date_ban = ?",
|
||||
(user_id, date_ban)
|
||||
)
|
||||
existing = await check_cursor.fetchone()
|
||||
await check_cursor.close()
|
||||
|
||||
if existing:
|
||||
logger.debug("Запись для user_id=%d с date_ban=%d уже существует, пропускаем", user_id, date_ban)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Вставляем запись в blacklist_history
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO blacklist_history
|
||||
(user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
message_for_user,
|
||||
date_ban,
|
||||
date_to_unban,
|
||||
ban_author,
|
||||
created_at if created_at is not None else current_time,
|
||||
current_time
|
||||
)
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
await conn.commit()
|
||||
|
||||
logger.info(
|
||||
"Миграция завершена. Перенесено записей: %d, пропущено (дубликаты): %d",
|
||||
migrated_count,
|
||||
skipped_count
|
||||
)
|
||||
print(f"Миграция завершена. Перенесено записей: {migrated_count}, пропущено (дубликаты): {skipped_count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Перенос записей из blacklist в blacklist_history"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД (или DB_PATH)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db))
|
||||
@@ -13,6 +13,7 @@ sys.path.insert(0, str(project_root))
|
||||
|
||||
# Загружаем .env файл
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_path = os.path.join(project_root, '.env')
|
||||
if os.path.exists(env_path):
|
||||
load_dotenv(env_path)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Скрипт для диагностики и очистки проблем с голосовыми файлами
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем корневую директорию проекта в путь
|
||||
|
||||
Reference in New Issue
Block a user