From 7d2a734b7d7d0c800c155ad4ebe40b9725dc3d11 Mon Sep 17 00:00:00 2001 From: kolo Date: Tue, 6 Jan 2026 19:35:49 +0300 Subject: [PATCH] update --- .../b1c2d3e4f5a6_add_time_limit_to_test.py | 26 ++++++++ config.example.toml | 13 ++-- .../bot/shared_dialogs/create_test.py | 43 ++++++++++++- .../application/bot/shared_dialogs/states.py | 2 + .../bot/shared_dialogs/templates.py | 7 +++ .../application/bot/shared_dialogs/tests.py | 62 +++++++++++++++++++ .../application/bot/user_dialogs/deeplink.py | 4 +- .../application/bot/user_dialogs/main_menu.py | 2 + src/quizzi/domain/schemas.py | 1 + src/quizzi/domain/test_parser.py | 3 + .../infrastructure/database/dao/test.py | 6 ++ .../infrastructure/database/dto/test.py | 1 + src/quizzi/infrastructure/database/models.py | 1 + 13 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 alembic/versions/b1c2d3e4f5a6_add_time_limit_to_test.py diff --git a/alembic/versions/b1c2d3e4f5a6_add_time_limit_to_test.py b/alembic/versions/b1c2d3e4f5a6_add_time_limit_to_test.py new file mode 100644 index 0000000..ff7e4b1 --- /dev/null +++ b/alembic/versions/b1c2d3e4f5a6_add_time_limit_to_test.py @@ -0,0 +1,26 @@ +"""add time_limit to test + +Revision ID: b1c2d3e4f5a6 +Revises: ca107b03ddf8 +Create Date: 2026-01-06 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b1c2d3e4f5a6' +down_revision: Union[str, None] = 'ca107b03ddf8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('tests', sa.Column('time_limit', sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('tests', 'time_limit') diff --git a/config.example.toml b/config.example.toml index 3600c62..96497a4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,14 +1,13 @@ [bot] -token = "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" -creator_id = 123456789 +token = "1234567890" +creator_id = 1234567890 [security] -test_hash_salt = "your_secret_salt_here_change_in_production" -test_hash_length = 16 +encode_key = "encode_key" [database] host = "localhost" port = 5432 -user = "trudex_user" -password = "secure_password" -database = "trudex_db" +user = "postgres" +password = "passkey" +database = "trudex" diff --git a/src/quizzi/application/bot/shared_dialogs/create_test.py b/src/quizzi/application/bot/shared_dialogs/create_test.py index 268030f..1ffcdde 100644 --- a/src/quizzi/application/bot/shared_dialogs/create_test.py +++ b/src/quizzi/application/bot/shared_dialogs/create_test.py @@ -100,11 +100,41 @@ async def on_attempts_input(message: Message, _widget: MessageInput, manager: Di return manager.dialog_data["attempts"] = attempts - await manager.switch_to(SharedCreateTestSG.input_expires_at) + await manager.switch_to(SharedCreateTestSG.input_time_limit) async def on_skip_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager): manager.dialog_data["attempts"] = None + await manager.switch_to(SharedCreateTestSG.input_time_limit) + + +async def on_time_limit_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Лимит времени не может быть пустым") + return + + time_limit_str = message.text.strip() + + if not time_limit_str.isdigit(): + await message.answer("❌ Лимит времени должен быть числом (в минутах)") + return + + time_limit_minutes = int(time_limit_str) + + if time_limit_minutes < 1: + await message.answer("❌ Лимит времени должен быть больше 0") + return + + if time_limit_minutes > 1440: + await message.answer("❌ Лимит времени не может быть больше 1440 минут (24 часа)") + return + + manager.dialog_data["time_limit"] = time_limit_minutes * 60 + await manager.switch_to(SharedCreateTestSG.input_expires_at) + + +async def on_skip_time_limit(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["time_limit"] = None await manager.switch_to(SharedCreateTestSG.input_expires_at) @@ -139,11 +169,13 @@ async def get_test_info(dialog_manager: DialogManager, **_kwargs): description = dialog_manager.dialog_data.get("description", "—") password = dialog_manager.dialog_data.get("password") attempts = dialog_manager.dialog_data.get("attempts") + time_limit = dialog_manager.dialog_data.get("time_limit") 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 "♾️ Без ограничений" + time_limit_str = f"⏱️ {time_limit // 60} мин" if time_limit else "⏱️ Без лимита" expires_at_msk = to_msk(expires_at) expires_str = expires_at_msk.strftime("%d.%m.%Y") if expires_at_msk else "Без срока" group_str = str(for_group) if for_group else "Для всех" @@ -155,6 +187,7 @@ async def get_test_info(dialog_manager: DialogManager, **_kwargs): f"Описание: {description}\n" f"Пароль: {password_str}\n" f"Попыток: {attempts_str}\n" + f"Время: {time_limit_str}\n" f"Истекает: {expires_str}\n" f"Для группы: {group_str}" ) @@ -168,6 +201,7 @@ async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: Di description = manager.dialog_data.get("description") password = manager.dialog_data.get("password") attempts = manager.dialog_data.get("attempts") + time_limit = manager.dialog_data.get("time_limit") expires_at = manager.dialog_data.get("expires_at") for_group = manager.dialog_data.get("for_group") @@ -176,6 +210,7 @@ async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: Di description=description, password=password, attempts=attempts, + time_limit=time_limit, expires_at=expires_at, for_group=for_group, ) @@ -470,6 +505,12 @@ shared_create_test_dialog = Dialog( Button(Const("⏭️ Без ограничений"), id="skip_attempts", on_click=on_skip_attempts), state=SharedCreateTestSG.input_attempts, ), + Window( + Const("⏱️ Лимит времени\n\n🔢 Введите лимит времени в минутах (1-1440) или пропустите для неограниченного времени:"), + MessageInput(on_time_limit_input), + Button(Const("⏭️ Без лимита"), id="skip_time_limit", on_click=on_skip_time_limit), + state=SharedCreateTestSG.input_time_limit, + ), Window( Const("📅 Срок действия\n\n🗓 Выберите дату истечения теста или пропустите:"), Calendar(id="calendar", on_click=on_date_selected), diff --git a/src/quizzi/application/bot/shared_dialogs/states.py b/src/quizzi/application/bot/shared_dialogs/states.py index 3f6255c..3a8e41e 100644 --- a/src/quizzi/application/bot/shared_dialogs/states.py +++ b/src/quizzi/application/bot/shared_dialogs/states.py @@ -15,6 +15,7 @@ class SharedTestsSG(StatesGroup): edit_menu = State() edit_password = State() edit_attempts = State() + edit_time_limit = State() edit_group = State() edit_expires = State() statistics = State() @@ -38,6 +39,7 @@ class SharedCreateTestSG(StatesGroup): input_description = State() input_password = State() input_attempts = State() + input_time_limit = State() input_expires_at = State() input_for_group = State() confirm_test_info = State() diff --git a/src/quizzi/application/bot/shared_dialogs/templates.py b/src/quizzi/application/bot/shared_dialogs/templates.py index 2be5ec3..0f4699e 100644 --- a/src/quizzi/application/bot/shared_dialogs/templates.py +++ b/src/quizzi/application/bot/shared_dialogs/templates.py @@ -36,6 +36,7 @@ SPEC_INFO = """📋 Спецификация формата JSON "description": "Описание теста", "password": null, "attempts": null, + "time_limit": null, "expires_at": null, "for_group": null, "questions": [...] @@ -46,6 +47,7 @@ SPEC_INFO = """📋 Спецификация формата JSONdescription — описание (до 2000 символов) • password — пароль для доступа или nullattempts — лимит попыток (1-100) или null +• time_limit — лимит времени в секундах (1-86400) или nullexpires_at — срок действия в ISO формате или nullfor_group — номер группы или null для всех @@ -90,6 +92,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════ // ⚙️ НАСТРОЙКИ: // • Пароль: test2024 // • Попыток: 5 +// • Лимит времени: 1800 секунд (30 минут) // • Срок действия: 31 декабря 2026, 23:59 // • Для группы: 2024 (или null для всех) // @@ -104,6 +107,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════ // 💡 ПОДСКАЗКИ: // • null означает "не задано" / "без ограничений" // • expires_at в формате ISO 8601: YYYY-MM-DDTHH:MM:SS +// • time_limit в секундах (1-86400), null для без ограничений // • for_group - номер группы или null для всех пользователей // • image_url - URL изображения к вопросу (опционально) // @@ -114,6 +118,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════ "description": "Полная демонстрация всех возможностей формата: разные типы вопросов, настройки доступа, ограничения по времени и группам", "password": "test2024", "attempts": 5, + "time_limit": 1800, "expires_at": "2026-12-31T23:59:59", "for_group": 2024, "questions": [ @@ -225,6 +230,7 @@ async def on_test_selected_for_export( "description": test.description, "password": test.password, "attempts": test.attempts, + "time_limit": test.time_limit, "expires_at": test.expires_at.isoformat() if test.expires_at else None, "for_group": test.for_group, "questions": [], @@ -340,6 +346,7 @@ async def create_test_from_parsed( description=parsed.description, password=parsed.password, attempts=parsed.attempts, + time_limit=parsed.time_limit, expires_at=parsed.expires_at, for_group=parsed.for_group, is_active=False, diff --git a/src/quizzi/application/bot/shared_dialogs/tests.py b/src/quizzi/application/bot/shared_dialogs/tests.py index 93a45ca..14c7e2c 100644 --- a/src/quizzi/application/bot/shared_dialogs/tests.py +++ b/src/quizzi/application/bot/shared_dialogs/tests.py @@ -68,6 +68,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 "♾️ Без ограничений" + time_limit_str = f"⏱️ {test.time_limit // 60} мин" if test.time_limit else "⏱️ Без лимита" expires_str = f"📅 {to_msk(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 "👥 Для всех" results_str = "👁 Результаты видны" if test.are_results_viewable else "🔒 Результаты скрыты" @@ -80,6 +81,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T f"Вопросов: {questions_count}\n" f"Пароль: {password_str}\n" f"Попытки: {attempts_str}\n" + f"Время: {time_limit_str}\n" f"Срок: {expires_str}\n" f"Группа: {group_str}\n" f"Видимость: {results_str}\n\n" @@ -254,6 +256,7 @@ async def on_export_test( "description": test.description, "password": test.password, "attempts": test.attempts, + "time_limit": test.time_limit, "expires_at": test.expires_at.isoformat() if test.expires_at else None, "for_group": test.for_group, "questions": [], @@ -361,6 +364,10 @@ async def on_edit_attempts(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(SharedTestsSG.edit_attempts) +async def on_edit_time_limit(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(SharedTestsSG.edit_time_limit) + + async def on_edit_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(SharedTestsSG.edit_group) @@ -446,6 +453,51 @@ async def on_remove_attempts(_callback: CallbackQuery, _button: Button, manager: await manager.switch_to(SharedTestsSG.test_detail) +@inject +async def on_time_limit_input(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 + + time_limit_str = message.text.strip() + + if not time_limit_str.isdigit(): + await message.answer("❌ Лимит времени должен быть числом (в минутах)") + return + + time_limit_minutes = int(time_limit_str) + + if time_limit_minutes < 1: + await message.answer("❌ Лимит времени должен быть больше 0") + return + + if time_limit_minutes > 1440: + await message.answer("❌ Лимит времени не может быть больше 1440 минут (24 часа)") + return + + time_limit_seconds = time_limit_minutes * 60 + await test_dao.update(test_id, time_limit=time_limit_seconds) + await message.answer("✅ Лимит времени обновлен") + await manager.switch_to(SharedTestsSG.test_detail) + + +@inject +async def on_remove_time_limit(_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, time_limit=None) + await _callback.answer("✅ Лимит времени удален") + await manager.switch_to(SharedTestsSG.test_detail) + + @inject async def get_groups_for_edit(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() @@ -561,6 +613,7 @@ shared_tests_dialog = Dialog( Column( Button(Const("🔑 Пароль"), id="edit_password", on_click=on_edit_password), Button(Const("🔄 Попытки"), id="edit_attempts", on_click=on_edit_attempts), + Button(Const("⏱️ Лимит времени"), id="edit_time_limit", on_click=on_edit_time_limit), 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_detail), @@ -585,6 +638,15 @@ shared_tests_dialog = Dialog( ), state=SharedTestsSG.edit_attempts, ), + Window( + Const("⏱️ Изменение лимита времени\n\n🔢 Введите лимит времени в минутах (1-1440) или удалите ограничение:"), + MessageInput(on_time_limit_input), + Column( + Button(Const("🗑 Без лимита"), id="remove_time_limit", on_click=on_remove_time_limit), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), + ), + state=SharedTestsSG.edit_time_limit, + ), Window( Const("👥 Изменение группы\n\n🎓 Выберите группу или удалите привязку:"), ScrollingGroup( diff --git a/src/quizzi/application/bot/user_dialogs/deeplink.py b/src/quizzi/application/bot/user_dialogs/deeplink.py index 74dcb61..30e1d3e 100644 --- a/src/quizzi/application/bot/user_dialogs/deeplink.py +++ b/src/quizzi/application/bot/user_dialogs/deeplink.py @@ -41,13 +41,15 @@ async def get_deeplink_test_data( password_str = "🔒 Требуется пароль" if test.password else "🔓 Без пароля" attempts_str = f"🔄 Попыток: {test.attempts}" if test.attempts else "🔄 Попыток: ♾️" + time_limit_str = f"⏱️ Время: {test.time_limit // 60} мин" if test.time_limit else "⏱️ Без лимита" test_info = ( f"📝 {test.title}\n\n" f"
{test.description or '—'}
\n\n" f"Вопросов: {questions_count}\n" f"{password_str}\n" - f"{attempts_str}" + f"{attempts_str}\n" + f"{time_limit_str}" ) return {"test_info": test_info, "can_start": True, "has_password": bool(test.password)} diff --git a/src/quizzi/application/bot/user_dialogs/main_menu.py b/src/quizzi/application/bot/user_dialogs/main_menu.py index 08de0fb..5bc8895 100644 --- a/src/quizzi/application/bot/user_dialogs/main_menu.py +++ b/src/quizzi/application/bot/user_dialogs/main_menu.py @@ -278,6 +278,7 @@ async def get_test_detail( password_str = "🔒 Требуется пароль" if test.password else "🔓 Без пароля" attempts_str = f"🔄 Попыток: {len(finished_attempts)}/{test.attempts}" if test.attempts else f"🔄 Попыток: {len(finished_attempts)}/♾️" + time_limit_str = f"⏱️ Время: {test.time_limit // 60} мин" if test.time_limit else "⏱️ Без лимита" expires_at_msk = to_msk(test.expires_at) expires_str = f"📅 До {expires_at_msk.strftime('%d.%m.%Y %H:%M')}" if expires_at_msk else "📅 Без срока" @@ -289,6 +290,7 @@ async def get_test_detail( f"Вопросов: {len(questions)}\n" f"{password_str}\n" f"{attempts_str}\n" + f"{time_limit_str}\n" f"{expires_str}\n" f"{group_str}" ) diff --git a/src/quizzi/domain/schemas.py b/src/quizzi/domain/schemas.py index 74db228..9573300 100644 --- a/src/quizzi/domain/schemas.py +++ b/src/quizzi/domain/schemas.py @@ -41,6 +41,7 @@ class Test: password: str | None = None expires_at: datetime | None = None attempts: int | None = None + time_limit: int | None = None is_active: bool = True are_results_viewable: bool = False created_at: datetime | None = None diff --git a/src/quizzi/domain/test_parser.py b/src/quizzi/domain/test_parser.py index fa72f6f..7a7df35 100644 --- a/src/quizzi/domain/test_parser.py +++ b/src/quizzi/domain/test_parser.py @@ -24,6 +24,7 @@ class ParsedTest: description: str | None password: str | None attempts: int | None + time_limit: int | None expires_at: datetime | None for_group: int | None questions: list[ParsedQuestion] @@ -53,6 +54,7 @@ class TestParser: description = self._parse_string(data, "description", required=False, max_length=2000, errors=errors) password = self._parse_string(data, "password", required=False, max_length=255, errors=errors) attempts = self._parse_int(data, "attempts", required=False, min_val=1, max_val=100, errors=errors) + time_limit = self._parse_int(data, "time_limit", required=False, min_val=1, max_val=86400, errors=errors) expires_at = self._parse_datetime(data, "expires_at", required=False, errors=errors) for_group = self._parse_int(data, "for_group", required=False, errors=errors) @@ -68,6 +70,7 @@ class TestParser: description=description, password=password, attempts=attempts, + time_limit=time_limit, expires_at=expires_at, for_group=for_group, questions=questions, diff --git a/src/quizzi/infrastructure/database/dao/test.py b/src/quizzi/infrastructure/database/dao/test.py index 5917259..eeec9ab 100644 --- a/src/quizzi/infrastructure/database/dao/test.py +++ b/src/quizzi/infrastructure/database/dao/test.py @@ -24,6 +24,7 @@ class TestUpdateFields(TypedDict, total=False): password: str | None expires_at: datetime | None attempts: int | None + time_limit: int | None is_active: bool are_results_viewable: bool @@ -64,6 +65,7 @@ class TestDAO: password: str | None = None, expires_at: datetime | None = None, attempts: int | None = None, + time_limit: int | None = None, is_active: bool = True, are_results_viewable: bool = False, ) -> DomainTest: @@ -74,6 +76,7 @@ class TestDAO: password=password, expires_at=expires_at, attempts=attempts, + time_limit=time_limit, is_active=is_active, are_results_viewable=are_results_viewable, ) @@ -91,6 +94,7 @@ class TestDAO: password: str | None | _UNSET = UNSET, expires_at: datetime | None | _UNSET = UNSET, attempts: int | None | _UNSET = UNSET, + time_limit: int | None | _UNSET = UNSET, is_active: bool | _UNSET = UNSET, are_results_viewable: bool | _UNSET = UNSET, ) -> DomainTest | None: @@ -113,6 +117,8 @@ class TestDAO: test.expires_at = expires_at if not isinstance(attempts, _UNSET): test.attempts = attempts + if not isinstance(time_limit, _UNSET): + test.time_limit = time_limit if not isinstance(is_active, _UNSET): test.is_active = is_active if not isinstance(are_results_viewable, _UNSET): diff --git a/src/quizzi/infrastructure/database/dto/test.py b/src/quizzi/infrastructure/database/dto/test.py index 796ff30..cb6edd0 100644 --- a/src/quizzi/infrastructure/database/dto/test.py +++ b/src/quizzi/infrastructure/database/dto/test.py @@ -15,6 +15,7 @@ class TestDTO: password=self.model.password, expires_at=self.model.expires_at, attempts=self.model.attempts, + time_limit=self.model.time_limit, is_active=self.model.is_active, are_results_viewable=self.model.are_results_viewable, created_at=self.model.created_at, diff --git a/src/quizzi/infrastructure/database/models.py b/src/quizzi/infrastructure/database/models.py index cef9774..af8dc9c 100644 --- a/src/quizzi/infrastructure/database/models.py +++ b/src/quizzi/infrastructure/database/models.py @@ -52,6 +52,7 @@ class Test(Base): password: Mapped[str | None] = mapped_column(String(255), default=None) expires_at: Mapped[datetime | None] = mapped_column(default=None) attempts: Mapped[int | None] = mapped_column(Integer, default=None) + time_limit: Mapped[int | None] = mapped_column(Integer, default=None) is_active: Mapped[bool] = mapped_column(default=True) are_results_viewable: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(server_default=func.now())