From 17cd64bdbc4d9b2bf4e7872734fc929f40ae88a6 Mon Sep 17 00:00:00 2001 From: Ivan Ashikhmin Date: Sun, 17 Mar 2024 21:28:49 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A2=D1=80=D0=B5=D1=82=D0=B8=D0=B9=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic.ini | 114 +++++++++++++++++++++++++++ alembic/README | 1 + alembic/env.py | 94 ++++++++++++++++++++++ alembic/script.py.mako | 26 ++++++ app/callbacks/__init__.py | 0 app/callbacks/callback_docker.py | 10 +++ app/callbacks/callback_favorites.py | 13 +++ app/handlers/docker_commands.py | 49 ++++++------ app/handlers/events.py | 6 +- app/handlers/favorites.py | 60 ++++++++++++++ app/handlers/multiply_commands.py | 6 +- app/handlers/single_command.py | 7 +- app/keyboards/docker_keyboards.py | 13 +-- app/keyboards/favorites_keyboards.py | 52 ++++++++++++ app/middlewares/__init__.py | 0 app/middlewares/admin_middleware.py | 18 +++++ app/models/__init__.py | 9 +++ app/models/base.py | 7 ++ app/models/favorites.py | 17 ++++ app/models/user.py | 18 +++++ app/schemas/__init__.py | 0 app/schemas/favorites_schema.py | 10 +++ app/schemas/user_schema.py | 12 +++ app/settings.py | 27 ++++--- app/utils/db_actions.py | 50 ++++++++++++ app/utils/statesform.py | 5 ++ app/utils/text_splitter.py | 20 +++++ app/views.py | 6 ++ main.py | 18 ++++- 29 files changed, 618 insertions(+), 50 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 app/callbacks/__init__.py create mode 100644 app/callbacks/callback_docker.py create mode 100644 app/callbacks/callback_favorites.py create mode 100644 app/keyboards/favorites_keyboards.py create mode 100644 app/middlewares/__init__.py create mode 100644 app/middlewares/admin_middleware.py create mode 100644 app/models/__init__.py create mode 100644 app/models/base.py create mode 100644 app/models/favorites.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/favorites_schema.py create mode 100644 app/schemas/user_schema.py create mode 100644 app/utils/db_actions.py create mode 100644 app/utils/text_splitter.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..56d1d18 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,114 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +hooks = black +black.type = console_scripts +black.entrypoint = black +black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..6f045bb --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,94 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from app.models import Base +from app.settings import secrets + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option("sqlalchemy.url", secrets.db_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/app/callbacks/__init__.py b/app/callbacks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/callbacks/callback_docker.py b/app/callbacks/callback_docker.py new file mode 100644 index 0000000..aba09a5 --- /dev/null +++ b/app/callbacks/callback_docker.py @@ -0,0 +1,10 @@ +from aiogram.filters.callback_data import CallbackData + + +class ContainerCallback(CallbackData, prefix="container"): + name: str + + +class ActionCallback(CallbackData, prefix="action"): + name: str + action: str diff --git a/app/callbacks/callback_favorites.py b/app/callbacks/callback_favorites.py new file mode 100644 index 0000000..bf599a8 --- /dev/null +++ b/app/callbacks/callback_favorites.py @@ -0,0 +1,13 @@ +from aiogram.filters.callback_data import CallbackData + + +class FavoriteCallback(CallbackData, prefix="favorite"): + user_id: int + + +class AddFavoriteCallback(FavoriteCallback, prefix="add"): + pass + + +class DelFavoriteCallback(FavoriteCallback, prefix="del"): + pass diff --git a/app/handlers/docker_commands.py b/app/handlers/docker_commands.py index 340a88c..7497c82 100644 --- a/app/handlers/docker_commands.py +++ b/app/handlers/docker_commands.py @@ -2,47 +2,42 @@ import subprocess from aiogram.types import Message, CallbackQuery +from app import views +from app.callbacks.callback_docker import ContainerCallback, ActionCallback from app.keyboards.docker_keyboards import container_names_keyboard, container_actions_keyboard +from app.utils.text_splitter import split_text async def get_containers(message: Message): - sub = subprocess.check_output("docker ps -a").decode() + sub = subprocess.check_output("docker ps -a", shell=True).decode() + messages = split_text(sub) + + for m in messages: + await message.answer( + text=f"
{m}
", + parse_mode="HTML", + ) + keyboard = container_names_keyboard(sub) - await message.answer( - text=f"
{sub}
", - parse_mode="HTML", - reply_markup=keyboard.as_markup() - ) + await message.answer(text="Выберите контейнер:", reply_markup=keyboard.as_markup()) -async def container_actions(call: CallbackQuery): - name = call.data.split("_")[-1] +async def container_actions(callback: CallbackQuery, callback_data: ContainerCallback): + name = callback_data.name keyboard = container_actions_keyboard(name) - await call.message.answer( + await callback.message.answer( text=f"Выберите действие для контейнера {name}", parse_mode="HTML", reply_markup=keyboard.as_markup() ) -async def do_container_action(call: CallbackQuery): - _, action, name = call.data.split("_") - match action: - case "start": - subprocess.run(f"docker start {name}") - message = f"Контейнер {name} успешно запущен" - case "stop": - subprocess.run(f"docker stop {name}") - message = f"Контейнер {name} успешно остановлен" - case "restart": - subprocess.run(f"docker restart {name}") - message = f"Контейнер {name} успешно перезапущен" - case "delete": - subprocess.run(f"docker rm -f {name}") - message = f"Контейнер {name} успешно удалён" - case _: - message = f"Произошла необъяснимая ошибка" +async def do_container_action(callback: CallbackQuery, callback_data: ActionCallback): + action, name = callback_data.name, callback_data.action - await call.message.answer( + subprocess.run(f"docker {action} {name}", shell=True) + message = views.actions.get(action).format(name) + + await callback.message.answer( text=message, ) diff --git a/app/handlers/events.py b/app/handlers/events.py index 8d1ad70..2303e8d 100644 --- a/app/handlers/events.py +++ b/app/handlers/events.py @@ -1,12 +1,12 @@ -from app.settings import bot, Secrets +from app.settings import bot, secrets from app.utils.commands import set_commands from app import views async def start_bot(): await set_commands(bot) - await bot.send_message(Secrets.admin_id, views.start_bot_message()) + await bot.send_message(secrets.admin_id, views.start_bot_message()) async def stop_bot(): - await bot.send_message(Secrets.admin_id, views.stop_bot_message()) + await bot.send_message(secrets.admin_id, views.stop_bot_message()) diff --git a/app/handlers/favorites.py b/app/handlers/favorites.py index e69de29..5d3cbbb 100644 --- a/app/handlers/favorites.py +++ b/app/handlers/favorites.py @@ -0,0 +1,60 @@ +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery + +from app.callbacks.callback_favorites import AddFavoriteCallback, DelFavoriteCallback +from app.keyboards.favorites_keyboards import favorite_list, add_favorite_inline, add_del_favorite_inline +from app.utils.db_actions import get_user, create_user, get_favorites, add_favorite, del_favorite +from app.utils.statesform import FavoritesCommandsSteps + + +async def favorites_command(message: Message): + user = await get_user(message.from_user.id) + if not user: + user = await create_user(message.from_user.id) + + favorites = await get_favorites(user.id) + + if not favorites: + inline_keyboard = await add_favorite_inline(user) + await message.answer("Нет команд в избранном.", reply_markup=inline_keyboard) + else: + inline_keyboard = await add_del_favorite_inline(user) + await message.answer("Выберите действие:", reply_markup=inline_keyboard) + keyboard = await favorite_list(favorites) + await message.answer("Выберите команду из списка ниже:", reply_markup=keyboard) + + +async def add_favorite_callback(callback: CallbackQuery, callback_data: AddFavoriteCallback, state: FSMContext): + await callback.message.answer("Введите команду.") + await state.set_data({"user_id": callback_data.user_id}) + await state.set_state(FavoritesCommandsSteps.ADD) + + +async def add_favorite_state(message: Message, state: FSMContext): + data = await state.get_data() + new_command = await add_favorite(user_id=data.get("user_id"), command=message.text) + await message.answer(f"Добавлена команда: {new_command.command}") + await state.clear() + + +async def del_favorite_callback(callback: CallbackQuery, callback_data: DelFavoriteCallback, state: FSMContext): + favorites = await get_favorites(callback_data.user_id) + favorites_text = "\n".join([f"{i}: {favorite.command}" for i, favorite in enumerate(favorites)]) + await callback.message.answer(f"Введите номер команды для удаления:\n{favorites_text}") + await state.set_data({"favorites": favorites}) + await state.set_state(FavoritesCommandsSteps.DEL) + + +async def del_favorite_state(message: Message, state: FSMContext): + data = await state.get_data() + + try: + choice = int(message.text) + except ValueError: + await message.answer("Введите число!") + await state.set_state(FavoritesCommandsSteps.DEL) + else: + favorite = data.get("favorites")[choice] + await del_favorite(favorite.id) + await message.answer("Команда успешно удалена.") + await state.clear() diff --git a/app/handlers/multiply_commands.py b/app/handlers/multiply_commands.py index 3a7d138..de2cdef 100644 --- a/app/handlers/multiply_commands.py +++ b/app/handlers/multiply_commands.py @@ -6,6 +6,7 @@ from aiogram.types import Message from app import views from app.utils.statesform import ExecuteCommandsSteps +from app.utils.text_splitter import split_text async def multiply_commands(message: Message, state: FSMContext): @@ -34,6 +35,9 @@ async def execute_commands(message: Message, state: FSMContext): except subprocess.CalledProcessError as e: res = views.subprocess_error(e) - await message.answer(views.user_command(res), parse_mode="HTML") + messages = split_text(res) + + for m in messages: + await message.answer(views.user_command(m), parse_mode="HTML") await state.set_state(ExecuteCommandsSteps.EXECUTE) diff --git a/app/handlers/single_command.py b/app/handlers/single_command.py index a134746..aa11a93 100644 --- a/app/handlers/single_command.py +++ b/app/handlers/single_command.py @@ -3,10 +3,15 @@ import subprocess from aiogram.types import Message from app import views +from app.utils.text_splitter import split_text async def execute_command(message: Message): user_command = message.text.split()[1:] sub = subprocess.check_output(user_command, shell=True) res = sub.decode().replace("&", "&").replace("<", "<").replace(">", ">") - await message.answer(views.user_command(res), parse_mode="HTML") + + messages = split_text(res) + + for m in messages: + await message.answer(views.user_command(m), parse_mode="HTML") diff --git a/app/keyboards/docker_keyboards.py b/app/keyboards/docker_keyboards.py index 224a9ff..013b7fa 100644 --- a/app/keyboards/docker_keyboards.py +++ b/app/keyboards/docker_keyboards.py @@ -1,16 +1,17 @@ from aiogram.types import InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder +from app.callbacks.callback_docker import ContainerCallback, ActionCallback + def container_names_keyboard(stdout: str): container_names = [line.split(" ")[-1].strip() for line in stdout.splitlines()[1:]] builder = InlineKeyboardBuilder() for name in container_names: - data = f"container_{name}" builder.add( InlineKeyboardButton( text=name, - callback_data=data, + callback_data=ContainerCallback(name=name).pack(), ) ) builder.adjust(1) @@ -23,19 +24,19 @@ def container_actions_keyboard(name: str): builder.add(InlineKeyboardButton( text="Запустить контейнер", - callback_data=f"action_start_{name}", + callback_data=ActionCallback(name=name, action="start").pack(), )) builder.add(InlineKeyboardButton( text="Остановить контейнер", - callback_data=f"action_stop_{name}", + callback_data=ActionCallback(name=name, action="stop").pack(), )) builder.add(InlineKeyboardButton( text="Перезапустить контейнер", - callback_data=f"action_restart_{name}", + callback_data=ActionCallback(name=name, action="restart").pack(), )) builder.add(InlineKeyboardButton( text="Удалить контейнер", - callback_data=f"action_delete_{name}", + callback_data=ActionCallback(name=name, action="delete").pack(), )) builder.adjust(1) diff --git a/app/keyboards/favorites_keyboards.py b/app/keyboards/favorites_keyboards.py new file mode 100644 index 0000000..35f5df0 --- /dev/null +++ b/app/keyboards/favorites_keyboards.py @@ -0,0 +1,52 @@ +from aiogram.types import KeyboardButton, InlineKeyboardButton +from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder + +from app.callbacks.callback_favorites import AddFavoriteCallback, DelFavoriteCallback +from app.schemas.favorites_schema import FavoritesSchemaOutput +from app.schemas.user_schema import UserSchemaOutput + + +async def favorite_list(favorites: list[FavoritesSchemaOutput]): + builder = ReplyKeyboardBuilder() + + for favorite in favorites: + builder.add( + KeyboardButton(text=f"/command {favorite.command}") + ) + + builder.adjust(2) + return builder.as_markup(resize_keyboard=True, one_time_keyboard=True) + + +async def add_favorite_inline(user: UserSchemaOutput): + builder = InlineKeyboardBuilder() + + builder.add( + InlineKeyboardButton( + text="Добавить команду", + callback_data=AddFavoriteCallback(user_id=user.id).pack() + ) + ) + + builder.adjust(1) + return builder.as_markup() + + +async def add_del_favorite_inline(user: UserSchemaOutput): + builder = InlineKeyboardBuilder() + + builder.add( + InlineKeyboardButton( + text="Добавить команду", + callback_data=AddFavoriteCallback(user_id=user.id).pack() + ) + ) + builder.add( + InlineKeyboardButton( + text="Удалить команду", + callback_data=DelFavoriteCallback(user_id=user.id).pack() + ) + ) + + builder.adjust(1) + return builder.as_markup() diff --git a/app/middlewares/__init__.py b/app/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/middlewares/admin_middleware.py b/app/middlewares/admin_middleware.py new file mode 100644 index 0000000..31555cd --- /dev/null +++ b/app/middlewares/admin_middleware.py @@ -0,0 +1,18 @@ +from typing import Callable, Dict, Any, Awaitable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, User + +from app.settings import secrets + + +class AdminMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + user: User = data.get("event_from_user") + if user.id == secrets.admin_id: + return await handler(event, data) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..5c1623b --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,9 @@ +from .base import Base +from .user import User +from .favorites import Favorites + +__all__ = [ + "Base", + "User", + "Favorites", +] diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..d33df06 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,7 @@ +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + __abstract__ = True + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) diff --git a/app/models/favorites.py b/app/models/favorites.py new file mode 100644 index 0000000..88dd188 --- /dev/null +++ b/app/models/favorites.py @@ -0,0 +1,17 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import String, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + +if TYPE_CHECKING: + from .user import User + + +class Favorites(Base): + __tablename__ = "favorites" + + command: Mapped[str] = mapped_column(String(200), nullable=False) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + user: Mapped["User"] = relationship("User", back_populates="favorites") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..a0e33ea --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,18 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import BigInteger, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + +if TYPE_CHECKING: + from .favorites import Favorites + + +class User(Base): + __tablename__ = "user" + + telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + added_at: Mapped[datetime] = mapped_column(server_default=func.now(), default=datetime.now) + favorites: Mapped["Favorites"] = relationship("Favorites", back_populates="user") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/favorites_schema.py b/app/schemas/favorites_schema.py new file mode 100644 index 0000000..e401219 --- /dev/null +++ b/app/schemas/favorites_schema.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class FavoritesSchemaInput(BaseModel): + command: str + user_id: int + + +class FavoritesSchemaOutput(FavoritesSchemaInput): + id: int diff --git a/app/schemas/user_schema.py b/app/schemas/user_schema.py new file mode 100644 index 0000000..510dfa5 --- /dev/null +++ b/app/schemas/user_schema.py @@ -0,0 +1,12 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class UserSchemaInput(BaseModel): + telegram_id: int + + +class UserSchemaOutput(UserSchemaInput): + id: int + added_at: datetime diff --git a/app/settings.py b/app/settings.py index aabd21a..2338489 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,16 +1,25 @@ import os -from dataclasses import dataclass +from typing import Union from aiogram import Bot -from dotenv import load_dotenv - -load_dotenv() +from pydantic import SecretStr +from pydantic_settings import BaseSettings +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker -@dataclass -class Secrets: - token: str = os.environ.get("token") - admin_id: int = os.environ.get("admin_id") +class Secrets(BaseSettings): + token: SecretStr + admin_id: Union[SecretStr.get_secret_value, int] + db_url: str = "sqlite+aiosqlite:///db.sqlite3" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" -bot = Bot(token=Secrets.token) +secrets = Secrets() + +engine = create_async_engine(url=secrets.db_url) +sessionmaker = async_sessionmaker(engine, expire_on_commit=False, autocommit=False) + +bot = Bot(token=secrets.token.get_secret_value()) diff --git a/app/utils/db_actions.py b/app/utils/db_actions.py new file mode 100644 index 0000000..481cfb2 --- /dev/null +++ b/app/utils/db_actions.py @@ -0,0 +1,50 @@ +from sqlalchemy import select, insert, delete + +from app.models import User, Favorites +from app.schemas.favorites_schema import FavoritesSchemaOutput +from app.schemas.user_schema import UserSchemaOutput +from app.settings import sessionmaker + + +async def get_user(telegram_id: int): + async with sessionmaker() as session: + query = select(User).where(User.telegram_id == telegram_id) + result = await session.execute(query) + user = result.scalar_one_or_none() + if user: + return UserSchemaOutput(**user.__dict__) + return user + + +async def create_user(telegram_id: int): + async with sessionmaker() as session: + query = insert(User).values(telegram_id=telegram_id).returning(User.id, User.telegram_id, User.added_at) + result = await session.execute(query) + await session.commit() + user = result.mappings().first() + return UserSchemaOutput(**user) + + +async def get_favorites(user_id: int): + async with sessionmaker() as session: + query = select(Favorites).where(Favorites.user_id == user_id) + result = await session.execute(query) + favorites = result.scalars().all() + return [FavoritesSchemaOutput(**favorite.__dict__) for favorite in favorites] + + +async def add_favorite(user_id: int, command: str): + async with sessionmaker() as session: + query = insert(Favorites).values(user_id=user_id, command=command).returning(Favorites.id, Favorites.user_id, + Favorites.command) + result = await session.execute(query) + await session.commit() + favorite = result.mappings().first() + return FavoritesSchemaOutput(**favorite) + + +async def del_favorite(favorite_id: int): + async with sessionmaker() as session: + query = delete(Favorites).where(Favorites.id == favorite_id) + await session.execute(query) + await session.commit() diff --git a/app/utils/statesform.py b/app/utils/statesform.py index 0e6547f..3794eb6 100644 --- a/app/utils/statesform.py +++ b/app/utils/statesform.py @@ -3,3 +3,8 @@ from aiogram.fsm.state import StatesGroup, State class ExecuteCommandsSteps(StatesGroup): EXECUTE = State() + + +class FavoritesCommandsSteps(StatesGroup): + ADD = State() + DEL = State() \ No newline at end of file diff --git a/app/utils/text_splitter.py b/app/utils/text_splitter.py new file mode 100644 index 0000000..6cbb931 --- /dev/null +++ b/app/utils/text_splitter.py @@ -0,0 +1,20 @@ +import re + + +def split_text(message: str): + messages = [] + lines = re.split(r"\n", message) + + temp = "" + + for line in lines: + if len(temp) + len(line) < 4096: + temp += line + "\n" + else: + messages.append(temp) + temp = line + "\n" + + if temp: + messages.append(temp) + + return messages \ No newline at end of file diff --git a/app/views.py b/app/views.py index 8633e9e..013312a 100644 --- a/app/views.py +++ b/app/views.py @@ -5,6 +5,12 @@ menu = { "docker_list": "Список Docker-контейнеров", } +actions = { + "start": "Контейнер {} успешно запущен", + "stop": "Контейнер {} успешно остановлен", + "restart": "Контейнер {} успешно перезапущен", + "delete": "Контейнер {} успешно удалён", +} def start_bot_message(): return "Бот запущен" diff --git a/main.py b/main.py index 72e5283..df4ddd4 100644 --- a/main.py +++ b/main.py @@ -3,18 +3,25 @@ import asyncio from aiogram import Dispatcher, F from aiogram.filters import Command +from app.callbacks.callback_docker import ContainerCallback, ActionCallback +from app.callbacks.callback_favorites import AddFavoriteCallback, DelFavoriteCallback from app.handlers.docker_commands import get_containers, container_actions, do_container_action from app.handlers.events import start_bot, stop_bot +from app.handlers.favorites import favorites_command, add_favorite_callback, add_favorite_state, del_favorite_callback, \ + del_favorite_state from app.handlers.multiply_commands import multiply_commands, execute_commands from app.handlers.simple import start_command from app.handlers.single_command import execute_command +from app.middlewares.admin_middleware import AdminMiddleware from app.settings import bot -from app.utils.statesform import ExecuteCommandsSteps +from app.utils.statesform import ExecuteCommandsSteps, FavoritesCommandsSteps async def start(): dp = Dispatcher() + dp.update.middleware(AdminMiddleware()) + dp.startup.register(start_bot) dp.shutdown.register(stop_bot) @@ -22,11 +29,16 @@ async def start(): dp.message.register(execute_command, Command(commands="command")) dp.message.register(multiply_commands, Command(commands="multiply_commands")) dp.message.register(get_containers, Command(commands="docker_list")) + dp.message.register(favorites_command, Command(commands="favorites")) - dp.callback_query.register(container_actions, F.data.startswith("container")) - dp.callback_query.register(do_container_action, F.data.startswith("action")) + dp.callback_query.register(container_actions, ContainerCallback.filter()) + dp.callback_query.register(do_container_action, ActionCallback.filter()) + dp.callback_query.register(add_favorite_callback, AddFavoriteCallback.filter()) + dp.callback_query.register(del_favorite_callback, DelFavoriteCallback.filter()) dp.message.register(execute_commands, ExecuteCommandsSteps.EXECUTE) + dp.message.register(add_favorite_state, FavoritesCommandsSteps.ADD) + dp.message.register(del_favorite_state, FavoritesCommandsSteps.DEL) try: await dp.start_polling(bot)