feat: добавлена система миграций БД и CI/CD пайплайны

- Создана система отслеживания миграций (MigrationRepository, таблица migrations)
- Добавлен скрипт apply_migrations.py для автоматического применения миграций
- Созданы CI/CD пайплайны (.github/workflows/ci.yml, deploy.yml)
- Обновлена документация по миграциям в database-patterns.md
- Миграции применяются автоматически при деплое в продакшн
This commit is contained in:
2026-01-25 23:17:09 +03:00
parent 07e72c4d14
commit e2b1353408
109 changed files with 1342 additions and 1441 deletions

View File

@@ -9,13 +9,12 @@
- async_db: основной класс AsyncBotDB
"""
from .models import (
User, BlacklistUser, UserMessage, TelegramPost, PostContent,
MessageContentLink, Admin, Migration, AudioMessage, AudioListenRecord, AudioModerate
)
from .repository_factory import RepositoryFactory
from .base import DatabaseConnection
from .async_db import AsyncBotDB
from .base import DatabaseConnection
from .models import (Admin, AudioListenRecord, AudioMessage, AudioModerate,
BlacklistUser, MessageContentLink, Migration, PostContent,
TelegramPost, User, UserMessage)
from .repository_factory import RepositoryFactory
# Для обратной совместимости экспортируем старый интерфейс
__all__ = [

View File

@@ -1,11 +1,11 @@
import aiosqlite
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from typing import Any, Dict, List, Optional, Tuple
import aiosqlite
from database.models import (Admin, AudioMessage, BlacklistHistoryRecord,
BlacklistUser, PostContent, TelegramPost, User,
UserMessage)
from database.repository_factory import RepositoryFactory
from database.models import (
User, BlacklistUser, BlacklistHistoryRecord, UserMessage, TelegramPost, PostContent,
Admin, AudioMessage
)
class AsyncBotDB:
@@ -403,25 +403,9 @@ class AsyncBotDB:
await self.factory.audio.delete_audio_record_by_file_name(file_name)
# Методы для миграций
async def get_migration_version(self) -> int:
"""Получение текущей версии миграции."""
return await self.factory.migrations.get_migration_version()
async def get_current_version(self) -> Optional[int]:
"""Возвращает текущую последнюю версию миграции."""
return await self.factory.migrations.get_current_version()
async def update_version(self, new_version: int, script_name: str):
"""Обновляет версию миграций в таблице migrations."""
await self.factory.migrations.update_version(new_version, script_name)
async def create_table(self, sql_script: str):
"""Создает таблицу в базе. Используется в миграциях."""
await self.factory.migrations.create_table(sql_script)
async def update_migration_version(self, version: int, script_name: str):
"""Обновление версии миграции."""
await self.factory.migrations.update_version(version, script_name)
await self.factory.migrations.create_table_from_sql(sql_script)
# Методы для voice bot welcome tracking
async def check_voice_bot_welcome_received(self, user_id: int) -> bool:

View File

@@ -1,6 +1,7 @@
import os
import aiosqlite
from typing import Optional
import aiosqlite
from logs.custom_logger import logger

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, List
from typing import List, Optional
@dataclass
@@ -89,9 +89,8 @@ class Admin:
@dataclass
class Migration:
"""Модель миграции."""
version: int
script_name: str
created_at: Optional[str] = None
applied_at: Optional[str] = None
@dataclass

View File

@@ -9,17 +9,20 @@
- post_repository: работа с постами
- admin_repository: работа с администраторами
- audio_repository: работа с аудио
- migration_repository: работа с миграциями БД
"""
from .user_repository import UserRepository
from .blacklist_repository import BlacklistRepository
from .blacklist_history_repository import BlacklistHistoryRepository
from .message_repository import MessageRepository
from .post_repository import PostRepository
from .admin_repository import AdminRepository
from .audio_repository import AudioRepository
from .blacklist_history_repository import BlacklistHistoryRepository
from .blacklist_repository import BlacklistRepository
from .message_repository import MessageRepository
from .migration_repository import MigrationRepository
from .post_repository import PostRepository
from .user_repository import UserRepository
__all__ = [
'UserRepository', 'BlacklistRepository', 'BlacklistHistoryRepository',
'MessageRepository', 'PostRepository', 'AdminRepository', 'AudioRepository'
'MessageRepository', 'PostRepository', 'AdminRepository', 'AudioRepository',
'MigrationRepository'
]

View File

@@ -1,4 +1,5 @@
from typing import Optional
from database.base import DatabaseConnection
from database.models import Admin

View File

@@ -1,7 +1,8 @@
from typing import Optional, List, Dict, Any
from database.base import DatabaseConnection
from database.models import AudioMessage, AudioListenRecord, AudioModerate
from datetime import datetime
from typing import Any, Dict, List, Optional
from database.base import DatabaseConnection
from database.models import AudioListenRecord, AudioMessage, AudioModerate
class AudioRepository(DatabaseConnection):

View File

@@ -1,4 +1,5 @@
from typing import Optional
from database.base import DatabaseConnection
from database.models import BlacklistHistoryRecord

View File

