This commit is contained in:
2026-01-06 19:35:49 +03:00
parent ff92ab2b30
commit 7d2a734b7d
13 changed files with 162 additions and 9 deletions
@@ -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"<b>Описание:</b> {description}\n"
f"<b>Пароль:</b> {password_str}\n"
f"<b>Попыток:</b> {attempts_str}\n"
f"<b>Время:</b> {time_limit_str}\n"
f"<b>Истекает:</b> {expires_str}\n"
f"<b>Для группы:</b> {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("<b>⏱️ Лимит времени</b>\n\n🔢 <b>Введите лимит времени в минутах</b> (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("<b>📅 Срок действия</b>\n\n🗓 <b>Выберите дату истечения теста</b> или пропустите:"),
Calendar(id="calendar", on_click=on_date_selected),
@@ -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()
@@ -36,6 +36,7 @@ SPEC_INFO = """<b>📋 Спецификация формата JSON</b>
"description": "Описание теста",
"password": null,
"attempts": null,
"time_limit": null,
"expires_at": null,
"for_group": null,
"questions": [...]
@@ -46,6 +47,7 @@ SPEC_INFO = """<b>📋 Спецификация формата JSON</b>
• <code>description</code> — описание (до 2000 символов)
• <code>password</code> — пароль для доступа или <code>null</code>
• <code>attempts</code> — лимит попыток (1-100) или <code>null</code>
• <code>time_limit</code> — лимит времени в секундах (1-86400) или <code>null</code>
• <code>expires_at</code> — срок действия в ISO формате или <code>null</code>
• <code>for_group</code> — номер группы или <code>null</code> для всех
@@ -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,
@@ -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"<b>Вопросов:</b> {questions_count}\n"
f"<b>Пароль:</b> {password_str}\n"
f"<b>Попытки:</b> {attempts_str}\n"
f"<b>Время:</b> {time_limit_str}\n"
f"<b>Срок:</b> {expires_str}\n"
f"<b>Группа:</b> {group_str}\n"
f"<b>Видимость:</b> {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("<b>⏱️ Изменение лимита времени</b>\n\n🔢 <b>Введите лимит времени в минутах</b> (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("<b>👥 Изменение группы</b>\n\n🎓 <b>Выберите группу</b> или удалите привязку:"),
ScrollingGroup(
@@ -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"<b>📝 {test.title}</b>\n\n"
f"<blockquote>{test.description or ''}</blockquote>\n\n"
f"<b>Вопросов:</b> {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)}
@@ -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"<b>Вопросов:</b> {len(questions)}\n"
f"{password_str}\n"
f"{attempts_str}\n"
f"{time_limit_str}\n"
f"{expires_str}\n"
f"{group_str}"
)
+1
View File
@@ -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
+3
View File
@@ -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,
@@ -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):
@@ -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,
@@ -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())