diff --git a/config.example.toml b/config.example.toml index a85fbf9..3600c62 100644 --- a/config.example.toml +++ b/config.example.toml @@ -2,6 +2,10 @@ token = "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" creator_id = 123456789 +[security] +test_hash_salt = "your_secret_salt_here_change_in_production" +test_hash_length = 16 + [database] host = "localhost" port = 5432 diff --git a/main.py b/main.py deleted file mode 100644 index 3030c16..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from trudex!") - - -if __name__ == "__main__": - main() diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 054d2ce..8ed7b1f 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -78,9 +78,11 @@ async def main() -> None: router.message.middleware(RejectNotAdminMiddleware()) router.message.middleware(RejectNotCreatorMiddleware()) - container = make_async_container(DatabaseProvider()) + container = make_async_container(DatabaseProvider(), context={Bot: bot, Config: config}) setup_dialogs(dp) setup_dishka(container, dp, auto_inject=True) + + bott = await container.get(Bot) async with container() as request_container: user_repo = await request_container.get(UserRepository) diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index ca0540e..9ce9066 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -14,7 +14,9 @@ class AdminUsersSG(StatesGroup): class AdminTestsSG(StatesGroup): tests_list = State() test_detail = State() + share_test = State() edit_password = State() + edit_attempts = State() edit_group = State() edit_expires = State() diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 9ce003e..2d73fda 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -1,5 +1,6 @@ from datetime import date, datetime +from aiogram import Bot from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput @@ -14,6 +15,8 @@ from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.utils.config import Config +from trudex.infrastructure.utils.test_id_to_hash import generate_alpha_id @inject @@ -57,6 +60,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" password_str = f"🔒 {test.password}" if test.password else "🔓 Без пароля" + attempts_str = f"🔄 {test.attempts}" if test.attempts else "♾️ Без ограничений" expires_str = test.expires_at.strftime("%d.%m.%Y %H:%M") if test.expires_at else "♾️ Без срока" group_str = f"🎓 Группа {test.for_group}" if test.for_group else "👥 Для всех" @@ -67,6 +71,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T f"Статус: {status}\n" f"Вопросов: {questions_count}\n" f"{password_str}\n" + f"Попыток: {attempts_str}\n" f"{expires_str}\n" f"{group_str}\n\n" f"Создан: {test.created_at.strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" @@ -101,10 +106,38 @@ async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(AdminTestsSG.tests_list) +async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.share_test) + + +@inject +async def get_share_link(dialog_manager: DialogManager, config: FromDishka[Config], bot: FromDishka[Bot], **_kwargs): + test_id = dialog_manager.dialog_data.get("selected_test_id") + + if not test_id: + return {"share_link": "Ошибка: тест не найден"} + + test_hash = generate_alpha_id( + test_id, + config.security.test_hash_salt, + config.security.test_hash_length + ) + + bot_info = await bot.get_me() + bot_username = bot_info.username or "your_bot" + share_link = f"https://t.me/{bot_username}?start={test_hash}" + + return {"share_link": share_link} + + async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(AdminTestsSG.edit_password) +async def on_edit_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.edit_attempts) + + async def on_edit_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(AdminTestsSG.edit_group) @@ -146,6 +179,50 @@ async def on_remove_password(_callback: CallbackQuery, _button: Button, manager: await manager.switch_to(AdminTestsSG.test_detail) +@inject +async def on_attempts_input_edit(message: Message, _widget: MessageInput, manager: DialogManager, test_dao: FromDishka[TestDAO]): + test_id = manager.dialog_data.get("selected_test_id") + if not test_id: + await message.answer("❌ Тест не найден") + return + + if not message.text: + await message.answer("❌ Количество попыток не может быть пустым") + return + + attempts_str = message.text.strip() + + if not attempts_str.isdigit(): + await message.answer("❌ Количество попыток должно быть числом") + return + + attempts = int(attempts_str) + + if attempts < 1: + await message.answer("❌ Количество попыток должно быть больше 0") + return + + if attempts > 100: + await message.answer("❌ Количество попыток не может быть больше 100") + return + + await test_dao.update(test_id, attempts=attempts) + await message.answer("✅ Количество попыток обновлено") + await manager.switch_to(AdminTestsSG.test_detail) + + +@inject +async def on_remove_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]): + test_id = manager.dialog_data.get("selected_test_id") + if not test_id: + await _callback.answer("❌ Тест не найден") + return + + await test_dao.update(test_id, attempts=None) + await _callback.answer("✅ Ограничение попыток удалено") + await manager.switch_to(AdminTestsSG.test_detail) + + @inject async def get_groups_for_edit(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() @@ -242,7 +319,9 @@ tests_dialog = Dialog( id="toggle_active", on_click=on_toggle_active ), + Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), Button(Const("🔑 Изменить пароль"), id="edit_password", on_click=on_edit_password), + Button(Const("🔄 Изменить попытки"), id="edit_attempts", on_click=on_edit_attempts), Button(Const("👥 Изменить группу"), id="edit_group", on_click=on_edit_group), Button(Const("📅 Изменить срок"), id="edit_expires", on_click=on_edit_expires), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), @@ -250,6 +329,13 @@ tests_dialog = Dialog( state=AdminTestsSG.test_detail, getter=get_test_detail, ), + Window( + Const("🔗 Поделиться тестом\n\n📎 Ссылка на тест:"), + Format("\n{share_link}\n\n💡 Отправьте эту ссылку пользователям для прохождения теста"), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + state=AdminTestsSG.share_test, + getter=get_share_link, + ), Window( Const("🔑 Изменение пароля\n\n💬 Введите новый пароль или удалите текущий:\n(максимум 255 символов)"), MessageInput(on_password_input), @@ -259,6 +345,15 @@ tests_dialog = Dialog( ), state=AdminTestsSG.edit_password, ), + Window( + Const("🔄 Изменение количества попыток\n\n🔢 Введите новое количество попыток (1-100) или удалите ограничение:"), + MessageInput(on_attempts_input_edit), + Column( + Button(Const("🗑 Без ограничений"), id="remove_attempts", on_click=on_remove_attempts), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=AdminTestsSG.edit_attempts, + ), Window( Const("👥 Изменение группы\n\n🎓 Выберите группу или удалите привязку:"), ScrollingGroup( diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index cc82112..95ce762 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -70,26 +70,43 @@ async def on_password_input(message: Message, _widget: MessageInput, manager: Di return manager.dialog_data["password"] = password - - groups = await group_dao.get_all() - - if len(groups) == 0: - manager.dialog_data["for_group"] = None - await manager.switch_to(CreateTestSG.confirm_test_info) - else: - await manager.switch_to(CreateTestSG.input_expires_at) + await manager.switch_to(CreateTestSG.input_attempts) @inject async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager, group_dao: FromDishka[GroupDAO]): manager.dialog_data["password"] = None - groups = await group_dao.get_all() + await manager.switch_to(CreateTestSG.input_attempts) + + +async def on_attempts_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Количество попыток не может быть пустым") + return - if len(groups) == 0: - manager.dialog_data["for_group"] = None - await manager.switch_to(CreateTestSG.confirm_test_info) - else: - await manager.switch_to(CreateTestSG.input_expires_at) + attempts_str = message.text.strip() + + if not attempts_str.isdigit(): + await message.answer("❌ Количество попыток должно быть числом") + return + + attempts = int(attempts_str) + + if attempts < 1: + await message.answer("❌ Количество попыток должно быть больше 0") + return + + if attempts > 100: + await message.answer("❌ Количество попыток не может быть больше 100") + return + + manager.dialog_data["attempts"] = attempts + await manager.switch_to(CreateTestSG.input_expires_at) + + +async def on_skip_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["attempts"] = None + await manager.switch_to(CreateTestSG.input_expires_at) async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): @@ -125,10 +142,12 @@ async def get_test_info(dialog_manager: DialogManager, **_kwargs): title = dialog_manager.dialog_data.get("title", "—") description = dialog_manager.dialog_data.get("description", "—") password = dialog_manager.dialog_data.get("password") + attempts = dialog_manager.dialog_data.get("attempts") expires_at = dialog_manager.dialog_data.get("expires_at") for_group = dialog_manager.dialog_data.get("for_group") password_str = f"🔒 {password}" if password else "Без пароля" + attempts_str = f"🔄 {attempts}" if attempts else "♾️ Без ограничений" expires_str = expires_at.strftime("%d.%m.%Y") if expires_at else "Без срока" group_str = str(for_group) if for_group else "Для всех" @@ -138,6 +157,7 @@ async def get_test_info(dialog_manager: DialogManager, **_kwargs): f"Название: {title}\n" f"Описание: {description}\n" f"Пароль: {password_str}\n" + f"Попыток: {attempts_str}\n" f"Истекает: {expires_str}\n" f"Для группы: {group_str}" ) @@ -147,8 +167,10 @@ async def get_test_info(dialog_manager: DialogManager, **_kwargs): @inject async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]): title = manager.dialog_data.get("title") + assert isinstance(title, str) description = manager.dialog_data.get("description") password = manager.dialog_data.get("password") + attempts = manager.dialog_data.get("attempts") expires_at = manager.dialog_data.get("expires_at") for_group = manager.dialog_data.get("for_group") @@ -156,6 +178,7 @@ async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: Di title=title, description=description, password=password, + attempts=attempts, expires_at=expires_at, for_group=for_group, ) @@ -361,6 +384,7 @@ async def on_save_question( test_repo: FromDishka[TestRepository], ): test_id = manager.dialog_data.get("test_id") + assert isinstance(test_id, int) current_question = manager.dialog_data.get("current_question", {}) current_options = manager.dialog_data.get("current_options", []) @@ -443,6 +467,12 @@ create_test_dialog = Dialog( Button(Const("⏭️ Без пароля"), id="skip_password", on_click=on_skip_password), state=CreateTestSG.input_password, ), + Window( + Const("🔄 Количество попыток\n\n🔢 Введите количество попыток (1-100) или пропустите для неограниченного количества:"), + MessageInput(on_attempts_input), + Button(Const("⏭️ Без ограничений"), id="skip_attempts", on_click=on_skip_attempts), + state=CreateTestSG.input_attempts, + ), Window( Const("📅 Срок действия\n\n🗓 Выберите дату истечения теста или пропустите:"), Calendar(id="calendar", on_click=on_date_selected), diff --git a/src/trudex/application/bot/creator_dialogs/groups.py b/src/trudex/application/bot/creator_dialogs/groups.py index 9642616..a5565bd 100644 --- a/src/trudex/application/bot/creator_dialogs/groups.py +++ b/src/trudex/application/bot/creator_dialogs/groups.py @@ -117,6 +117,8 @@ async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs): @inject async def on_confirm_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager, group_dao: FromDishka[GroupDAO]): group_id = manager.dialog_data.get("delete_group_id") + + assert isinstance(group_id, int) await group_dao.delete(group_id) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index d237f72..071c43d 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -15,7 +15,9 @@ class CreatorUsersSG(StatesGroup): class CreatorTestsSG(StatesGroup): tests_list = State() test_detail = State() + share_test = State() edit_password = State() + edit_attempts = State() edit_group = State() edit_expires = State() @@ -36,6 +38,7 @@ class CreateTestSG(StatesGroup): input_title = State() input_description = State() input_password = State() + input_attempts = State() input_expires_at = State() input_for_group = State() confirm_test_info = State() diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index c09bb8d..6742751 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -1,5 +1,6 @@ from datetime import date, datetime +from aiogram import Bot from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput @@ -15,6 +16,8 @@ from trudex.application.bot.creator_dialogs.states import (CreateTestSG, from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.utils.config import Config +from trudex.infrastructure.utils.test_id_to_hash import generate_alpha_id @inject @@ -58,6 +61,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" password_str = f"🔒 {test.password}" if test.password else "🔓 Без пароля" + attempts_str = f"🔄 {test.attempts}" if test.attempts else "♾️ Без ограничений" expires_str = test.expires_at.strftime("%d.%m.%Y %H:%M") if test.expires_at else "♾️ Без срока" group_str = f"🎓 Группа {test.for_group}" if test.for_group else "👥 Для всех" @@ -68,6 +72,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T f"Статус: {status}\n" f"Вопросов: {questions_count}\n" f"{password_str}\n" + f"Попыток: {attempts_str}\n" f"{expires_str}\n" f"{group_str}\n\n" f"Создан: {test.created_at.strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" @@ -102,10 +107,38 @@ async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(CreatorTestsSG.tests_list) +async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.share_test) + + +@inject +async def get_share_link(dialog_manager: DialogManager, config: FromDishka[Config], **_kwargs): + test_id = dialog_manager.dialog_data.get("selected_test_id") + if not test_id: + return {"share_link": "Ошибка: тест не найден"} + + test_hash = generate_alpha_id( + test_id, + config.security.test_hash_salt, + config.security.test_hash_length + ) + bot = Bot(config.bot.token) + bot_info = await bot.get_me() + await bot.session.close() + bot_username = bot_info.username or "your_bot" + share_link = f"https://t.me/{bot_username}?start={test_hash}" + + return {"share_link": share_link} + + async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(CreatorTestsSG.edit_password) +async def on_edit_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.edit_attempts) + + async def on_edit_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(CreatorTestsSG.edit_group) @@ -147,6 +180,50 @@ async def on_remove_password(_callback: CallbackQuery, _button: Button, manager: await manager.switch_to(CreatorTestsSG.test_detail) +@inject +async def on_attempts_input_edit(message: Message, _widget: MessageInput, manager: DialogManager, test_dao: FromDishka[TestDAO]): + test_id = manager.dialog_data.get("selected_test_id") + if not test_id: + await message.answer("❌ Тест не найден") + return + + if not message.text: + await message.answer("❌ Количество попыток не может быть пустым") + return + + attempts_str = message.text.strip() + + if not attempts_str.isdigit(): + await message.answer("❌ Количество попыток должно быть числом") + return + + attempts = int(attempts_str) + + if attempts < 1: + await message.answer("❌ Количество попыток должно быть больше 0") + return + + if attempts > 100: + await message.answer("❌ Количество попыток не может быть больше 100") + return + + await test_dao.update(test_id, attempts=attempts) + await message.answer("✅ Количество попыток обновлено") + await manager.switch_to(CreatorTestsSG.test_detail) + + +@inject +async def on_remove_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]): + test_id = manager.dialog_data.get("selected_test_id") + if not test_id: + await _callback.answer("❌ Тест не найден") + return + + await test_dao.update(test_id, attempts=None) + await _callback.answer("✅ Ограничение попыток удалено") + await manager.switch_to(CreatorTestsSG.test_detail) + + @inject async def get_groups_for_edit(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() @@ -243,7 +320,9 @@ tests_dialog = Dialog( id="toggle_active", on_click=on_toggle_active ), + Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), Button(Const("🔑 Изменить пароль"), id="edit_password", on_click=on_edit_password), + Button(Const("🔄 Изменить попытки"), id="edit_attempts", on_click=on_edit_attempts), Button(Const("👥 Изменить группу"), id="edit_group", on_click=on_edit_group), Button(Const("📅 Изменить срок"), id="edit_expires", on_click=on_edit_expires), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), @@ -251,6 +330,13 @@ tests_dialog = Dialog( state=CreatorTestsSG.test_detail, getter=get_test_detail, ), + Window( + Const("🔗 Поделиться тестом\n\n📎 Ссылка на тест:"), + Format("\n{share_link}\n\n💡 Отправьте эту ссылку пользователям для прохождения теста"), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + state=CreatorTestsSG.share_test, + getter=get_share_link, + ), Window( Const("🔑 Изменение пароля\n\n💬 Введите новый пароль или удалите текущий:\n(максимум 255 символов)"), MessageInput(on_password_input), @@ -260,6 +346,15 @@ tests_dialog = Dialog( ), state=CreatorTestsSG.edit_password, ), + Window( + Const("🔄 Изменение количества попыток\n\n🔢 Введите новое количество попыток (1-100) или удалите ограничение:"), + MessageInput(on_attempts_input_edit), + Column( + Button(Const("🗑 Без ограничений"), id="remove_attempts", on_click=on_remove_attempts), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=CreatorTestsSG.edit_attempts, + ), Window( Const("👥 Изменение группы\n\n🎓 Выберите группу или удалите привязку:"), ScrollingGroup( diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index 536735d..a9c3e20 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -19,10 +19,6 @@ from trudex.infrastructure.utils.config import Config class DatabaseProvider(Provider): - @provide(scope=Scope.APP) - def get_config(self) -> Config: - return Config.from_toml("config.toml") - @provide(scope=Scope.APP) def get_session_maker(self, config: Config) -> async_sessionmaker[AsyncSession]: return new_session_maker(config.database.url) diff --git a/src/trudex/infrastructure/utils/config.py b/src/trudex/infrastructure/utils/config.py index 5ffb45c..7fb8aa8 100644 --- a/src/trudex/infrastructure/utils/config.py +++ b/src/trudex/infrastructure/utils/config.py @@ -10,6 +10,12 @@ class BotConfig: creator_id: int +@dataclass +class SecurityConfig: + test_hash_salt: str + test_hash_length: int = 16 + + @dataclass class DatabaseConfig: host: str @@ -27,6 +33,7 @@ class DatabaseConfig: class Config: bot: BotConfig database: DatabaseConfig + security: SecurityConfig @classmethod def from_toml(cls, path: str | Path) -> Self: @@ -35,6 +42,7 @@ class Config: bot_data: dict[str, str | int] = data["bot"] db_data: dict[str, str | int] = data["database"] + security_data: dict[str, str | int] = data["security"] return cls( bot=BotConfig( @@ -47,5 +55,9 @@ class Config: user=str(db_data["user"]), password=str(db_data["password"]), database=str(db_data["database"]) + ), + security=SecurityConfig( + test_hash_salt=str(security_data["test_hash_salt"]), + test_hash_length=int(security_data.get("test_hash_length", 16)) ) ) diff --git a/src/trudex/infrastructure/utils/test_id_to_hash.py b/src/trudex/infrastructure/utils/test_id_to_hash.py new file mode 100644 index 0000000..41ac700 --- /dev/null +++ b/src/trudex/infrastructure/utils/test_id_to_hash.py @@ -0,0 +1,25 @@ +import hashlib +import hmac +import string + + +def generate_alpha_id(n: int, secret_key: str, length: int = 16) -> str: + data = str(n).encode('utf-8') + key = secret_key.encode('utf-8') + + digest = hmac.new(key, data, hashlib.sha256).digest() + num = int.from_bytes(digest, byteorder='big') + + alphabet = string.ascii_letters + result = [] + + while num > 0: + num, rem = divmod(num, 52) + result.append(alphabet[rem]) + + encoded = "".join(result) + + if len(encoded) < length: + encoded = encoded.ljust(length, alphabet[0]) + + return encoded[:length]