@@ -1,4 +1,5 @@
from typing import Optional, List, Dict
from typing import Dict, List, Optional
from database.base import DatabaseConnection
from database.models import BlacklistUser

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from typing import Optional
from database.base import DatabaseConnection
from database.models import UserMessage

View File

@@ -0,0 +1,79 @@
"""Репозиторий для работы с миграциями базы данных."""
import aiosqlite
from database.base import DatabaseConnection
class MigrationRepository(DatabaseConnection):
"""Репозиторий для управления миграциями базы данных."""
async def create_table(self):
"""Создает таблицу migrations, если она не существует."""
query = """
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
script_name TEXT NOT NULL UNIQUE,
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
"""
await self._execute_query(query)
self.logger.info("Таблица migrations создана или уже существует")
async def get_applied_migrations(self) -> list[str]:
"""Возвращает список имен примененных скриптов миграций."""
conn = None
try:
conn = await self._get_connection()
cursor = await conn.execute("SELECT script_name FROM migrations ORDER BY applied_at")
rows = await cursor.fetchall()
await cursor.close()
return [row[0] for row in rows]
except Exception as e:
self.logger.error(f"Ошибка при получении списка миграций: {e}")
raise
finally:
if conn:
await conn.close()
async def is_migration_applied(self, script_name: str) -> bool:
"""Проверяет, применена ли миграция."""
conn = None
try:
conn = await self._get_connection()
cursor = await conn.execute(
"SELECT COUNT(*) FROM migrations WHERE script_name = ?",
(script_name,)
)
row = await cursor.fetchone()
await cursor.close()
return row[0] > 0 if row else False
except Exception as e:
self.logger.error(f"Ошибка при проверке миграции {script_name}: {e}")
raise
finally:
if conn:
await conn.close()
async def mark_migration_applied(self, script_name: str) -> None:
"""Отмечает миграцию как примененную."""
conn = None
try:
conn = await self._get_connection()
await conn.execute(
"INSERT INTO migrations (script_name) VALUES (?)",
(script_name,)
)
await conn.commit()
self.logger.info(f"Миграция {script_name} отмечена как примененная")
except aiosqlite.IntegrityError:
self.logger.warning(f"Миграция {script_name} уже была применена ранее")
except Exception as e:
self.logger.error(f"Ошибка при отметке миграции {script_name}: {e}")
raise
finally:
if conn:
await conn.close()
async def create_table_from_sql(self, sql_script: str) -> None:
"""Создает таблицу из SQL скрипта. Используется в миграциях."""
await self._execute_query(sql_script)

View File

@@ -1,7 +1,8 @@
from datetime import datetime
from typing import Optional, List, Tuple
from typing import List, Optional, Tuple
from database.base import DatabaseConnection
from database.models import TelegramPost, PostContent, MessageContentLink
from database.models import MessageContentLink, PostContent, TelegramPost
class PostRepository(DatabaseConnection):

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from typing import Optional, List, Dict, Any
from typing import Any, Dict, List, Optional
from database.base import DatabaseConnection
from database.models import User

View File

@@ -1,11 +1,14 @@
from typing import Optional
from database.repositories.user_repository import UserRepository
from database.repositories.blacklist_repository import BlacklistRepository
from database.repositories.blacklist_history_repository import BlacklistHistoryRepository
from database.repositories.message_repository import MessageRepository
from database.repositories.post_repository import PostRepository
from database.repositories.admin_repository import AdminRepository
from database.repositories.audio_repository import AudioRepository
from database.repositories.blacklist_history_repository import \
BlacklistHistoryRepository
from database.repositories.blacklist_repository import BlacklistRepository
from database.repositories.message_repository import MessageRepository
from database.repositories.migration_repository import MigrationRepository
from database.repositories.post_repository import PostRepository
from database.repositories.user_repository import UserRepository
class RepositoryFactory:
@@ -20,6 +23,7 @@ class RepositoryFactory:
self._post_repo: Optional[PostRepository] = None
self._admin_repo: Optional[AdminRepository] = None
self._audio_repo: Optional[AudioRepository] = None
self._migration_repo: Optional[MigrationRepository] = None
@property
def users(self) -> UserRepository:
@@ -70,8 +74,16 @@ class RepositoryFactory:
self._audio_repo = AudioRepository(self.db_path)
return self._audio_repo
@property
def migrations(self) -> MigrationRepository:
"""Возвращает репозиторий миграций."""
if self._migration_repo is None:
self._migration_repo = MigrationRepository(self.db_path)
return self._migration_repo
async def create_all_tables(self):
"""Создает все таблицы в базе данных."""
await self.migrations.create_table() # Сначала создаем таблицу миграций
await self.users.create_tables()
await self.blacklist.create_tables()
await self.blacklist_history.create_tables()

View File

@@ -126,6 +126,13 @@ CREATE TABLE IF NOT EXISTS audio_moderate (
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Database migrations tracking
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
script_name TEXT NOT NULL UNIQUE,
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- Create indexes for better performance
-- Optimized index for user_audio_listens - only user_id for "show all audio listened by user X"
CREATE INDEX IF NOT EXISTS idx_user_audio_listens_user_id ON user_audio_listens(user_id);