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 = """📋 Спецификация формата JSON
• description — описание (до 2000 символов)
• password — пароль для доступа или null
• attempts — лимит попыток (1-100) или null
+• time_limit — лимит времени в секундах (1-86400) или null
• expires_at — срок действия в ISO формате или null
• for_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())