diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index ccf3cc0..886813d 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -70,14 +70,13 @@ urfu-daddy/ │ │ ├── __init__.py # Функция для запуска бота. │ │ ├── handlers/ # Обработка всех ивентов. │ │ │ ├── callbacks/ # Обработка запросов. -│ │ │ ├── commands/ # Обработка комманд. -│ │ │ ├── utils/ # Вспомогательные компоненты для хендлеров. -│ │ ├── middlewares/ # Мидлвари для диспетчера. -│ │ └── services/ # Взаимодействие с другими сервисами бота. +│ │ │ └── commands/ # Обработка комманд. +│ │ ├── middlewares/ # Мидлвари для диспетчера/роутеров. +│ │ ├── services/ # Взаимодействие с другими сервисами бота. +│ │ └── utils/ # Вспомогательные компоненты для для бота. │ ├── config/ # Получение env-настроек, настройка логирования. │ ├── database/ # Инициализация и настройка БД. -│ ├── locales/ # Папка с локализацией проекта. -│ └── redis_client/ # Настройка redis-клиента. +│ └── locales/ # Папка с локализацией проекта. ├── LICENSE # Лицензия проекта. ├── README.md # Описание проекта. ├── CONTRIBUTE.md # Этот файл. diff --git a/src/bot/__init__.py b/src/bot/__init__.py index fd3daba..b6edf0c 100644 --- a/src/bot/__init__.py +++ b/src/bot/__init__.py @@ -5,7 +5,7 @@ from aiogram import Bot, Dispatcher from aiogram.fsm.storage.redis import RedisStorage from loguru import logger as loguru_logger from redis.asyncio.client import Redis -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.ext.asyncio import async_sessionmaker from .handlers import include_routers, registry from .middlewares import connect_middlewares @@ -14,8 +14,8 @@ from .middlewares import connect_middlewares @loguru_logger.catch async def start_bot( bot_token: str, - redis_client: Redis, - database_session: async_sessionmaker[AsyncSession], + redis_protocol: Redis, + session_maker: async_sessionmaker, ) -> None: """ Start Telegram bot. @@ -23,28 +23,31 @@ async def start_bot( Args: bot_token (str): Telegram API bot token. redis_client (Redis): async configured client for redis. - database_session (AsyncSession): async database session. + session_maker (AsyncSession): async database session maker. """ bot = Bot(bot_token) dispatcher = Dispatcher( - storage=RedisStorage(redis=redis_client), + storage=RedisStorage(redis=redis_protocol), + ) + + connect_middlewares( + dispatcher=dispatcher, + session_maker=session_maker, + ) + loguru_logger.debug( + "Middlewares have been successfully connected." ) include_routers(dispatcher) loguru_logger.debug("Routers have been successfully included.") - connect_middlewares(dispatcher) - loguru_logger.debug( - "Middlewares have been successfully connected." - ) - - await bot.delete_webhook(drop_pending_updates=True) - loguru_logger.debug( - "All updates for the Telegram bot have been dropped." - ) - await registry.set_menu(bot) loguru_logger.debug("The bot menu has been successfully set.") + await bot.delete_webhook(drop_pending_updates=True) + loguru_logger.debug( + "All updates for the Telegram bot have been dropped.", + ) + await dispatcher.start_polling(bot) diff --git a/src/bot/handlers/__init__.py b/src/bot/handlers/__init__.py index da34c81..10b5cfb 100644 --- a/src/bot/handlers/__init__.py +++ b/src/bot/handlers/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from aiogram import Router from loguru import logger as loguru_logger -from .utils.registry import RouterRegistry +from bot.utils.registry import RouterRegistry # Main registry router for bot handlers. registry: RouterRegistry = RouterRegistry() diff --git a/src/bot/handlers/callbacks/menu.py b/src/bot/handlers/callbacks/menu.py index ac8ca01..a6a42d9 100644 --- a/src/bot/handlers/callbacks/menu.py +++ b/src/bot/handlers/callbacks/menu.py @@ -1,8 +1,8 @@ from aiogram.types import CallbackQuery, Message from bot.handlers import registry -from bot.handlers.utils.keyboards import get_menu_markup -from bot.handlers.utils.types import ChatType +from bot.utils.keyboards import get_menu_markup +from bot.utils.types import ChatType @registry.register( diff --git a/src/bot/handlers/commands/help.py b/src/bot/handlers/commands/help.py index ce56a3b..3dadc15 100644 --- a/src/bot/handlers/commands/help.py +++ b/src/bot/handlers/commands/help.py @@ -1,7 +1,8 @@ from aiogram.types import Message from bot.handlers import registry -from bot.handlers.utils.types import ChatType +from bot.services.database import check_telegram_user +from bot.utils.types import ChatType @registry.register( @@ -9,5 +10,13 @@ from bot.handlers.utils.types import ChatType chat_types=ChatType.PRIVATE, description="Help Command Function Description", ) -async def cmd_help(message: Message) -> None: - await message.answer("Help Command Function Answer Text") +async def cmd_help(message: Message, session) -> None: + if message.from_user is None: + return + + user = await check_telegram_user(session, message.from_user.id) + + await message.answer( + "Help Command Function Answer Text. " + f"Your locale is {user.lang}" + ) diff --git a/src/bot/handlers/commands/menu.py b/src/bot/handlers/commands/menu.py index 3df72e9..873c6a3 100644 --- a/src/bot/handlers/commands/menu.py +++ b/src/bot/handlers/commands/menu.py @@ -1,8 +1,8 @@ from aiogram.types import Message from bot.handlers import registry -from bot.handlers.utils.keyboards import get_menu_markup -from bot.handlers.utils.types import ChatType +from bot.utils.keyboards import get_menu_markup +from bot.utils.types import ChatType @registry.register( diff --git a/src/bot/middlewares/__init__.py b/src/bot/middlewares/__init__.py index c9ca463..be444ef 100644 --- a/src/bot/middlewares/__init__.py +++ b/src/bot/middlewares/__init__.py @@ -1,9 +1,16 @@ __all__ = ["connect_middlewares"] -from aiogram import Router +from aiogram import Dispatcher +from sqlalchemy.ext.asyncio import async_sessionmaker + +from .database import DatabaseSessionMiddleware -def connect_middlewares(dispatcher: Router): - # TODO: Add database and localization middleware and connect them. - ... +def connect_middlewares( + dispatcher: Dispatcher, + session_maker: async_sessionmaker, +) -> None: + database_middleware = DatabaseSessionMiddleware(session_maker) + + dispatcher.update.middleware(database_middleware) diff --git a/src/bot/middlewares/database.py b/src/bot/middlewares/database.py new file mode 100644 index 0000000..8b72c58 --- /dev/null +++ b/src/bot/middlewares/database.py @@ -0,0 +1,22 @@ +from typing import Any, Awaitable, Callable, Dict + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject +from sqlalchemy.ext.asyncio import async_sessionmaker + + +class DatabaseSessionMiddleware(BaseMiddleware): + def __init__(self, session_maker: async_sessionmaker): + self.session_maker = session_maker + + async def __call__( + self, + handler: Callable[ + [TelegramObject, Dict[str, Any]], Awaitable[Any] + ], + event: TelegramObject, + data: Dict[str, Any], + ) -> Any: + async with self.session_maker() as session: + data["session"] = session + return await handler(event, data) diff --git a/src/bot/services/__init__.py b/src/bot/services/__init__.py index 7dfd4a8..f52f96a 100644 --- a/src/bot/services/__init__.py +++ b/src/bot/services/__init__.py @@ -1,5 +1,4 @@ -__all__ = [] +__all__ = ["check_telegram_user"] -# TODO: Add bot business logic -# (working with other service components). +from .database import check_telegram_user diff --git a/src/bot/services/database.py b/src/bot/services/database.py new file mode 100644 index 0000000..6472bae --- /dev/null +++ b/src/bot/services/database.py @@ -0,0 +1,52 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import TelegramUser + + +async def _get_telegram_user( + session: AsyncSession, + telegram_id: int, +) -> TelegramUser | None: + """ + Получить пользователя из БД. + Возвращает либо объект пользователя, либо None. + """ + query = select(TelegramUser).where(TelegramUser.id == telegram_id) + result = await session.execute(query) + + return result.scalar() + + +async def _add_telegram_user( + session: AsyncSession, + telegram_id: int, +) -> TelegramUser: + """ + Добавить пользователя в БД. + Возвращает только что добавленный объект пользователя. + """ + user = TelegramUser(id=telegram_id) + + session.add(user) + + await session.commit() + + return user + + +async def check_telegram_user( + session: AsyncSession, + telegram_id: int, +) -> TelegramUser: + """ + Проверяет, есть ли пользователь в БД. + Если его нет, то добавляет. + Всегда возвращает объект пользователя. + """ + result = await _get_telegram_user(session, telegram_id) + + if result is not None: + return result + + return await _add_telegram_user(session, telegram_id) diff --git a/src/bot/services/database/__init__.py b/src/bot/utils/__init__.py similarity index 100% rename from src/bot/services/database/__init__.py rename to src/bot/utils/__init__.py diff --git a/src/bot/handlers/utils/filters/__init__.py b/src/bot/utils/filters/__init__.py similarity index 97% rename from src/bot/handlers/utils/filters/__init__.py rename to src/bot/utils/filters/__init__.py index e59a335..c4d3852 100644 --- a/src/bot/handlers/utils/filters/__init__.py +++ b/src/bot/utils/filters/__init__.py @@ -6,7 +6,7 @@ from typing import Union from aiogram.filters import BaseFilter from aiogram.types import CallbackQuery, Message -from bot.handlers.utils.types import ChatType +from bot.utils.types import ChatType class ChatTypeFilter(BaseFilter): diff --git a/src/bot/handlers/utils/keyboards/__init__.py b/src/bot/utils/keyboards/__init__.py similarity index 100% rename from src/bot/handlers/utils/keyboards/__init__.py rename to src/bot/utils/keyboards/__init__.py diff --git a/src/bot/handlers/utils/registry/__init__.py b/src/bot/utils/registry/__init__.py similarity index 98% rename from src/bot/handlers/utils/registry/__init__.py rename to src/bot/utils/registry/__init__.py index 4854455..ba6307d 100644 --- a/src/bot/handlers/utils/registry/__init__.py +++ b/src/bot/utils/registry/__init__.py @@ -13,8 +13,8 @@ from aiogram.types.bot_command_scope_all_private_chats import ( ) from loguru import logger as loguru_logger -from bot.handlers.utils.filters import ChatTypeFilter -from bot.handlers.utils.types import ( +from bot.utils.filters import ChatTypeFilter +from bot.utils.types import ( ChatType, HandlerMeta, HandlerType, diff --git a/src/bot/handlers/utils/states/__init__.py b/src/bot/utils/states/__init__.py similarity index 100% rename from src/bot/handlers/utils/states/__init__.py rename to src/bot/utils/states/__init__.py diff --git a/src/bot/handlers/utils/types/__init__.py b/src/bot/utils/types/__init__.py similarity index 100% rename from src/bot/handlers/utils/types/__init__.py rename to src/bot/utils/types/__init__.py diff --git a/src/bot/handlers/utils/types/chat.py b/src/bot/utils/types/chat.py similarity index 100% rename from src/bot/handlers/utils/types/chat.py rename to src/bot/utils/types/chat.py diff --git a/src/bot/handlers/utils/types/handler.py b/src/bot/utils/types/handler.py similarity index 94% rename from src/bot/handlers/utils/types/handler.py rename to src/bot/utils/types/handler.py index 34a2c1c..d589bb9 100644 --- a/src/bot/handlers/utils/types/handler.py +++ b/src/bot/utils/types/handler.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Any, Awaitable, Callable, TypeVar -from bot.handlers.utils.types import ChatType +from bot.utils.types import ChatType HandlerType = TypeVar( "HandlerType", bound=Callable[[Any], Awaitable[Any]] diff --git a/src/config/__init__.py b/src/config/__init__.py index a2811c6..8e8d2da 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -1,6 +1,7 @@ __all__ = ["settings", "configure_logger"] -from .utils import Settings, configure_logger +from .logger import configure_logger +from .settings import Settings settings = Settings() diff --git a/src/config/logger.py b/src/config/logger.py new file mode 100644 index 0000000..56dc1e7 --- /dev/null +++ b/src/config/logger.py @@ -0,0 +1,94 @@ +import logging +import sys +from pathlib import Path + +from loguru import logger as loguru_logger + +# Constant paths for project. +LOG_DIR = Path("logs") +LOG_DIR.mkdir(parents=True, exist_ok=True) + +# Log format for loguru logger. +LOG_FORMAT = ( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level:<8} | " + "{process}.{thread.name} | " + "{module}.{function}:{line} – " + "{message}" +) + + +class InterceptHandler(logging.Handler): + """ + Class for intercepting logs from logging, including Aiogram. + """ + + def emit(self, record: logging.LogRecord) -> None: + """ + Try to intercepting logs from logging. + + Args: + record (logging.LogRecord): record from logging. + """ + try: + level = loguru_logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = logging.currentframe(), 2 + + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + if frame is None: + break + + loguru_logger.opt( + depth=depth, + exception=record.exc_info, + ).log( + level, + record.getMessage(), + ) + + +def configure_logger( + file_log_level: str, + listen_logging: bool, + console_log_level: str, +) -> None: + """ + Configure loguru logger. + + Args: + file_log_level (str): logging level for the .log file. + listen_logging (bool): intercepting logs from logging. + console_log_level (str): logging level for the console. + """ + if listen_logging: + logging.root.handlers.clear() + logging.root.setLevel(logging.DEBUG) + logging.root.addHandler(InterceptHandler()) + + loguru_logger.remove() + + loguru_logger.add( + sys.stdout, + level=console_log_level, + colorize=True, + enqueue=True, + format=LOG_FORMAT, + ) + + loguru_logger.add( + LOG_DIR / "{time:YYYY-MM-DD}.log", + level=file_log_level, + colorize=False, + enqueue=True, + format=LOG_FORMAT, + rotation="10 MB", + retention="14 days", + compression="zip", + serialize=False, + ) diff --git a/src/config/utils.py b/src/config/settings.py similarity index 52% rename from src/config/utils.py rename to src/config/settings.py index 19813a2..6ebcf88 100644 --- a/src/config/utils.py +++ b/src/config/settings.py @@ -1,25 +1,9 @@ -import logging -import sys from pathlib import Path from typing import Literal -from loguru import logger as loguru_logger from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict -# Constant paths for project. -LOG_DIR = Path("logs") -LOG_DIR.mkdir(parents=True, exist_ok=True) - -# Log format for loguru logger. -LOG_FORMAT = ( - "{time:YYYY-MM-DD HH:mm:ss} | " - "{level:<8} | " - "{process}.{thread.name} | " - "{module}.{function}:{line} – " - "{message}" -) - class Settings(BaseSettings): """ @@ -40,6 +24,12 @@ class Settings(BaseSettings): PostgreSQL user password. postgres_name (str): PostgreSQL database name. + postgres_url (str):database_url + Full PostgreSQL database url. + + database_engine_echo (bool): + Enable SQLAlchemy engine echo. + Defaults to True. redis_host (str): Redis host name. @@ -69,8 +59,10 @@ class Settings(BaseSettings): postgres_pass: str = Field(frozen=True) postgres_name: str = Field(frozen=True) + database_engine_echo: bool = Field(frozen=True, default=True) + @property - def database_url(self): + def postgres_url(self): """Get PostgreSQL database url.""" return ( f"postgresql+asyncpg://{self.postgres_user}:" @@ -113,79 +105,3 @@ class Settings(BaseSettings): env_file_encoding="utf-8", case_sensitive=False, ) - - -class InterceptHandler(logging.Handler): - """ - Class for intercepting logs from logging, including Aiogram. - """ - - def emit(self, record: logging.LogRecord) -> None: - """ - Try to intercepting logs from logging. - - Args: - record (logging.LogRecord): record from logging. - """ - try: - level = loguru_logger.level(record.levelname).name - except ValueError: - level = record.levelno - - frame, depth = logging.currentframe(), 2 - - while frame and frame.f_code.co_filename == logging.__file__: - frame = frame.f_back - depth += 1 - - if frame is None: - break - - loguru_logger.opt( - depth=depth, - exception=record.exc_info, - ).log( - level, - record.getMessage(), - ) - - -def configure_logger( - file_log_level: str, - listen_logging: bool, - console_log_level: str, -) -> None: - """ - Configure loguru logger. - - Args: - file_log_level (str): logging level for the .log file. - listen_logging (bool): intercepting logs from logging. - console_log_level (str): logging level for the console. - """ - if listen_logging: - logging.root.handlers.clear() - logging.root.setLevel(logging.DEBUG) - logging.root.addHandler(InterceptHandler()) - - loguru_logger.remove() - - loguru_logger.add( - sys.stdout, - level=console_log_level, - colorize=True, - enqueue=True, - format=LOG_FORMAT, - ) - - loguru_logger.add( - LOG_DIR / "{time:YYYY-MM-DD}.log", - level=file_log_level, - colorize=False, - enqueue=True, - format=LOG_FORMAT, - rotation="10 MB", - retention="14 days", - compression="zip", - serialize=False, - ) diff --git a/src/database/__init__.py b/src/database/__init__.py index 4c8fbb8..b6da8ee 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -1,4 +1,20 @@ -__all__ = ["create_session", "create_db", "drop_db"] +__all__ = [ + "Student", + "TelegramUser", + "UniversityMember", + "create_db", + "drop_db", + "engine", + "session_maker", + "redis_protocol", +] -from .session import create_db, create_session, drop_db +from .models import Student, TelegramUser, UniversityMember +from .postgres import ( + create_db, + drop_db, + engine, + session_maker, +) +from .redis import protocol as redis_protocol diff --git a/src/database/models.py b/src/database/models.py index cad8f11..b76f0bf 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -84,11 +84,6 @@ class TelegramUser(Base): autoincrement=False, ) - username: Mapped[str] = mapped_column( - String(64), - nullable=True, - ) - lang: Mapped[str] = mapped_column( String(2), default="ru", diff --git a/src/database/postgres.py b/src/database/postgres.py new file mode 100644 index 0000000..a2bc826 --- /dev/null +++ b/src/database/postgres.py @@ -0,0 +1,32 @@ +# from loguru import logger as loguru_logger +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from config import settings + +from .models import Base + +engine = create_async_engine( + url=settings.postgres_url, + echo=settings.database_engine_echo, +) + +session_maker = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def create_db(engine: AsyncEngine): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def drop_db(engine: AsyncEngine): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) diff --git a/src/database/redis.py b/src/database/redis.py new file mode 100644 index 0000000..2bcebff --- /dev/null +++ b/src/database/redis.py @@ -0,0 +1,8 @@ +from redis.asyncio.client import Redis + +from config import settings + +protocol = Redis( + host=settings.redis_host, + port=settings.redis_port, +) diff --git a/src/database/session.py b/src/database/session.py deleted file mode 100644 index 4fd389c..0000000 --- a/src/database/session.py +++ /dev/null @@ -1,40 +0,0 @@ -# from loguru import logger as loguru_logger -from sqlalchemy.ext.asyncio import ( - AsyncEngine, - AsyncSession, - async_sessionmaker, - create_async_engine, -) - -from .models import Base - -engine: AsyncEngine - - -def create_session( - url: str, engine_echo: bool = True -) -> async_sessionmaker[AsyncSession]: - global engine - engine = create_async_engine(url=url, echo=engine_echo) - - session = async_sessionmaker( - bind=engine, - class_=AsyncSession, - expire_on_commit=False, - ) - - return session - - -async def create_db(): - global engine - - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - -async def drop_db(): - global engine - - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) diff --git a/src/main.py b/src/main.py index 17de15b..4e1251a 100644 --- a/src/main.py +++ b/src/main.py @@ -2,11 +2,10 @@ import asyncio from bot import start_bot from config import configure_logger, settings -from database import create_db, create_session -from redis_client import get_redis_client +from database import create_db, engine, redis_protocol, session_maker -def main() -> None: +async def main() -> None: """ Launch of all service components. Configure logger and start bot. @@ -17,26 +16,15 @@ def main() -> None: listen_logging=settings.listen_logging, ) - session = create_session( - url=settings.database_url, engine_echo=True - ) - # FIXME: Add argument for create/drop db. - asyncio.run(create_db()) + await create_db(engine) - redis_client = get_redis_client( - host=settings.redis_host, - port=settings.redis_port, - ) - - asyncio.run( - start_bot( - bot_token=settings.bot_token, - redis_client=redis_client, - database_session=session, - ) + await start_bot( + bot_token=settings.bot_token, + redis_protocol=redis_protocol, + session_maker=session_maker, ) if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/src/redis_client/__init__.py b/src/redis_client/__init__.py deleted file mode 100644 index f2425d4..0000000 --- a/src/redis_client/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -__all__ = ["get_redis_client"] - - -from redis.asyncio.client import Redis - - -def get_redis_client(host: str, port: int) -> Redis: - """ - Get async redis client. - - Args: - host (str): redis host. - port (int): redis port. - - Returns: - Redis: async redis client. - """ - client = Redis( - host=host, - port=port, - ) - - return client