From bd1def421f53c7239ad05583476e45600376bec2 Mon Sep 17 00:00:00 2001 From: kolo Date: Tue, 30 Dec 2025 23:55:43 +0300 Subject: [PATCH 01/57] Initial commit --- .gitignore | 50 ++ README.md | 51 ++ alembic.ini | 36 + alembic/env.py | 58 ++ alembic/script.py.mako | 24 + config.example.toml | 9 + main.py | 6 + pyproject.toml | 17 + src/trudex/__init__.py | 1 + src/trudex/__main__.py | 18 + src/trudex/application/__init__.py | 1 + src/trudex/application/bot/__init__.py | 1 + .../application/bot/admin_dialogs/__init__.py | 1 + src/trudex/application/bot/handlers.py | 4 + .../application/bot/middlewares/__init__.py | 1 + .../application/bot/user_dialogs/__init__.py | 1 + src/trudex/domain/__init__.py | 1 + src/trudex/domain/schemas.py | 2 + src/trudex/infrastructure/__init__.py | 1 + src/trudex/infrastructure/api/__init__.py | 1 + .../infrastructure/database/__init__.py | 1 + src/trudex/infrastructure/database/config.py | 9 + .../infrastructure/database/dao/__init__.py | 1 + src/trudex/infrastructure/database/models.py | 5 + src/trudex/infrastructure/di.py | 9 + .../infrastructure/scheduling/__init__.py | 1 + src/trudex/infrastructure/utils/__init__.py | 1 + src/trudex/infrastructure/utils/config.py | 37 + uv.lock | 723 ++++++++++++++++++ 29 files changed, 1071 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 config.example.toml create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 src/trudex/__init__.py create mode 100644 src/trudex/__main__.py create mode 100644 src/trudex/application/__init__.py create mode 100644 src/trudex/application/bot/__init__.py create mode 100644 src/trudex/application/bot/admin_dialogs/__init__.py create mode 100644 src/trudex/application/bot/handlers.py create mode 100644 src/trudex/application/bot/middlewares/__init__.py create mode 100644 src/trudex/application/bot/user_dialogs/__init__.py create mode 100644 src/trudex/domain/__init__.py create mode 100644 src/trudex/domain/schemas.py create mode 100644 src/trudex/infrastructure/__init__.py create mode 100644 src/trudex/infrastructure/api/__init__.py create mode 100644 src/trudex/infrastructure/database/__init__.py create mode 100644 src/trudex/infrastructure/database/config.py create mode 100644 src/trudex/infrastructure/database/dao/__init__.py create mode 100644 src/trudex/infrastructure/database/models.py create mode 100644 src/trudex/infrastructure/di.py create mode 100644 src/trudex/infrastructure/scheduling/__init__.py create mode 100644 src/trudex/infrastructure/utils/__init__.py create mode 100644 src/trudex/infrastructure/utils/config.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bdb438 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Config +config.toml +*.env +.env + +# Database +*.db +*.sqlite + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b050f2 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Trudex + +Telegram платформа для тестирования по охране труда + +## Архитектура + +Проект построен на принципах Clean Architecture с разделением на три слоя: + +- **Application** - координация и UI логика (aiogram, aiogram-dialog) +- **Domain** - бизнес-логика и доменные модели +- **Infrastructure** - технические детали (БД, API, планировщик) + +## Технологический стек + +- aiogram 3.x - Telegram Bot API +- aiogram-dialog - state machine для диалогов +- dishka - Dependency Injection +- SQLAlchemy 2.x async - ORM для PostgreSQL +- Alembic - миграции БД +- APScheduler - фоновые задачи +- httpx - HTTP-клиент +- Pydantic - валидация конфигурации + +## Структура проекта + +``` +src/trudex/ +├── application/ # Слой приложения +│ └── bot/ +│ ├── middlewares/ # Промежуточные обработчики +│ ├── user_dialogs/ # Диалоги пользователей +│ ├── admin_dialogs/# Диалоги администраторов +│ └── handlers.py # Обработчики событий +├── domain/ # Доменный слой +│ └── schemas.py # Доменные модели +└── infrastructure/ # Инфраструктурный слой + ├── api/ # Внешние API + ├── database/ # Работа с БД + │ ├── dao/ # Data Access Objects + │ ├── models.py # ORM модели + │ └── config.py # Конфигурация БД + ├── scheduling/ # Фоновые задачи + └── utils/ # Утилиты и конфигурация +``` + +## Запуск + +1. Установить зависимости: `uv sync` +2. Настроить `config.toml` +3. Запустить миграции: `alembic upgrade head` +4. Запустить бота: `python -m trudex` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..ac62538 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..f7e137d --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,58 @@ +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 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = None + + +def run_migrations_offline() -> None: + 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: + 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: + import asyncio + 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..a58301c --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +${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: 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/config.example.toml b/config.example.toml new file mode 100644 index 0000000..517019f --- /dev/null +++ b/config.example.toml @@ -0,0 +1,9 @@ +[bot] +token = "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" + +[database] +host = "localhost" +port = 5432 +user = "trudex_user" +password = "secure_password" +database = "trudex_db" diff --git a/main.py b/main.py new file mode 100644 index 0000000..3030c16 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from trudex!") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d5e85ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "trudex" +version = "0.1.0" +description = "Telegram платформа для тестирования по охране труда" +readme = "README.md" +requires-python = ">=3.12,<3.14" +dependencies = [ + "aiogram>=3.23.0", + "aiogram-dialog>=2.4.0", + "alembic>=1.17.2", + "asyncpg>=0.31.0", + "dishka>=1.7.2", + "httpx>=0.28.1", + "sqlalchemy>=2.0.45", + "apscheduler>=3.10.4", + "pydantic>=2.10.5", +] diff --git a/src/trudex/__init__.py b/src/trudex/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/trudex/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/trudex/__main__.py b/src/trudex/__main__.py new file mode 100644 index 0000000..a42263d --- /dev/null +++ b/src/trudex/__main__.py @@ -0,0 +1,18 @@ +import asyncio +import logging + +from aiogram import Bot, Dispatcher + +from trudex.infrastructure.utils.config import AppConfig + + +async def main(): + logging.basicConfig(level=logging.INFO) + + config = AppConfig.from_toml() + + logging.info("Бот запущен") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/trudex/application/__init__.py b/src/trudex/application/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/application/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/application/bot/__init__.py b/src/trudex/application/bot/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/application/bot/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/application/bot/admin_dialogs/__init__.py b/src/trudex/application/bot/admin_dialogs/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py new file mode 100644 index 0000000..e40f738 --- /dev/null +++ b/src/trudex/application/bot/handlers.py @@ -0,0 +1,4 @@ +from aiogram import Router + + +router = Router() diff --git a/src/trudex/application/bot/middlewares/__init__.py b/src/trudex/application/bot/middlewares/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/application/bot/middlewares/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/application/bot/user_dialogs/__init__.py b/src/trudex/application/bot/user_dialogs/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/application/bot/user_dialogs/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/domain/__init__.py b/src/trudex/domain/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/domain/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py new file mode 100644 index 0000000..3204566 --- /dev/null +++ b/src/trudex/domain/schemas.py @@ -0,0 +1,2 @@ +from dataclasses import dataclass +from datetime import datetime diff --git a/src/trudex/infrastructure/__init__.py b/src/trudex/infrastructure/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/infrastructure/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/infrastructure/api/__init__.py b/src/trudex/infrastructure/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/infrastructure/api/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/infrastructure/database/__init__.py b/src/trudex/infrastructure/database/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/infrastructure/database/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/infrastructure/database/config.py b/src/trudex/infrastructure/database/config.py new file mode 100644 index 0000000..a8bd5f8 --- /dev/null +++ b/src/trudex/infrastructure/database/config.py @@ -0,0 +1,9 @@ +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine + + +def create_engine(database_url: str) -> AsyncEngine: + return create_async_engine(database_url, echo=False) + + +def create_session_maker(engine: AsyncEngine) -> async_sessionmaker: + return async_sessionmaker(engine, expire_on_commit=False) diff --git a/src/trudex/infrastructure/database/dao/__init__.py b/src/trudex/infrastructure/database/dao/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/infrastructure/database/dao/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/src/trudex/infrastructure/database/models.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py new file mode 100644 index 0000000..741c289 --- /dev/null +++ b/src/trudex/infrastructure/di.py @@ -0,0 +1,9 @@ +from dishka import Provider, Scope + + +class DatabaseProvider(Provider): + scope = Scope.APP + + +class InfrastructureProvider(Provider): + scope = Scope.APP diff --git a/src/trudex/infrastructure/scheduling/__init__.py b/src/trudex/infrastructure/scheduling/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/infrastructure/scheduling/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/infrastructure/utils/__init__.py b/src/trudex/infrastructure/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/trudex/infrastructure/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/src/trudex/infrastructure/utils/config.py b/src/trudex/infrastructure/utils/config.py new file mode 100644 index 0000000..48c1490 --- /dev/null +++ b/src/trudex/infrastructure/utils/config.py @@ -0,0 +1,37 @@ +import tomllib +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class BotConfig: + token: str + + +@dataclass +class DatabaseConfig: + host: str + port: int + user: str + password: str + database: str + + @property + def url(self) -> str: + return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}" + + +@dataclass +class AppConfig: + bot: BotConfig + database: DatabaseConfig + + @classmethod + def from_toml(cls, path: str | Path = "config.toml") -> "AppConfig": + with open(path, "rb") as f: + data = tomllib.load(f) + + return cls( + bot=BotConfig(**data["bot"]), + database=DatabaseConfig(**data["database"]) + ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..578ab82 --- /dev/null +++ b/uv.lock @@ -0,0 +1,723 @@ +version = 1 +revision = 3 +requires-python = ">=3.12, <3.14" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiogram" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "certifi" }, + { name = "magic-filter" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9f/72d11c2d53be81afed597c49759cdf05a2eff6512b52d3d897a891829024/aiogram-3.23.0.tar.gz", hash = "sha256:bb48c0bc1e567b6e9e9d7cfd448f2b3d142d2a405c58ab217596b62f64968f1a", size = 1520457, upload-time = "2025-12-06T23:31:55.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/54/41cb6b3f3b904097aad4d4229063c1536c13d2343a2d193e3ecd0d9983c7/aiogram-3.23.0-py3-none-any.whl", hash = "sha256:62de64edf7f3e9c8ec86351e0ace6a53a6e073cc81cfd762cb5a0401565d80ad", size = 698365, upload-time = "2025-12-06T23:31:52.971Z" }, +] + +[[package]] +name = "aiogram-dialog" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiogram" }, + { name = "cachetools" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/b9/cfadb823578f9aff44f10928f8020dc4abda82078fba62295aae8869e894/aiogram_dialog-2.4.0.tar.gz", hash = "sha256:e8f0a811be3a58d1e48dfb9dd79108fd0e25c8ab7f8067c31c49f2f583ba4ecc", size = 80359, upload-time = "2025-07-08T22:41:41.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/d0/1c6511eb6840d06efbfba12cf6e59f688f9e314ab90af34027fa8069af75/aiogram_dialog-2.4.0-py3-none-any.whl", hash = "sha256:4e5fd8f7db66b6d252983c4025968b379069f4a2022db60bde36e5b91268b6ad", size = 112911, upload-time = "2025-07-08T22:41:47.61Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/9b/01f00e9856d0a73260e86dd8ed0c2234a466c5c1712ce1c281548df39777/aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", size = 737623, upload-time = "2025-10-28T20:56:30.797Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1b/4be39c445e2b2bd0aab4ba736deb649fabf14f6757f405f0c9685019b9e9/aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", size = 492664, upload-time = "2025-10-28T20:56:32.708Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/d35dcfea8050e131cdd731dff36434390479b4045a8d0b9d7111b0a968f1/aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", size = 491808, upload-time = "2025-10-28T20:56:34.57Z" }, + { url = "https://files.pythonhosted.org/packages/00/29/8e4609b93e10a853b65f8291e64985de66d4f5848c5637cddc70e98f01f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb", size = 1738863, upload-time = "2025-10-28T20:56:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fa/4ebdf4adcc0def75ced1a0d2d227577cd7b1b85beb7edad85fcc87693c75/aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3", size = 1700586, upload-time = "2025-10-28T20:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/73f5f02ff348a3558763ff6abe99c223381b0bace05cd4530a0258e52597/aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f", size = 1768625, upload-time = "2025-10-28T20:56:39.75Z" }, + { url = "https://files.pythonhosted.org/packages/f8/49/a825b79ffec124317265ca7d2344a86bcffeb960743487cb11988ffb3494/aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6", size = 1867281, upload-time = "2025-10-28T20:56:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/b9/48/adf56e05f81eac31edcfae45c90928f4ad50ef2e3ea72cb8376162a368f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e", size = 1752431, upload-time = "2025-10-28T20:56:43.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/ab/593855356eead019a74e862f21523db09c27f12fd24af72dbc3555b9bfd9/aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7", size = 1562846, upload-time = "2025-10-28T20:56:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/39/0f/9f3d32271aa8dc35036e9668e31870a9d3b9542dd6b3e2c8a30931cb27ae/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d", size = 1699606, upload-time = "2025-10-28T20:56:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/52d2658c5699b6ef7692a3f7128b2d2d4d9775f2a68093f74bca06cf01e1/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b", size = 1720663, upload-time = "2025-10-28T20:56:48.528Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d4/8f8f3ff1fb7fb9e3f04fcad4e89d8a1cd8fc7d05de67e3de5b15b33008ff/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8", size = 1737939, upload-time = "2025-10-28T20:56:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/03/d3/ddd348f8a27a634daae39a1b8e291ff19c77867af438af844bf8b7e3231b/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16", size = 1555132, upload-time = "2025-10-28T20:56:52.568Z" }, + { url = "https://files.pythonhosted.org/packages/39/b8/46790692dc46218406f94374903ba47552f2f9f90dad554eed61bfb7b64c/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169", size = 1764802, upload-time = "2025-10-28T20:56:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e4/19ce547b58ab2a385e5f0b8aa3db38674785085abcf79b6e0edd1632b12f/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248", size = 1719512, upload-time = "2025-10-28T20:56:56.428Z" }, + { url = "https://files.pythonhosted.org/packages/70/30/6355a737fed29dcb6dfdd48682d5790cb5eab050f7b4e01f49b121d3acad/aiohttp-3.13.2-cp312-cp312-win32.whl", hash = "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e", size = 426690, upload-time = "2025-10-28T20:56:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/b10ac09069973d112de6ef980c1f6bb31cb7dcd0bc363acbdad58f927873/aiohttp-3.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45", size = 453465, upload-time = "2025-10-28T20:57:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" }, + { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" }, + { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alembic" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "dishka" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/d7/1be31f5ef32387059190353f9fa493ff4d07a1c75fa856c7566ca45e0800/dishka-1.7.2.tar.gz", hash = "sha256:47d4cb5162b28c61bf5541860e605ed5eaf5c667122299c7ef657c86fc8d5a49", size = 68132, upload-time = "2025-09-24T21:23:05.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/89381173b4f336e986d72471198614806cd313e0f85c143ccb677c310223/dishka-1.7.2-py3-none-any.whl", hash = "sha256:f6faa6ab321903926b825b3337d77172ee693450279b314434864978d01fbad3", size = 94774, upload-time = "2025-09-24T21:23:03.246Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "magic-filter" +version = "1.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/08/da7c2cc7398cc0376e8da599d6330a437c01d3eace2f2365f300e0f3f758/magic_filter-1.0.12.tar.gz", hash = "sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9", size = 11071, upload-time = "2023-10-01T12:33:19.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/75/f620449f0056eff0ec7c1b1e088f71068eb4e47a46eb54f6c065c6ad7675/magic_filter-1.0.12-py3-none-any.whl", hash = "sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6", size = 11335, upload-time = "2023-10-01T12:33:17.711Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, + { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +] + +[[package]] +name = "trudex" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiogram" }, + { name = "aiogram-dialog" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "dishka" }, + { name = "httpx" }, + { name = "sqlalchemy" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiogram", specifier = ">=3.23.0" }, + { name = "aiogram-dialog", specifier = ">=2.4.0" }, + { name = "alembic", specifier = ">=1.17.2" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "dishka", specifier = ">=1.7.2" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "sqlalchemy", specifier = ">=2.0.45" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] From a615f439837935a6c41217f1f760b5405c582149 Mon Sep 17 00:00:00 2001 From: kolo Date: Wed, 31 Dec 2025 00:11:37 +0300 Subject: [PATCH 02/57] fix linting issues --- justfile | 14 +++ pyproject.toml | 16 ++- src/trudex/infrastructure/utils/config.py | 19 ++- uv.lock | 135 +++++++++++++++++++++- 4 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 justfile diff --git a/justfile b/justfile new file mode 100644 index 0000000..569df4a --- /dev/null +++ b/justfile @@ -0,0 +1,14 @@ +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] +set shell := ["bash", "-c"] + +dev: + watchfiles --filter python "python -m trudex.application" src + +run: + python -m trudex + +lint: + ruff check src + +format: + isort src diff --git a/pyproject.toml b/pyproject.toml index d5e85ae..da522b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,10 @@ [project] name = "trudex" version = "0.1.0" -description = "Telegram платформа для тестирования по охране труда" +description = "Occupational health and safety testing platform" +authors = [{ name = "kolo", email = "kolo.is.main@gmail.com" }] readme = "README.md" +license = { text = "MIT" } requires-python = ">=3.12,<3.14" dependencies = [ "aiogram>=3.23.0", @@ -15,3 +17,15 @@ dependencies = [ "apscheduler>=3.10.4", "pydantic>=2.10.5", ] + +[dependency-groups] +dev = [ + "isort>=7.0.0", + "ruff>=0.14.10", + "watchfiles>=1.1.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + diff --git a/src/trudex/infrastructure/utils/config.py b/src/trudex/infrastructure/utils/config.py index 48c1490..ffc1010 100644 --- a/src/trudex/infrastructure/utils/config.py +++ b/src/trudex/infrastructure/utils/config.py @@ -11,7 +11,7 @@ class BotConfig: @dataclass class DatabaseConfig: host: str - port: int + port: int | str user: str password: str database: str @@ -29,9 +29,20 @@ class AppConfig: @classmethod def from_toml(cls, path: str | Path = "config.toml") -> "AppConfig": with open(path, "rb") as f: - data = tomllib.load(f) + data: dict[str, dict[str, str]] = tomllib.load(f) + + bot_data: dict[str, str] = data["bot"] + db_data: dict[str, str] = data["database"] return cls( - bot=BotConfig(**data["bot"]), - database=DatabaseConfig(**data["database"]) + bot=BotConfig( + token=bot_data["token"] + ), + database=DatabaseConfig( + host=db_data["host"], + port=db_data["port"], + user=db_data["user"], + password=db_data["password"], + database=db_data["database"] + ) ) diff --git a/uv.lock b/uv.lock index 578ab82..2254a7e 100644 --- a/uv.lock +++ b/uv.lock @@ -151,6 +151,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -338,6 +350,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -587,6 +608,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.45" @@ -617,28 +664,46 @@ wheels = [ [[package]] name = "trudex" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "aiogram" }, { name = "aiogram-dialog" }, { name = "alembic" }, + { name = "apscheduler" }, { name = "asyncpg" }, { name = "dishka" }, { name = "httpx" }, + { name = "pydantic" }, { name = "sqlalchemy" }, ] +[package.dev-dependencies] +dev = [ + { name = "isort" }, + { name = "ruff" }, + { name = "watchfiles" }, +] + [package.metadata] requires-dist = [ { name = "aiogram", specifier = ">=3.23.0" }, { name = "aiogram-dialog", specifier = ">=2.4.0" }, { name = "alembic", specifier = ">=1.17.2" }, + { name = "apscheduler", specifier = ">=3.10.4" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "dishka", specifier = ">=1.7.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic", specifier = ">=2.10.5" }, { name = "sqlalchemy", specifier = ">=2.0.45" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "isort", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.14.10" }, + { name = "watchfiles", specifier = ">=1.1.1" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -660,6 +725,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, +] + [[package]] name = "yarl" version = "1.22.0" From d4898869fa1fe907477f0c08c912f93a10a7e715 Mon Sep 17 00:00:00 2001 From: kolo Date: Wed, 31 Dec 2025 00:40:41 +0300 Subject: [PATCH 03/57] Initial commit --- alembic/env.py | 50 ++++++-- alembic/script.py.mako | 11 +- alembic/versions/409f04b7b544_initial.py | 39 ++++++ src/trudex/__main__.py | 4 +- src/trudex/application/bot/handlers.py | 1 - src/trudex/infrastructure/database/config.py | 19 ++- .../infrastructure/database/dao/user.py | 121 ++++++++++++++++++ src/trudex/infrastructure/database/models.py | 20 ++- src/trudex/infrastructure/di.py | 23 +++- src/trudex/infrastructure/utils/config.py | 5 +- 10 files changed, 258 insertions(+), 35 deletions(-) create mode 100644 alembic/versions/409f04b7b544_initial.py create mode 100644 src/trudex/infrastructure/database/dao/user.py diff --git a/alembic/env.py b/alembic/env.py index f7e137d..17ad9ee 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,23 +1,47 @@ +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 sqlalchemy import Connection +from sqlalchemy.ext.asyncio import create_async_engine from alembic import context +from trudex.infrastructure.database.models import Base +from trudex.infrastructure.utils.config import Config +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. config = context.config +# 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) -target_metadata = None +# add your model's MetaData object here +# for 'autogenerate' support +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. +db_config = Config.from_toml("config.toml").database def run_migrations_offline() -> None: - url = config.get_main_option("sqlalchemy.url") + """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. + + """ context.configure( - url=url, + url=db_config.url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, @@ -27,7 +51,7 @@ def run_migrations_offline() -> None: context.run_migrations() -def do_run_migrations(connection: Connection) -> None: +def do_run_migrations(connection: Connection): context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): @@ -35,11 +59,11 @@ def do_run_migrations(connection: Connection) -> None: async def run_async_migrations() -> None: - connectable = async_engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = create_async_engine(db_config.url) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) @@ -48,7 +72,7 @@ async def run_async_migrations() -> None: def run_migrations_online() -> None: - import asyncio + """Run migrations in 'online' mode.""" asyncio.run(run_async_migrations()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako index a58301c..c812c15 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -1,19 +1,20 @@ -${message} +"""${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} -from typing import Sequence, Union +""" +from collections.abc import Sequence from alembic import op import sqlalchemy as sa ${imports if imports else ""} 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)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} def upgrade() -> None: diff --git a/alembic/versions/409f04b7b544_initial.py b/alembic/versions/409f04b7b544_initial.py new file mode 100644 index 0000000..35c409a --- /dev/null +++ b/alembic/versions/409f04b7b544_initial.py @@ -0,0 +1,39 @@ +"""initial + +Revision ID: 409f04b7b544 +Revises: +Create Date: 2025-12-31 00:38:10.367405 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = '409f04b7b544' +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('username', sa.String(length=32), nullable=True), + sa.Column('first_name', sa.String(length=64), nullable=False), + sa.Column('last_name', sa.String(length=64), nullable=True), + sa.Column('group', sa.Integer(), nullable=True), + sa.Column('is_admin', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + # ### end Alembic commands ### diff --git a/src/trudex/__main__.py b/src/trudex/__main__.py index a42263d..a7daa55 100644 --- a/src/trudex/__main__.py +++ b/src/trudex/__main__.py @@ -3,13 +3,13 @@ import logging from aiogram import Bot, Dispatcher -from trudex.infrastructure.utils.config import AppConfig +from trudex.infrastructure.utils.config import Config async def main(): logging.basicConfig(level=logging.INFO) - config = AppConfig.from_toml() + config = Config.from_toml("config.toml") logging.info("Бот запущен") diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index e40f738..1e22eb6 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,4 +1,3 @@ from aiogram import Router - router = Router() diff --git a/src/trudex/infrastructure/database/config.py b/src/trudex/infrastructure/database/config.py index a8bd5f8..8b3bd0f 100644 --- a/src/trudex/infrastructure/database/config.py +++ b/src/trudex/infrastructure/database/config.py @@ -1,9 +1,14 @@ -from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import (AsyncSession, async_sessionmaker, + create_async_engine) -def create_engine(database_url: str) -> AsyncEngine: - return create_async_engine(database_url, echo=False) - - -def create_session_maker(engine: AsyncEngine) -> async_sessionmaker: - return async_sessionmaker(engine, expire_on_commit=False) +def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]: + engine = create_async_engine( + db_url, + pool_size=15, + max_overflow=15, + connect_args={ + "timeout": 5, + }, + ) + return async_sessionmaker(engine, class_=AsyncSession, autoflush=False, expire_on_commit=False) diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py new file mode 100644 index 0000000..0606151 --- /dev/null +++ b/src/trudex/infrastructure/database/dao/user.py @@ -0,0 +1,121 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.infrastructure.database.models import User + + +class UserDAO: + def __init__(self, session: AsyncSession) -> None: + self.session: AsyncSession = session + + async def get_by_id(self, user_id: int) -> User | None: + result = await self.session.execute( + select(User).where(User.id == user_id) + ) + return result.scalar_one_or_none() + + async def get_all(self) -> list[User]: + result = await self.session.execute(select(User)) + return list(result.scalars().all()) + + async def get_by_group(self, group: int) -> list[User]: + result = await self.session.execute( + select(User).where(User.group == group) + ) + return list(result.scalars().all()) + + async def get_admins(self) -> list[User]: + result = await self.session.execute( + select(User).where(User.is_admin == True) + ) + return list(result.scalars().all()) + + async def create( + self, + user_id: int, + first_name: str, + username: str | None = None, + last_name: str | None = None, + group: int | None = None, + is_admin: bool = False, + ) -> User: + user = User( + id=user_id, + username=username, + first_name=first_name, + last_name=last_name, + group=group, + is_admin=is_admin, + ) + self.session.add(user) + await self.session.flush() + return user + + async def update( + self, + user_id: int, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + group: int | None = None, + is_admin: bool | None = None, + ) -> User | None: + user = await self.get_by_id(user_id) + if not user: + return None + + if username is not None: + user.username = username + if first_name is not None: + user.first_name = first_name + if last_name is not None: + user.last_name = last_name + if group is not None: + user.group = group + if is_admin is not None: + user.is_admin = is_admin + + await self.session.flush() + return user + + async def delete(self, user_id: int) -> bool: + user = await self.get_by_id(user_id) + if not user: + return False + + await self.session.delete(user) + await self.session.flush() + return True + + async def upsert( + self, + user_id: int, + first_name: str, + username: str | None = None, + last_name: str | None = None, + group: int | None = None, + is_admin: bool = False, + ) -> User: + user = await self.get_by_id(user_id) + if user: + if username is not None: + user.username = username + if first_name is not None: + user.first_name = first_name + if last_name is not None: + user.last_name = last_name + if group is not None: + user.group = group + if is_admin is not None: + user.is_admin = is_admin + await self.session.flush() + return user + + return await self.create( + user_id=user_id, + username=username, + first_name=first_name, + last_name=last_name, + group=group, + is_admin=is_admin, + ) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index fa2b68a..3e067e7 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -1,5 +1,23 @@ -from sqlalchemy.orm import DeclarativeBase +from datetime import datetime +from typing import final + +from sqlalchemy import BigInteger, CheckConstraint, String, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass + + +@final +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + username: Mapped[str | None] = mapped_column(String(32)) + first_name: Mapped[str] = mapped_column(String(64)) + last_name: Mapped[str | None] = mapped_column(String(64)) + group: Mapped[int | None] = mapped_column(CheckConstraint("group >= 1000 AND group <= 9999")) + is_admin: Mapped[bool] = mapped_column(default=False) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index 741c289..b674407 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -1,9 +1,24 @@ -from dishka import Provider, Scope +from collections.abc import AsyncIterable + +from dishka import Provider, Scope, provide +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from trudex.infrastructure.database.config import new_session_maker +from trudex.infrastructure.utils.config import Config class DatabaseProvider(Provider): - scope = Scope.APP + @provide(scope=Scope.APP) + def get_session_maker(self, config: Config) -> async_sessionmaker[AsyncSession]: + return new_session_maker(config.database.url) + @provide(scope=Scope.REQUEST) + async def get_session( + self, session_maker: async_sessionmaker[AsyncSession] + ) -> AsyncIterable[AsyncSession]: + async with session_maker() as session: + yield session -class InfrastructureProvider(Provider): - scope = Scope.APP + @provide(scope=Scope.REQUEST) + async def get_users_dao(self, session: AsyncSession) -> UsersDAO: + return UsersDAO(session) diff --git a/src/trudex/infrastructure/utils/config.py b/src/trudex/infrastructure/utils/config.py index ffc1010..8cc30a7 100644 --- a/src/trudex/infrastructure/utils/config.py +++ b/src/trudex/infrastructure/utils/config.py @@ -1,6 +1,7 @@ import tomllib from dataclasses import dataclass from pathlib import Path +from typing import Self @dataclass @@ -22,12 +23,12 @@ class DatabaseConfig: @dataclass -class AppConfig: +class Config: bot: BotConfig database: DatabaseConfig @classmethod - def from_toml(cls, path: str | Path = "config.toml") -> "AppConfig": + def from_toml(cls, path: str | Path) -> Self: with open(path, "rb") as f: data: dict[str, dict[str, str]] = tomllib.load(f) From d7072f88fc39c64a0128f9233c6998ad96fef7de Mon Sep 17 00:00:00 2001 From: kolo Date: Wed, 31 Dec 2025 00:52:08 +0300 Subject: [PATCH 04/57] Initial commit --- src/trudex/domain/schemas.py | 12 +++++ .../infrastructure/database/dao/user.py | 53 +++++++++++++------ .../infrastructure/database/dto/__init__.py | 0 .../infrastructure/database/dto/user.py | 19 +++++++ 4 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 src/trudex/infrastructure/database/dto/__init__.py create mode 100644 src/trudex/infrastructure/database/dto/user.py diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index 3204566..cc4d8d7 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -1,2 +1,14 @@ from dataclasses import dataclass from datetime import datetime + + +@dataclass +class User: + id: int + first_name: str + username: str | None = None + last_name: str | None = None + group: int | None = None + is_admin: bool = False + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index 0606151..25c08b2 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -1,6 +1,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from trudex.domain.schemas import User as DomainUser +from trudex.infrastructure.database.dto.user import UserDTO from trudex.infrastructure.database.models import User @@ -8,27 +10,31 @@ class UserDAO: def __init__(self, session: AsyncSession) -> None: self.session: AsyncSession = session - async def get_by_id(self, user_id: int) -> User | None: + async def get_by_id(self, user_id: int) -> DomainUser | None: result = await self.session.execute( select(User).where(User.id == user_id) ) - return result.scalar_one_or_none() + model = result.scalar_one_or_none() + return UserDTO(model).to_domain() if model else None - async def get_all(self) -> list[User]: + async def get_all(self) -> list[DomainUser]: result = await self.session.execute(select(User)) - return list(result.scalars().all()) + models = list(result.scalars().all()) + return [UserDTO(model).to_domain() for model in models] - async def get_by_group(self, group: int) -> list[User]: + async def get_by_group(self, group: int) -> list[DomainUser]: result = await self.session.execute( select(User).where(User.group == group) ) - return list(result.scalars().all()) + models = list(result.scalars().all()) + return [UserDTO(model).to_domain() for model in models] - async def get_admins(self) -> list[User]: + async def get_admins(self) -> list[DomainUser]: result = await self.session.execute( select(User).where(User.is_admin == True) ) - return list(result.scalars().all()) + models = list(result.scalars().all()) + return [UserDTO(model).to_domain() for model in models] async def create( self, @@ -38,7 +44,7 @@ class UserDAO: last_name: str | None = None, group: int | None = None, is_admin: bool = False, - ) -> User: + ) -> DomainUser: user = User( id=user_id, username=username, @@ -49,7 +55,8 @@ class UserDAO: ) self.session.add(user) await self.session.flush() - return user + await self.session.refresh(user) + return UserDTO(user).to_domain() async def update( self, @@ -59,8 +66,11 @@ class UserDAO: last_name: str | None = None, group: int | None = None, is_admin: bool | None = None, - ) -> User | None: - user = await self.get_by_id(user_id) + ) -> DomainUser | None: + result = await self.session.execute( + select(User).where(User.id == user_id) + ) + user = result.scalar_one_or_none() if not user: return None @@ -76,10 +86,14 @@ class UserDAO: user.is_admin = is_admin await self.session.flush() - return user + await self.session.refresh(user) + return UserDTO(user).to_domain() async def delete(self, user_id: int) -> bool: - user = await self.get_by_id(user_id) + result = await self.session.execute( + select(User).where(User.id == user_id) + ) + user = result.scalar_one_or_none() if not user: return False @@ -95,8 +109,12 @@ class UserDAO: last_name: str | None = None, group: int | None = None, is_admin: bool = False, - ) -> User: - user = await self.get_by_id(user_id) + ) -> DomainUser: + result = await self.session.execute( + select(User).where(User.id == user_id) + ) + user = result.scalar_one_or_none() + if user: if username is not None: user.username = username @@ -109,7 +127,8 @@ class UserDAO: if is_admin is not None: user.is_admin = is_admin await self.session.flush() - return user + await self.session.refresh(user) + return UserDTO(user).to_domain() return await self.create( user_id=user_id, diff --git a/src/trudex/infrastructure/database/dto/__init__.py b/src/trudex/infrastructure/database/dto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/trudex/infrastructure/database/dto/user.py b/src/trudex/infrastructure/database/dto/user.py new file mode 100644 index 0000000..5e69349 --- /dev/null +++ b/src/trudex/infrastructure/database/dto/user.py @@ -0,0 +1,19 @@ +from trudex.domain.schemas import User as DomainUser +from trudex.infrastructure.database.models import User as UserModel + + +class UserDTO: + def __init__(self, model: UserModel) -> None: + self.model: UserModel = model + + def to_domain(self) -> DomainUser: + return DomainUser( + id=self.model.id, + username=self.model.username, + first_name=self.model.first_name, + last_name=self.model.last_name, + group=self.model.group, + is_admin=self.model.is_admin, + created_at=self.model.created_at, + updated_at=self.model.updated_at, + ) From f84efea30f694e0a58dd0ab34433991af826189f Mon Sep 17 00:00:00 2001 From: kolo Date: Wed, 31 Dec 2025 01:10:03 +0300 Subject: [PATCH 05/57] Initial commit --- alembic/versions/780dec53b460_tests.py | 57 ++++++++++++++++++++ src/trudex/infrastructure/database/models.py | 57 +++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/780dec53b460_tests.py diff --git a/alembic/versions/780dec53b460_tests.py b/alembic/versions/780dec53b460_tests.py new file mode 100644 index 0000000..2e718b8 --- /dev/null +++ b/alembic/versions/780dec53b460_tests.py @@ -0,0 +1,57 @@ +"""tests + +Revision ID: 780dec53b460 +Revises: 409f04b7b544 +Create Date: 2025-12-31 01:09:25.135116 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = '780dec53b460' +down_revision: str | None = '409f04b7b544' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tests', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('for_group', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('questions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('test_id', sa.Integer(), nullable=False), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('question_type', sa.Enum('SINGLE', 'MULTIPLE', 'INPUT', name='questiontype'), nullable=False), + sa.Column('tg_file_id', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['test_id'], ['tests.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('options', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('question_id', sa.Integer(), nullable=False), + sa.Column('text', sa.String(length=255), nullable=False), + sa.Column('is_correct', sa.Boolean(), nullable=False), + sa.Column('explanation', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('options') + op.drop_table('questions') + op.drop_table('tests') + # ### end Alembic commands ### diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 3e067e7..eedf500 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -1,8 +1,10 @@ from datetime import datetime +from enum import Enum +from tokenize import group from typing import final -from sqlalchemy import BigInteger, CheckConstraint, String, func -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): @@ -21,3 +23,54 @@ class User(Base): is_admin: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + +class QuestionType(str, Enum): + SINGLE = "single" + MULTIPLE = "multiple" + INPUT = "input" + +@final +class Test(Base): + __tablename__ = "tests" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text) + for_group: Mapped[int | None] = mapped_column(default=None) + is_active: Mapped[bool] = mapped_column(default=True) + + questions: Mapped[list["Question"]] = relationship( + back_populates="test", + cascade="all, delete-orphan", + order_by="Question.position" + ) + +@final +class Question(Base): + __tablename__ = "questions" + + id: Mapped[int] = mapped_column(primary_key=True) + test_id: Mapped[int] = mapped_column(ForeignKey("tests.id")) + text: Mapped[str] = mapped_column(Text) + position: Mapped[int] = mapped_column(Integer, default=0) + question_type: Mapped[QuestionType] = mapped_column(default=QuestionType.SINGLE) + tg_file_id: Mapped[str | None] = mapped_column(String(255)) + + test: Mapped["Test"] = relationship(back_populates="questions") + options: Mapped[list["Option"]] = relationship( + back_populates="question", + cascade="all, delete-orphan" + ) + +@final +class Option(Base): + __tablename__ = "options" + + id: Mapped[int] = mapped_column(primary_key=True) + question_id: Mapped[int] = mapped_column(ForeignKey("questions.id")) + text: Mapped[str] = mapped_column(String(255)) + is_correct: Mapped[bool] = mapped_column(default=False) + explanation: Mapped[str | None] = mapped_column(Text) + + question: Mapped["Question"] = relationship(back_populates="options") From 59a4baabd432ee069a1b755db6c69fd9acd3e33f Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 1 Jan 2026 02:56:55 +0300 Subject: [PATCH 06/57] Initial commit --- src/trudex/domain/schemas.py | 30 ++++ .../infrastructure/database/dao/option.py | 78 +++++++++ .../infrastructure/database/dao/question.py | 83 ++++++++++ .../infrastructure/database/dao/test.py | 81 +++++++++ .../infrastructure/database/dao/user.py | 14 -- .../infrastructure/database/dto/option.py | 16 ++ .../infrastructure/database/dto/question.py | 17 ++ .../infrastructure/database/dto/test.py | 18 ++ src/trudex/infrastructure/database/models.py | 8 +- .../infrastructure/database/repo/__init__.py | 0 .../infrastructure/database/repo/test.py | 155 ++++++++++++++++++ 11 files changed, 484 insertions(+), 16 deletions(-) create mode 100644 src/trudex/infrastructure/database/dao/option.py create mode 100644 src/trudex/infrastructure/database/dao/question.py create mode 100644 src/trudex/infrastructure/database/dao/test.py create mode 100644 src/trudex/infrastructure/database/dto/option.py create mode 100644 src/trudex/infrastructure/database/dto/question.py create mode 100644 src/trudex/infrastructure/database/dto/test.py create mode 100644 src/trudex/infrastructure/database/repo/__init__.py create mode 100644 src/trudex/infrastructure/database/repo/test.py diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index cc4d8d7..c269624 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -12,3 +12,33 @@ class User: is_admin: bool = False created_at: datetime | None = None updated_at: datetime | None = None + + +@dataclass +class Test: + id: int + title: str + description: str | None = None + for_group: int | None = None + is_active: bool = True + created_at: datetime | None = None + updated_at: datetime | None = None + + +@dataclass +class Question: + id: int + test_id: int + text: str + position: int = 0 + question_type: str = "single" + tg_file_id: str | None = None + + +@dataclass +class Option: + id: int + question_id: int + text: str + is_correct: bool = False + explanation: str | None = None diff --git a/src/trudex/infrastructure/database/dao/option.py b/src/trudex/infrastructure/database/dao/option.py new file mode 100644 index 0000000..5c36a8a --- /dev/null +++ b/src/trudex/infrastructure/database/dao/option.py @@ -0,0 +1,78 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.domain.schemas import Option as DomainOption +from trudex.infrastructure.database.dto.option import OptionDTO +from trudex.infrastructure.database.models import Option + + +class OptionDAO: + def __init__(self, session: AsyncSession) -> None: + self.session: AsyncSession = session + + async def get_by_id(self, option_id: int) -> DomainOption | None: + result = await self.session.execute( + select(Option).where(Option.id == option_id) + ) + model = result.scalar_one_or_none() + return OptionDTO(model).to_domain() if model else None + + async def get_all(self) -> list[DomainOption]: + result = await self.session.execute(select(Option)) + models = list(result.scalars().all()) + return [OptionDTO(model).to_domain() for model in models] + + async def create( + self, + question_id: int, + text: str, + is_correct: bool = False, + explanation: str | None = None, + ) -> DomainOption: + option = Option( + question_id=question_id, + text=text, + is_correct=is_correct, + explanation=explanation, + ) + self.session.add(option) + await self.session.flush() + await self.session.refresh(option) + return OptionDTO(option).to_domain() + + async def update( + self, + option_id: int, + text: str | None = None, + is_correct: bool | None = None, + explanation: str | None = None, + ) -> DomainOption | None: + result = await self.session.execute( + select(Option).where(Option.id == option_id) + ) + option = result.scalar_one_or_none() + if not option: + return None + + if text is not None: + option.text = text + if is_correct is not None: + option.is_correct = is_correct + if explanation is not None: + option.explanation = explanation + + await self.session.flush() + await self.session.refresh(option) + return OptionDTO(option).to_domain() + + async def delete(self, option_id: int) -> bool: + result = await self.session.execute( + select(Option).where(Option.id == option_id) + ) + option = result.scalar_one_or_none() + if not option: + return False + + await self.session.delete(option) + await self.session.flush() + return True diff --git a/src/trudex/infrastructure/database/dao/question.py b/src/trudex/infrastructure/database/dao/question.py new file mode 100644 index 0000000..c67a60f --- /dev/null +++ b/src/trudex/infrastructure/database/dao/question.py @@ -0,0 +1,83 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.domain.schemas import Question as DomainQuestion +from trudex.infrastructure.database.dto.question import QuestionDTO +from trudex.infrastructure.database.models import Question, QuestionType + + +class QuestionDAO: + def __init__(self, session: AsyncSession) -> None: + self.session: AsyncSession = session + + async def get_by_id(self, question_id: int) -> DomainQuestion | None: + result = await self.session.execute( + select(Question).where(Question.id == question_id) + ) + model = result.scalar_one_or_none() + return QuestionDTO(model).to_domain() if model else None + + async def get_all(self) -> list[DomainQuestion]: + result = await self.session.execute(select(Question)) + models = list(result.scalars().all()) + return [QuestionDTO(model).to_domain() for model in models] + + async def create( + self, + test_id: int, + text: str, + position: int = 0, + question_type: str = "single", + tg_file_id: str | None = None, + ) -> DomainQuestion: + question = Question( + test_id=test_id, + text=text, + position=position, + question_type=QuestionType(question_type), + tg_file_id=tg_file_id, + ) + self.session.add(question) + await self.session.flush() + await self.session.refresh(question) + return QuestionDTO(question).to_domain() + + async def update( + self, + question_id: int, + text: str | None = None, + position: int | None = None, + question_type: str | None = None, + tg_file_id: str | None = None, + ) -> DomainQuestion | None: + result = await self.session.execute( + select(Question).where(Question.id == question_id) + ) + question = result.scalar_one_or_none() + if not question: + return None + + if text is not None: + question.text = text + if position is not None: + question.position = position + if question_type is not None: + question.question_type = QuestionType(question_type) + if tg_file_id is not None: + question.tg_file_id = tg_file_id + + await self.session.flush() + await self.session.refresh(question) + return QuestionDTO(question).to_domain() + + async def delete(self, question_id: int) -> bool: + result = await self.session.execute( + select(Question).where(Question.id == question_id) + ) + question = result.scalar_one_or_none() + if not question: + return False + + await self.session.delete(question) + await self.session.flush() + return True diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/trudex/infrastructure/database/dao/test.py new file mode 100644 index 0000000..6996c7f --- /dev/null +++ b/src/trudex/infrastructure/database/dao/test.py @@ -0,0 +1,81 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.domain.schemas import Test as DomainTest +from trudex.infrastructure.database.dto.test import TestDTO +from trudex.infrastructure.database.models import Test + + +class TestDAO: + def __init__(self, session: AsyncSession) -> None: + self.session: AsyncSession = session + + async def get_by_id(self, test_id: int) -> DomainTest | None: + result = await self.session.execute( + select(Test).where(Test.id == test_id) + ) + model = result.scalar_one_or_none() + return TestDTO(model).to_domain() if model else None + + async def get_all(self) -> list[DomainTest]: + result = await self.session.execute(select(Test)) + models = list(result.scalars().all()) + return [TestDTO(model).to_domain() for model in models] + + async def create( + self, + title: str, + description: str | None = None, + for_group: int | None = None, + is_active: bool = True, + ) -> DomainTest: + test = Test( + title=title, + description=description, + for_group=for_group, + is_active=is_active, + ) + self.session.add(test) + await self.session.flush() + await self.session.refresh(test) + return TestDTO(test).to_domain() + + async def update( + self, + test_id: int, + title: str | None = None, + description: str | None = None, + for_group: int | None = None, + is_active: bool | None = None, + ) -> DomainTest | None: + result = await self.session.execute( + select(Test).where(Test.id == test_id) + ) + test = result.scalar_one_or_none() + if not test: + return None + + if title is not None: + test.title = title + if description is not None: + test.description = description + if for_group is not None: + test.for_group = for_group + if is_active is not None: + test.is_active = is_active + + await self.session.flush() + await self.session.refresh(test) + return TestDTO(test).to_domain() + + async def delete(self, test_id: int) -> bool: + result = await self.session.execute( + select(Test).where(Test.id == test_id) + ) + test = result.scalar_one_or_none() + if not test: + return False + + await self.session.delete(test) + await self.session.flush() + return True diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index 25c08b2..8464bf9 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -22,20 +22,6 @@ class UserDAO: models = list(result.scalars().all()) return [UserDTO(model).to_domain() for model in models] - async def get_by_group(self, group: int) -> list[DomainUser]: - result = await self.session.execute( - select(User).where(User.group == group) - ) - models = list(result.scalars().all()) - return [UserDTO(model).to_domain() for model in models] - - async def get_admins(self) -> list[DomainUser]: - result = await self.session.execute( - select(User).where(User.is_admin == True) - ) - models = list(result.scalars().all()) - return [UserDTO(model).to_domain() for model in models] - async def create( self, user_id: int, diff --git a/src/trudex/infrastructure/database/dto/option.py b/src/trudex/infrastructure/database/dto/option.py new file mode 100644 index 0000000..e05397e --- /dev/null +++ b/src/trudex/infrastructure/database/dto/option.py @@ -0,0 +1,16 @@ +from trudex.domain.schemas import Option as DomainOption +from trudex.infrastructure.database.models import Option as OptionModel + + +class OptionDTO: + def __init__(self, model: OptionModel) -> None: + self.model: OptionModel = model + + def to_domain(self) -> DomainOption: + return DomainOption( + id=self.model.id, + question_id=self.model.question_id, + text=self.model.text, + is_correct=self.model.is_correct, + explanation=self.model.explanation, + ) diff --git a/src/trudex/infrastructure/database/dto/question.py b/src/trudex/infrastructure/database/dto/question.py new file mode 100644 index 0000000..6490f4b --- /dev/null +++ b/src/trudex/infrastructure/database/dto/question.py @@ -0,0 +1,17 @@ +from trudex.domain.schemas import Question as DomainQuestion +from trudex.infrastructure.database.models import Question as QuestionModel + + +class QuestionDTO: + def __init__(self, model: QuestionModel) -> None: + self.model: QuestionModel = model + + def to_domain(self) -> DomainQuestion: + return DomainQuestion( + id=self.model.id, + test_id=self.model.test_id, + text=self.model.text, + position=self.model.position, + question_type=self.model.question_type.value, + tg_file_id=self.model.tg_file_id, + ) diff --git a/src/trudex/infrastructure/database/dto/test.py b/src/trudex/infrastructure/database/dto/test.py new file mode 100644 index 0000000..55971fc --- /dev/null +++ b/src/trudex/infrastructure/database/dto/test.py @@ -0,0 +1,18 @@ +from trudex.domain.schemas import Test as DomainTest +from trudex.infrastructure.database.models import Test as TestModel + + +class TestDTO: + def __init__(self, model: TestModel) -> None: + self.model: TestModel = model + + def to_domain(self) -> DomainTest: + return DomainTest( + id=self.model.id, + title=self.model.title, + description=self.model.description, + for_group=self.model.for_group, + is_active=self.model.is_active, + created_at=self.model.created_at, + updated_at=self.model.updated_at, + ) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index eedf500..b4e1f29 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -1,6 +1,5 @@ from datetime import datetime from enum import Enum -from tokenize import group from typing import final from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, Text, func @@ -30,6 +29,7 @@ class QuestionType(str, Enum): MULTIPLE = "multiple" INPUT = "input" + @final class Test(Base): __tablename__ = "tests" @@ -37,8 +37,10 @@ class Test(Base): id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(255)) description: Mapped[str | None] = mapped_column(Text) - for_group: Mapped[int | None] = mapped_column(default=None) + for_group: Mapped[int | None] = mapped_column(default=None) is_active: Mapped[bool] = mapped_column(default=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) questions: Mapped[list["Question"]] = relationship( back_populates="test", @@ -46,6 +48,7 @@ class Test(Base): order_by="Question.position" ) + @final class Question(Base): __tablename__ = "questions" @@ -63,6 +66,7 @@ class Question(Base): cascade="all, delete-orphan" ) + @final class Option(Base): __tablename__ = "options" diff --git a/src/trudex/infrastructure/database/repo/__init__.py b/src/trudex/infrastructure/database/repo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/trudex/infrastructure/database/repo/test.py b/src/trudex/infrastructure/database/repo/test.py new file mode 100644 index 0000000..7603617 --- /dev/null +++ b/src/trudex/infrastructure/database/repo/test.py @@ -0,0 +1,155 @@ +from typing import final +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from trudex.domain.schemas import Option, Question, Test +from trudex.infrastructure.database.dao.option import OptionDAO +from trudex.infrastructure.database.dao.question import QuestionDAO +from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.dto.option import OptionDTO +from trudex.infrastructure.database.dto.question import QuestionDTO +from trudex.infrastructure.database.dto.test import TestDTO +from trudex.infrastructure.database.models import ( + Option as OptionModel, + Question as QuestionModel, + Test as TestModel, +) + + +@final +class TestRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.test_dao = TestDAO(session) + self.question_dao = QuestionDAO(session) + self.option_dao = OptionDAO(session) + + async def get_active_tests(self) -> list[Test]: + result = await self.session.execute( + select(TestModel).where(TestModel.is_active == True) + ) + models = list(result.scalars().all()) + return [TestDTO(model).to_domain() for model in models] + + async def get_tests_by_group(self, group: int) -> list[Test]: + result = await self.session.execute( + select(TestModel).where(TestModel.for_group == group) + ) + models = list(result.scalars().all()) + return [TestDTO(model).to_domain() for model in models] + + async def get_active_tests_by_group(self, group: int) -> list[Test]: + result = await self.session.execute( + select(TestModel) + .where(TestModel.for_group == group) + .where(TestModel.is_active == True) + ) + models = list(result.scalars().all()) + return [TestDTO(model).to_domain() for model in models] + + async def get_test_with_questions(self, test_id: int) -> tuple[Test | None, list[Question]]: + test = await self.test_dao.get_by_id(test_id) + if not test: + return None, [] + + result = await self.session.execute( + select(TestModel) + .where(TestModel.id == test_id) + .options(selectinload(TestModel.questions)) + ) + test_model = result.scalar_one_or_none() + if not test_model: + return test, [] + + questions = [QuestionDTO(q).to_domain() for q in sorted(test_model.questions, key=lambda x: x.position)] + return test, questions + + async def get_question_with_options(self, question_id: int) -> tuple[Question | None, list[Option]]: + question = await self.question_dao.get_by_id(question_id) + if not question: + return None, [] + + result = await self.session.execute( + select(QuestionModel) + .where(QuestionModel.id == question_id) + .options(selectinload(QuestionModel.options)) + ) + question_model = result.scalar_one_or_none() + if not question_model: + return question, [] + + options = [OptionDTO(o).to_domain() for o in question_model.options] + return question, options + + async def get_correct_options_for_question(self, question_id: int) -> list[Option]: + result = await self.session.execute( + select(OptionModel) + .where(OptionModel.question_id == question_id) + .where(OptionModel.is_correct == True) + ) + models = list(result.scalars().all()) + return [OptionDTO(model).to_domain() for model in models] + + async def get_full_test(self, test_id: int) -> tuple[Test | None, list[tuple[Question, list[Option]]]]: + test = await self.test_dao.get_by_id(test_id) + if not test: + return None, [] + + result = await self.session.execute( + select(TestModel) + .where(TestModel.id == test_id) + .options( + selectinload(TestModel.questions).selectinload(QuestionModel.options) + ) + ) + test_model = result.scalar_one_or_none() + if not test_model: + return test, [] + + questions_with_options: list[tuple[Question, list[Option]]] = [] + for question_model in sorted(test_model.questions, key=lambda x: x.position): + question = QuestionDTO(question_model).to_domain() + options = [OptionDTO(o).to_domain() for o in question_model.options] + questions_with_options.append((question, options)) + + return test, questions_with_options + + async def count_questions_in_test(self, test_id: int) -> int: + result = await self.session.execute( + select(func.count(QuestionModel.id)) + .where(QuestionModel.test_id == test_id) + ) + count = result.scalar_one() + return count + + async def duplicate_test(self, test_id: int, new_title: str) -> Test | None: + test, questions_with_options = await self.get_full_test(test_id) + if not test: + return None + + new_test = await self.test_dao.create( + title=new_title, + description=test.description, + for_group=test.for_group, + is_active=False, + ) + + for question, options in questions_with_options: + new_question = await self.question_dao.create( + test_id=new_test.id, + text=question.text, + position=question.position, + question_type=question.question_type, + tg_file_id=question.tg_file_id, + ) + + for option in options: + _ = await self.option_dao.create( + question_id=new_question.id, + text=option.text, + is_correct=option.is_correct, + explanation=option.explanation, + ) + + return new_test From bf04bde8905f0a3243f0243930f45e432e0b9578 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 1 Jan 2026 03:12:21 +0300 Subject: [PATCH 07/57] Initial commit --- ...c53b460_tests.py => 59dd00dc1990_tests.py} | 8 ++- pyproject.toml | 3 + .../bot/middlewares/reject_not_admin.py | 37 +++++++++++ .../infrastructure/database/dao/__init__.py | 4 ++ .../infrastructure/database/repo/__init__.py | 4 ++ .../infrastructure/database/repo/test.py | 13 ++-- .../infrastructure/database/repo/user.py | 61 +++++++++++++++++++ src/trudex/infrastructure/di.py | 30 ++++++++- 8 files changed, 149 insertions(+), 11 deletions(-) rename alembic/versions/{780dec53b460_tests.py => 59dd00dc1990_tests.py} (87%) create mode 100644 src/trudex/application/bot/middlewares/reject_not_admin.py create mode 100644 src/trudex/infrastructure/database/repo/user.py diff --git a/alembic/versions/780dec53b460_tests.py b/alembic/versions/59dd00dc1990_tests.py similarity index 87% rename from alembic/versions/780dec53b460_tests.py rename to alembic/versions/59dd00dc1990_tests.py index 2e718b8..36a8890 100644 --- a/alembic/versions/780dec53b460_tests.py +++ b/alembic/versions/59dd00dc1990_tests.py @@ -1,8 +1,8 @@ """tests -Revision ID: 780dec53b460 +Revision ID: 59dd00dc1990 Revises: 409f04b7b544 -Create Date: 2025-12-31 01:09:25.135116 +Create Date: 2026-01-01 03:02:33.134535 """ from collections.abc import Sequence @@ -11,7 +11,7 @@ from alembic import op import sqlalchemy as sa -revision: str = '780dec53b460' +revision: str = '59dd00dc1990' down_revision: str | None = '409f04b7b544' branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -25,6 +25,8 @@ def upgrade() -> None: sa.Column('description', sa.Text(), nullable=True), sa.Column('for_group', sa.Integer(), nullable=True), sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), sa.PrimaryKeyConstraint('id') ) op.create_table('questions', diff --git a/pyproject.toml b/pyproject.toml index da522b0..d234a4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ dev = [ "watchfiles>=1.1.1", ] +[tool.pyright] +typeCheckingMode = "standard" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/trudex/application/bot/middlewares/reject_not_admin.py b/src/trudex/application/bot/middlewares/reject_not_admin.py new file mode 100644 index 0000000..c016590 --- /dev/null +++ b/src/trudex/application/bot/middlewares/reject_not_admin.py @@ -0,0 +1,37 @@ +from typing import Any, Callable +from collections.abc import Awaitable + +from aiogram import BaseMiddleware +from aiogram.types import Message, TelegramObject +from dishka import AsyncContainer + +from trudex.infrastructure.database.repo import UserRepository + + +class RejectNotAdminMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + if not isinstance(event, Message): + return await handler(event, data) + + assert event.from_user is not None + + container: AsyncContainer = data["dishka_container"] + user_id = event.from_user.id + admin_commands = ["/admin"] + if event.text: + if event.text.strip() in admin_commands: + users_dao: UserRepository = await container.get(UserRepository) + admins = await users_dao.get_admins() + if user_id in [admin.id for admin in admins]: + return await handler(event, data) + else: + pass + else: + return await handler(event, data) + else: + return await handler(event, data) diff --git a/src/trudex/infrastructure/database/dao/__init__.py b/src/trudex/infrastructure/database/dao/__init__.py index 8b13789..ecf61f2 100644 --- a/src/trudex/infrastructure/database/dao/__init__.py +++ b/src/trudex/infrastructure/database/dao/__init__.py @@ -1 +1,5 @@ +from .test import TestDAO as TestDAO +from .user import UserDAO as UserDAO +from .question import QuestionDAO as QuestionDAO +from .option import OptionDAO as OptionDAO \ No newline at end of file diff --git a/src/trudex/infrastructure/database/repo/__init__.py b/src/trudex/infrastructure/database/repo/__init__.py index e69de29..8a2838f 100644 --- a/src/trudex/infrastructure/database/repo/__init__.py +++ b/src/trudex/infrastructure/database/repo/__init__.py @@ -0,0 +1,4 @@ +from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.database.repo.user import UserRepository + +__all__ = ["TestRepository", "UserRepository"] diff --git a/src/trudex/infrastructure/database/repo/test.py b/src/trudex/infrastructure/database/repo/test.py index 7603617..fc36ad5 100644 --- a/src/trudex/infrastructure/database/repo/test.py +++ b/src/trudex/infrastructure/database/repo/test.py @@ -1,4 +1,5 @@ from typing import final + from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -145,11 +146,11 @@ class TestRepository: ) for option in options: - _ = await self.option_dao.create( - question_id=new_question.id, - text=option.text, - is_correct=option.is_correct, - explanation=option.explanation, - ) + await self.option_dao.create( + question_id=new_question.id, + text=option.text, + is_correct=option.is_correct, + explanation=option.explanation, + ) return new_test diff --git a/src/trudex/infrastructure/database/repo/user.py b/src/trudex/infrastructure/database/repo/user.py new file mode 100644 index 0000000..681e0be --- /dev/null +++ b/src/trudex/infrastructure/database/repo/user.py @@ -0,0 +1,61 @@ +from typing import final + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.domain.schemas import User +from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.database.dto.user import UserDTO +from trudex.infrastructure.database.models import User as UserModel + + +@final +class UserRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.user_dao = UserDAO(session) + + async def get_admins(self) -> list[User]: + result = await self.session.execute( + select(UserModel).where(UserModel.is_admin == True) + ) + models = list(result.scalars().all()) + return [UserDTO(model).to_domain() for model in models] + + async def get_users_by_group(self, group: int) -> list[User]: + result = await self.session.execute( + select(UserModel).where(UserModel.group == group) + ) + models = list(result.scalars().all()) + return [UserDTO(model).to_domain() for model in models] + + async def get_users_without_group(self) -> list[User]: + result = await self.session.execute( + select(UserModel).where(UserModel.group == None) + ) + models = list(result.scalars().all()) + return [UserDTO(model).to_domain() for model in models] + + async def is_admin(self, user_id: int) -> bool: + user = await self.user_dao.get_by_id(user_id) + return user.is_admin if user else False + + async def has_group(self, user_id: int) -> bool: + user = await self.user_dao.get_by_id(user_id) + return user.group is not None if user else False + + async def count_users_by_group(self, group: int) -> int: + result = await self.session.execute( + select(func.count(UserModel.id)) + .where(UserModel.group == group) + ) + count = result.scalar_one() + return count + + async def count_admins(self) -> int: + result = await self.session.execute( + select(func.count(UserModel.id)) + .where(UserModel.is_admin == True) + ) + count = result.scalar_one() + return count diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index b674407..beff360 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -4,6 +4,12 @@ from dishka import Provider, Scope, provide from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from trudex.infrastructure.database.config import new_session_maker +from trudex.infrastructure.database.dao.option import OptionDAO +from trudex.infrastructure.database.dao.question import QuestionDAO +from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.config import Config @@ -20,5 +26,25 @@ class DatabaseProvider(Provider): yield session @provide(scope=Scope.REQUEST) - async def get_users_dao(self, session: AsyncSession) -> UsersDAO: - return UsersDAO(session) + def get_user_dao(self, session: AsyncSession) -> UserDAO: + return UserDAO(session) + + @provide(scope=Scope.REQUEST) + def get_test_dao(self, session: AsyncSession) -> TestDAO: + return TestDAO(session) + + @provide(scope=Scope.REQUEST) + def get_question_dao(self, session: AsyncSession) -> QuestionDAO: + return QuestionDAO(session) + + @provide(scope=Scope.REQUEST) + def get_option_dao(self, session: AsyncSession) -> OptionDAO: + return OptionDAO(session) + + @provide(scope=Scope.REQUEST) + def get_user_repository(self, session: AsyncSession) -> UserRepository: + return UserRepository(session) + + @provide(scope=Scope.REQUEST) + def get_test_repository(self, session: AsyncSession) -> TestRepository: + return TestRepository(session) From 046a117b09559b88f8812d9d34bfcff679488c7a Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 1 Jan 2026 03:23:00 +0300 Subject: [PATCH 08/57] Initial commit --- config.example.toml | 1 + justfile | 2 +- src/trudex/__main__.py | 18 ---------- src/trudex/application/__main__.py | 43 +++++++++++++++++++++++ src/trudex/application/bot/handlers.py | 6 ++++ src/trudex/infrastructure/di.py | 4 +++ src/trudex/infrastructure/utils/config.py | 18 +++++----- 7 files changed, 65 insertions(+), 27 deletions(-) delete mode 100644 src/trudex/__main__.py create mode 100644 src/trudex/application/__main__.py diff --git a/config.example.toml b/config.example.toml index 517019f..a85fbf9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,6 @@ [bot] token = "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" +creator_id = 123456789 [database] host = "localhost" diff --git a/justfile b/justfile index 569df4a..e8c867f 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,7 @@ set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] set shell := ["bash", "-c"] dev: - watchfiles --filter python "python -m trudex.application" src + watchfiles --filter python ".venv/Scripts/python -m trudex.application" src run: python -m trudex diff --git a/src/trudex/__main__.py b/src/trudex/__main__.py deleted file mode 100644 index a7daa55..0000000 --- a/src/trudex/__main__.py +++ /dev/null @@ -1,18 +0,0 @@ -import asyncio -import logging - -from aiogram import Bot, Dispatcher - -from trudex.infrastructure.utils.config import Config - - -async def main(): - logging.basicConfig(level=logging.INFO) - - config = Config.from_toml("config.toml") - - logging.info("Бот запущен") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py new file mode 100644 index 0000000..e344ca8 --- /dev/null +++ b/src/trudex/application/__main__.py @@ -0,0 +1,43 @@ +import asyncio +import logging + +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from dishka import make_async_container +from dishka.integrations.aiogram import setup_dishka + +from trudex.application.bot.handlers import router +from trudex.infrastructure.di import DatabaseProvider +from trudex.infrastructure.utils.config import Config + + +async def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + + config = Config.from_toml("config.toml") + + bot = Bot( + token=config.bot.token, + default=DefaultBotProperties(parse_mode=ParseMode.HTML) + ) + + dp = Dispatcher() + dp.include_router(router) + + container = make_async_container(DatabaseProvider()) + setup_dishka(container, dp) + + logging.info("Бот запущен") + + try: + await dp.start_polling(bot) + finally: + await bot.session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 1e22eb6..d66aea4 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,3 +1,9 @@ from aiogram import Router +from aiogram.filters import CommandStart +from aiogram.types import Message router = Router() + +@router.message(CommandStart()) +async def start_handler(message: Message) -> None: + await message.answer("Привет! Я бот для тестирования по охране труда.") diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index beff360..0536352 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -14,6 +14,10 @@ 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 8cc30a7..5ffb45c 100644 --- a/src/trudex/infrastructure/utils/config.py +++ b/src/trudex/infrastructure/utils/config.py @@ -7,6 +7,7 @@ from typing import Self @dataclass class BotConfig: token: str + creator_id: int @dataclass @@ -30,20 +31,21 @@ class Config: @classmethod def from_toml(cls, path: str | Path) -> Self: with open(path, "rb") as f: - data: dict[str, dict[str, str]] = tomllib.load(f) + data: dict[str, dict[str, str | int]] = tomllib.load(f) - bot_data: dict[str, str] = data["bot"] - db_data: dict[str, str] = data["database"] + bot_data: dict[str, str | int] = data["bot"] + db_data: dict[str, str | int] = data["database"] return cls( bot=BotConfig( - token=bot_data["token"] + token=str(bot_data["token"]), + creator_id=int(bot_data["creator_id"]) ), database=DatabaseConfig( - host=db_data["host"], + host=str(db_data["host"]), port=db_data["port"], - user=db_data["user"], - password=db_data["password"], - database=db_data["database"] + user=str(db_data["user"]), + password=str(db_data["password"]), + database=str(db_data["database"]) ) ) From 9836ecfd426194c2ad0c0fcd8573e557231af72b Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 1 Jan 2026 16:23:41 +0300 Subject: [PATCH 09/57] Initial commit --- src/trudex/domain/schemas.py | 21 ++ .../database/dao/test_attempt.py | 80 +++++++ .../database/dao/user_answer.py | 80 +++++++ .../database/dto/test_attempt.py | 18 ++ .../database/dto/user_answer.py | 17 ++ src/trudex/infrastructure/database/models.py | 36 ++++ .../infrastructure/database/repo/__init__.py | 3 +- .../database/repo/test_attempt.py | 197 ++++++++++++++++++ src/trudex/infrastructure/di.py | 15 ++ 9 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 src/trudex/infrastructure/database/dao/test_attempt.py create mode 100644 src/trudex/infrastructure/database/dao/user_answer.py create mode 100644 src/trudex/infrastructure/database/dto/test_attempt.py create mode 100644 src/trudex/infrastructure/database/dto/user_answer.py create mode 100644 src/trudex/infrastructure/database/repo/test_attempt.py diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index c269624..ad55ab9 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -42,3 +42,24 @@ class Option: text: str is_correct: bool = False explanation: str | None = None + + +@dataclass +class TestAttempt: + id: int + user_id: int + test_id: int + started_at: datetime + finished_at: datetime | None = None + score: int = 0 + is_passed: bool = False + + +@dataclass +class UserAnswer: + id: int + attempt_id: int + question_id: int + selected_option_id: int | None = None + text_answer: str | None = None + is_correct: bool = False diff --git a/src/trudex/infrastructure/database/dao/test_attempt.py b/src/trudex/infrastructure/database/dao/test_attempt.py new file mode 100644 index 0000000..551c283 --- /dev/null +++ b/src/trudex/infrastructure/database/dao/test_attempt.py @@ -0,0 +1,80 @@ +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.domain.schemas import TestAttempt as DomainTestAttempt +from trudex.infrastructure.database.dto.test_attempt import TestAttemptDTO +from trudex.infrastructure.database.models import TestAttempt + + +class TestAttemptDAO: + def __init__(self, session: AsyncSession) -> None: + self.session: AsyncSession = session + + async def get_by_id(self, attempt_id: int) -> DomainTestAttempt | None: + result = await self.session.execute( + select(TestAttempt).where(TestAttempt.id == attempt_id) + ) + model = result.scalar_one_or_none() + return TestAttemptDTO(model).to_domain() if model else None + + async def get_all(self) -> list[DomainTestAttempt]: + result = await self.session.execute(select(TestAttempt)) + models = list(result.scalars().all()) + return [TestAttemptDTO(model).to_domain() for model in models] + + async def create( + self, + user_id: int, + test_id: int, + score: int = 0, + is_passed: bool = False, + ) -> DomainTestAttempt: + attempt = TestAttempt( + user_id=user_id, + test_id=test_id, + score=score, + is_passed=is_passed, + ) + self.session.add(attempt) + await self.session.flush() + await self.session.refresh(attempt) + return TestAttemptDTO(attempt).to_domain() + + async def update( + self, + attempt_id: int, + finished_at: datetime | None = None, + score: int | None = None, + is_passed: bool | None = None, + ) -> DomainTestAttempt | None: + result = await self.session.execute( + select(TestAttempt).where(TestAttempt.id == attempt_id) + ) + attempt = result.scalar_one_or_none() + if not attempt: + return None + + if finished_at is not None: + attempt.finished_at = finished_at + if score is not None: + attempt.score = score + if is_passed is not None: + attempt.is_passed = is_passed + + await self.session.flush() + await self.session.refresh(attempt) + return TestAttemptDTO(attempt).to_domain() + + async def delete(self, attempt_id: int) -> bool: + result = await self.session.execute( + select(TestAttempt).where(TestAttempt.id == attempt_id) + ) + attempt = result.scalar_one_or_none() + if not attempt: + return False + + await self.session.delete(attempt) + await self.session.flush() + return True diff --git a/src/trudex/infrastructure/database/dao/user_answer.py b/src/trudex/infrastructure/database/dao/user_answer.py new file mode 100644 index 0000000..57be605 --- /dev/null +++ b/src/trudex/infrastructure/database/dao/user_answer.py @@ -0,0 +1,80 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.domain.schemas import UserAnswer as DomainUserAnswer +from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO +from trudex.infrastructure.database.models import UserAnswer + + +class UserAnswerDAO: + def __init__(self, session: AsyncSession) -> None: + self.session: AsyncSession = session + + async def get_by_id(self, answer_id: int) -> DomainUserAnswer | None: + result = await self.session.execute( + select(UserAnswer).where(UserAnswer.id == answer_id) + ) + model = result.scalar_one_or_none() + return UserAnswerDTO(model).to_domain() if model else None + + async def get_all(self) -> list[DomainUserAnswer]: + result = await self.session.execute(select(UserAnswer)) + models = list(result.scalars().all()) + return [UserAnswerDTO(model).to_domain() for model in models] + + async def create( + self, + attempt_id: int, + question_id: int, + selected_option_id: int | None = None, + text_answer: str | None = None, + is_correct: bool = False, + ) -> DomainUserAnswer: + answer = UserAnswer( + attempt_id=attempt_id, + question_id=question_id, + selected_option_id=selected_option_id, + text_answer=text_answer, + is_correct=is_correct, + ) + self.session.add(answer) + await self.session.flush() + await self.session.refresh(answer) + return UserAnswerDTO(answer).to_domain() + + async def update( + self, + answer_id: int, + selected_option_id: int | None = None, + text_answer: str | None = None, + is_correct: bool | None = None, + ) -> DomainUserAnswer | None: + result = await self.session.execute( + select(UserAnswer).where(UserAnswer.id == answer_id) + ) + answer = result.scalar_one_or_none() + if not answer: + return None + + if selected_option_id is not None: + answer.selected_option_id = selected_option_id + if text_answer is not None: + answer.text_answer = text_answer + if is_correct is not None: + answer.is_correct = is_correct + + await self.session.flush() + await self.session.refresh(answer) + return UserAnswerDTO(answer).to_domain() + + async def delete(self, answer_id: int) -> bool: + result = await self.session.execute( + select(UserAnswer).where(UserAnswer.id == answer_id) + ) + answer = result.scalar_one_or_none() + if not answer: + return False + + await self.session.delete(answer) + await self.session.flush() + return True diff --git a/src/trudex/infrastructure/database/dto/test_attempt.py b/src/trudex/infrastructure/database/dto/test_attempt.py new file mode 100644 index 0000000..786eb38 --- /dev/null +++ b/src/trudex/infrastructure/database/dto/test_attempt.py @@ -0,0 +1,18 @@ +from trudex.domain.schemas import TestAttempt as DomainTestAttempt +from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel + + +class TestAttemptDTO: + def __init__(self, model: TestAttemptModel) -> None: + self.model: TestAttemptModel = model + + def to_domain(self) -> DomainTestAttempt: + return DomainTestAttempt( + id=self.model.id, + user_id=self.model.user_id, + test_id=self.model.test_id, + started_at=self.model.started_at, + finished_at=self.model.finished_at, + score=self.model.score, + is_passed=self.model.is_passed, + ) diff --git a/src/trudex/infrastructure/database/dto/user_answer.py b/src/trudex/infrastructure/database/dto/user_answer.py new file mode 100644 index 0000000..58f0ddc --- /dev/null +++ b/src/trudex/infrastructure/database/dto/user_answer.py @@ -0,0 +1,17 @@ +from trudex.domain.schemas import UserAnswer as DomainUserAnswer +from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel + + +class UserAnswerDTO: + def __init__(self, model: UserAnswerModel) -> None: + self.model: UserAnswerModel = model + + def to_domain(self) -> DomainUserAnswer: + return DomainUserAnswer( + id=self.model.id, + attempt_id=self.model.attempt_id, + question_id=self.model.question_id, + selected_option_id=self.model.selected_option_id, + text_answer=self.model.text_answer, + is_correct=self.model.is_correct, + ) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index b4e1f29..561c657 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -78,3 +78,39 @@ class Option(Base): explanation: Mapped[str | None] = mapped_column(Text) question: Mapped["Question"] = relationship(back_populates="options") + + +@final +class TestAttempt(Base): + __tablename__ = "test_attempts" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(BigInteger, index=True) + test_id: Mapped[int] = mapped_column(ForeignKey("tests.id")) + started_at: Mapped[datetime] = mapped_column(server_default=func.now()) + finished_at: Mapped[datetime | None] = mapped_column(default=None) + score: Mapped[int] = mapped_column(Integer, default=0) + is_passed: Mapped[bool] = mapped_column(default=False) + + test: Mapped["Test"] = relationship() + answers: Mapped[list["UserAnswer"]] = relationship( + back_populates="attempt", + cascade="all, delete-orphan" + ) + + +@final +class UserAnswer(Base): + __tablename__ = "user_answers" + + id: Mapped[int] = mapped_column(primary_key=True) + attempt_id: Mapped[int] = mapped_column(ForeignKey("test_attempts.id")) + question_id: Mapped[int] = mapped_column(ForeignKey("questions.id")) + selected_option_id: Mapped[int | None] = mapped_column(ForeignKey("options.id"), default=None) + text_answer: Mapped[str | None] = mapped_column(Text, default=None) + is_correct: Mapped[bool] = mapped_column(default=False) + + attempt: Mapped["TestAttempt"] = relationship(back_populates="answers") + question: Mapped["Question"] = relationship() + selected_option: Mapped["Option | None"] = relationship() + diff --git a/src/trudex/infrastructure/database/repo/__init__.py b/src/trudex/infrastructure/database/repo/__init__.py index 8a2838f..3d25dad 100644 --- a/src/trudex/infrastructure/database/repo/__init__.py +++ b/src/trudex/infrastructure/database/repo/__init__.py @@ -1,4 +1,5 @@ from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository -__all__ = ["TestRepository", "UserRepository"] +__all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"] diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py new file mode 100644 index 0000000..d6043cb --- /dev/null +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -0,0 +1,197 @@ +from datetime import datetime +from typing import final + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from trudex.domain.schemas import TestAttempt, UserAnswer +from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO +from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO +from trudex.infrastructure.database.dto.test_attempt import TestAttemptDTO +from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO +from trudex.infrastructure.database.models import ( + TestAttempt as TestAttemptModel, + UserAnswer as UserAnswerModel, +) + + +@final +class TestAttemptRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.attempt_dao = TestAttemptDAO(session) + self.answer_dao = UserAnswerDAO(session) + + async def get_user_attempts(self, user_id: int) -> list[TestAttempt]: + result = await self.session.execute( + select(TestAttemptModel) + .where(TestAttemptModel.user_id == user_id) + .order_by(TestAttemptModel.started_at.desc()) + ) + models = list(result.scalars().all()) + return [TestAttemptDTO(model).to_domain() for model in models] + + async def get_test_attempts(self, test_id: int) -> list[TestAttempt]: + result = await self.session.execute( + select(TestAttemptModel) + .where(TestAttemptModel.test_id == test_id) + .order_by(TestAttemptModel.started_at.desc()) + ) + models = list(result.scalars().all()) + return [TestAttemptDTO(model).to_domain() for model in models] + + async def get_user_test_attempts(self, user_id: int, test_id: int) -> list[TestAttempt]: + result = await self.session.execute( + select(TestAttemptModel) + .where(TestAttemptModel.user_id == user_id) + .where(TestAttemptModel.test_id == test_id) + .order_by(TestAttemptModel.started_at.desc()) + ) + models = list(result.scalars().all()) + return [TestAttemptDTO(model).to_domain() for model in models] + + async def get_active_attempt(self, user_id: int, test_id: int) -> TestAttempt | None: + result = await self.session.execute( + select(TestAttemptModel) + .where(TestAttemptModel.user_id == user_id) + .where(TestAttemptModel.test_id == test_id) + .where(TestAttemptModel.finished_at == None) + .order_by(TestAttemptModel.started_at.desc()) + ) + model = result.scalar_one_or_none() + return TestAttemptDTO(model).to_domain() if model else None + + async def get_attempt_with_answers(self, attempt_id: int) -> tuple[TestAttempt | None, list[UserAnswer]]: + attempt = await self.attempt_dao.get_by_id(attempt_id) + if not attempt: + return None, [] + + result = await self.session.execute( + select(TestAttemptModel) + .where(TestAttemptModel.id == attempt_id) + .options(selectinload(TestAttemptModel.answers)) + ) + attempt_model = result.scalar_one_or_none() + if not attempt_model: + return attempt, [] + + answers = [UserAnswerDTO(answer).to_domain() for answer in attempt_model.answers] + return attempt, answers + + async def get_answers_for_attempt(self, attempt_id: int) -> list[UserAnswer]: + result = await self.session.execute( + select(UserAnswerModel) + .where(UserAnswerModel.attempt_id == attempt_id) + ) + models = list(result.scalars().all()) + return [UserAnswerDTO(model).to_domain() for model in models] + + async def count_user_attempts(self, user_id: int, test_id: int | None = None) -> int: + query = select(func.count(TestAttemptModel.id)).where(TestAttemptModel.user_id == user_id) + if test_id is not None: + query = query.where(TestAttemptModel.test_id == test_id) + + result = await self.session.execute(query) + count = result.scalar_one() + return count + + async def count_passed_attempts(self, user_id: int, test_id: int | None = None) -> int: + query = ( + select(func.count(TestAttemptModel.id)) + .where(TestAttemptModel.user_id == user_id) + .where(TestAttemptModel.is_passed == True) + ) + if test_id is not None: + query = query.where(TestAttemptModel.test_id == test_id) + + result = await self.session.execute(query) + count = result.scalar_one() + return count + + async def get_best_attempt(self, user_id: int, test_id: int) -> TestAttempt | None: + result = await self.session.execute( + select(TestAttemptModel) + .where(TestAttemptModel.user_id == user_id) + .where(TestAttemptModel.test_id == test_id) + .where(TestAttemptModel.finished_at != None) + .order_by(TestAttemptModel.score.desc(), TestAttemptModel.started_at.asc()) + ) + model = result.scalar_one_or_none() + return TestAttemptDTO(model).to_domain() if model else None + + async def get_latest_attempt(self, user_id: int, test_id: int) -> TestAttempt | None: + result = await self.session.execute( + select(TestAttemptModel) + .where(TestAttemptModel.user_id == user_id) + .where(TestAttemptModel.test_id == test_id) + .order_by(TestAttemptModel.started_at.desc()) + ) + model = result.scalar_one_or_none() + return TestAttemptDTO(model).to_domain() if model else None + + async def finish_attempt(self, attempt_id: int, score: int, is_passed: bool) -> TestAttempt | None: + return await self.attempt_dao.update( + attempt_id=attempt_id, + finished_at=datetime.utcnow(), + score=score, + is_passed=is_passed + ) + + async def calculate_attempt_score(self, attempt_id: int) -> int: + result = await self.session.execute( + select(func.count(UserAnswerModel.id)) + .where(UserAnswerModel.attempt_id == attempt_id) + .where(UserAnswerModel.is_correct == True) + ) + count = result.scalar_one() + return count + + async def get_incorrect_answers(self, attempt_id: int) -> list[UserAnswer]: + result = await self.session.execute( + select(UserAnswerModel) + .where(UserAnswerModel.attempt_id == attempt_id) + .where(UserAnswerModel.is_correct == False) + ) + models = list(result.scalars().all()) + return [UserAnswerDTO(model).to_domain() for model in models] + + async def get_question_statistics(self, question_id: int) -> dict[str, int]: + total_result = await self.session.execute( + select(func.count(UserAnswerModel.id)) + .where(UserAnswerModel.question_id == question_id) + ) + total = total_result.scalar_one() + + correct_result = await self.session.execute( + select(func.count(UserAnswerModel.id)) + .where(UserAnswerModel.question_id == question_id) + .where(UserAnswerModel.is_correct == True) + ) + correct = correct_result.scalar_one() + + return { + "total_answers": total, + "correct_answers": correct, + "incorrect_answers": total - correct, + } + + async def get_most_difficult_questions(self, test_id: int, limit: int = 10) -> list[tuple[int, float]]: + from trudex.infrastructure.database.models import Question as QuestionModel + + result = await self.session.execute( + select( + UserAnswerModel.question_id, + func.count(UserAnswerModel.id).label("total"), + func.sum(func.cast(UserAnswerModel.is_correct, func.Integer)).label("correct") + ) + .join(QuestionModel, UserAnswerModel.question_id == QuestionModel.id) + .where(QuestionModel.test_id == test_id) + .group_by(UserAnswerModel.question_id) + .having(func.count(UserAnswerModel.id) > 0) + .order_by((func.sum(func.cast(UserAnswerModel.is_correct, func.Integer)) / func.count(UserAnswerModel.id)).asc()) + .limit(limit) + ) + + rows = result.all() + return [(row.question_id, row.correct / row.total if row.total > 0 else 0.0) for row in rows] diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index 0536352..a6eb442 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -7,8 +7,11 @@ from trudex.infrastructure.database.config import new_session_maker from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.config import Config @@ -45,6 +48,14 @@ class DatabaseProvider(Provider): def get_option_dao(self, session: AsyncSession) -> OptionDAO: return OptionDAO(session) + @provide(scope=Scope.REQUEST) + def get_test_attempt_dao(self, session: AsyncSession) -> TestAttemptDAO: + return TestAttemptDAO(session) + + @provide(scope=Scope.REQUEST) + def get_user_answer_dao(self, session: AsyncSession) -> UserAnswerDAO: + return UserAnswerDAO(session) + @provide(scope=Scope.REQUEST) def get_user_repository(self, session: AsyncSession) -> UserRepository: return UserRepository(session) @@ -52,3 +63,7 @@ class DatabaseProvider(Provider): @provide(scope=Scope.REQUEST) def get_test_repository(self, session: AsyncSession) -> TestRepository: return TestRepository(session) + + @provide(scope=Scope.REQUEST) + def get_test_attempt_repository(self, session: AsyncSession) -> TestAttemptRepository: + return TestAttemptRepository(session) From ead8fbe1a078c80dc29555dd71fcad8b1c861536 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 1 Jan 2026 23:00:52 +0300 Subject: [PATCH 10/57] Initial commit --- .../versions/f63140aa50c0_test_attempts.py | 54 +++++ src/trudex/application/__main__.py | 13 +- .../application/bot/admin_dialogs/__init__.py | 1 - .../bot/admin_dialogs/main_menu.py | 141 +++++++++++++ .../application/bot/admin_dialogs/states.py | 8 + .../bot/creator_dialogs/__init__.py | 0 .../bot/creator_dialogs/main_menu.py | 198 ++++++++++++++++++ .../application/bot/creator_dialogs/states.py | 9 + src/trudex/application/bot/handlers.py | 31 ++- .../bot/middlewares/reject_not_admin.py | 29 +-- .../bot/middlewares/reject_not_creator.py | 36 ++++ src/trudex/infrastructure/di.py | 1 + 12 files changed, 505 insertions(+), 16 deletions(-) create mode 100644 alembic/versions/f63140aa50c0_test_attempts.py create mode 100644 src/trudex/application/bot/admin_dialogs/main_menu.py create mode 100644 src/trudex/application/bot/admin_dialogs/states.py create mode 100644 src/trudex/application/bot/creator_dialogs/__init__.py create mode 100644 src/trudex/application/bot/creator_dialogs/main_menu.py create mode 100644 src/trudex/application/bot/creator_dialogs/states.py create mode 100644 src/trudex/application/bot/middlewares/reject_not_creator.py diff --git a/alembic/versions/f63140aa50c0_test_attempts.py b/alembic/versions/f63140aa50c0_test_attempts.py new file mode 100644 index 0000000..3e7c86d --- /dev/null +++ b/alembic/versions/f63140aa50c0_test_attempts.py @@ -0,0 +1,54 @@ +"""test attempts + +Revision ID: f63140aa50c0 +Revises: 59dd00dc1990 +Create Date: 2026-01-01 16:26:43.398213 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'f63140aa50c0' +down_revision: str | None = '59dd00dc1990' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('test_attempts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('test_id', sa.Integer(), nullable=False), + sa.Column('started_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('finished_at', sa.DateTime(), nullable=True), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('is_passed', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['test_id'], ['tests.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_test_attempts_user_id'), 'test_attempts', ['user_id'], unique=False) + op.create_table('user_answers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('attempt_id', sa.Integer(), nullable=False), + sa.Column('question_id', sa.Integer(), nullable=False), + sa.Column('selected_option_id', sa.Integer(), nullable=True), + sa.Column('text_answer', sa.Text(), nullable=True), + sa.Column('is_correct', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['attempt_id'], ['test_attempts.id'], ), + sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ), + sa.ForeignKeyConstraint(['selected_option_id'], ['options.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_answers') + op.drop_index(op.f('ix_test_attempts_user_id'), table_name='test_attempts') + op.drop_table('test_attempts') + # ### end Alembic commands ### diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index e344ca8..87487e7 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -4,10 +4,15 @@ import logging from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode +from aiogram_dialog import setup_dialogs from dishka import make_async_container from dishka.integrations.aiogram import setup_dishka +from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog +from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog from trudex.application.bot.handlers import router +from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware +from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware from trudex.infrastructure.di import DatabaseProvider from trudex.infrastructure.utils.config import Config @@ -26,10 +31,16 @@ async def main() -> None: ) dp = Dispatcher() + dp.message.middleware(RejectNotAdminMiddleware()) + dp.message.middleware(RejectNotCreatorMiddleware()) dp.include_router(router) + dp.include_router(admin_menu_dialog) + dp.include_router(creator_menu_dialog) + container = make_async_container(DatabaseProvider()) - setup_dishka(container, dp) + setup_dishka(container, dp, auto_inject=True) + setup_dialogs(dp) logging.info("Бот запущен") diff --git a/src/trudex/application/bot/admin_dialogs/__init__.py b/src/trudex/application/bot/admin_dialogs/__init__.py index 8b13789..e69de29 100644 --- a/src/trudex/application/bot/admin_dialogs/__init__.py +++ b/src/trudex/application/bot/admin_dialogs/__init__.py @@ -1 +0,0 @@ - diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py new file mode 100644 index 0000000..bf8b240 --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -0,0 +1,141 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Back, Button, Column, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.admin_dialogs.states import AdminMenuSG +from trudex.infrastructure.database.dao.user import UserDAO + + +@inject +async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): + users = await user_dao.get_all() + + return { + "users": [ + (f"{u.first_name} (@{u.username or 'нет'})", u.id) + for u in users + ], + "count": len(users), + } + + +@inject +async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"user_info": "Пользователь не выбран"} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"user_info": "Пользователь не найден"} + + username_str = f"@{user.username}" if user.username else "—" + last_name_str = user.last_name or "—" + group_str = str(user.group) if user.group else "—" + admin_status = "✅ Да" if user.is_admin else "❌ Нет" + + user_info = ( + f"👤 Информация о пользователе\n\n" + f"ID: {user.id}\n" + f"Имя: {user.first_name}\n" + f"Фамилия: {last_name_str}\n" + f"Username: {username_str}\n" + f"Группа: {group_str}\n" + f"Администратор: {admin_status}" + ) + + return {"user_info": user_info} + + +async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_user_id"] = int(item_id) + await manager.switch_to(AdminMenuSG.user_detail) + + +async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminMenuSG.users_input) + + +async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): + from dishka.integrations.aiogram import CONTAINER_NAME + container = manager.middleware_data[CONTAINER_NAME] + user_dao = await container.get(UserDAO) + + text = (message.text or "").strip() + + user = None + if text.startswith("@"): + username = text[1:] + all_users = await user_dao.get_all() + user = next((u for u in all_users if u.username == username), None) + elif text.isdigit(): + user = await user_dao.get_by_id(int(text)) + + if not user: + await message.answer("❌ Пользователь не найден в базе данных.") + return + + manager.dialog_data["selected_user_id"] = user.id + await manager.switch_to(AdminMenuSG.user_detail) + + +async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("Управление тестами") + + +async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(AdminMenuSG.users_list) + + +async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("Рассылка") + + +admin_menu_dialog = Dialog( + Window( + Const("🔧 Админ-панель\n\nВыберите раздел:"), + Column( + Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked), + Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked), + Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked), + ), + state=AdminMenuSG.main, + ), + Window( + Format("👥 Пользователи\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="user_select", + item_id_getter=lambda x: x[1], + items="users", + on_click=on_user_selected, + ), + id="users_scroll", + width=1, + height=7, + ), + Column( + Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode), + Back(Const("◀️ Назад")), + ), + state=AdminMenuSG.users_list, + getter=get_users_data, + ), + Window( + Const("Введите ID или @username пользователя:"), + MessageInput(on_user_input), + Back(Const("◀️ Назад")), + state=AdminMenuSG.users_input, + ), + Window( + Format("{user_info}"), + Back(Const("◀️ Назад")), + state=AdminMenuSG.user_detail, + getter=get_user_detail_data, + ), +) diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py new file mode 100644 index 0000000..22127fe --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -0,0 +1,8 @@ +from aiogram.fsm.state import State, StatesGroup + + +class AdminMenuSG(StatesGroup): + main = State() + users_list = State() + users_input = State() + user_detail = State() diff --git a/src/trudex/application/bot/creator_dialogs/__init__.py b/src/trudex/application/bot/creator_dialogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py new file mode 100644 index 0000000..e651161 --- /dev/null +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -0,0 +1,198 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG +from trudex.infrastructure.database.dao.user import UserDAO + + +@inject +async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): + users = await user_dao.get_all() + + return { + "users": [ + (f"{u.first_name} (@{u.username or 'нет'})", u.id) + for u in users + ], + "count": len(users), + } + + +@inject +async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"user_info": "Пользователь не выбран", "is_admin": True, "show_make_admin": False} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"user_info": "Пользователь не найден", "is_admin": True, "show_make_admin": False} + + username_str = f"@{user.username}" if user.username else "—" + last_name_str = user.last_name or "—" + group_str = str(user.group) if user.group else "—" + admin_status = "✅ Да" if user.is_admin else "❌ Нет" + + user_info = ( + f"👤 Информация о пользователе\n\n" + f"ID: {user.id}\n" + f"Имя: {user.first_name}\n" + f"Фамилия: {last_name_str}\n" + f"Username: {username_str}\n" + f"Группа: {group_str}\n" + f"Администратор: {admin_status}" + ) + + return { + "user_info": user_info, + "is_admin": user.is_admin, + "show_make_admin": not user.is_admin, + } + + +@inject +async def get_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"user_info": "Пользователь не выбран"} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"user_info": "Пользователь не найден"} + + username_str = f"@{user.username}" if user.username else "—" + return { + "user_info": f"{user.first_name}\n{username_str}\nID: {user.id}" + } + + +async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_user_id"] = int(item_id) + await manager.switch_to(CreatorMenuSG.user_detail) + + +async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorMenuSG.users_input) + + +async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): + from dishka.integrations.aiogram import CONTAINER_NAME + container = manager.middleware_data[CONTAINER_NAME] + user_dao = await container.get(UserDAO) + + text = (message.text or "").strip() + + user = None + if text.startswith("@"): + username = text[1:] + all_users = await user_dao.get_all() + user = next((u for u in all_users if u.username == username), None) + elif text.isdigit(): + user = await user_dao.get_by_id(int(text)) + + if not user: + await message.answer("❌ Пользователь не найден в базе данных.") + return + + manager.dialog_data["selected_user_id"] = user.id + await manager.switch_to(CreatorMenuSG.user_detail) + + +async def on_make_admin_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorMenuSG.make_admin_confirm) + + +async def on_confirm_yes(_callback: CallbackQuery, _button: Button, manager: DialogManager): + from dishka.integrations.aiogram import CONTAINER_NAME + container = manager.middleware_data[CONTAINER_NAME] + user_dao = await container.get(UserDAO) + + user_id = manager.dialog_data.get("selected_user_id") + if not user_id: + await _callback.answer("Ошибка: пользователь не выбран") + return + + await user_dao.update(user_id=user_id, is_admin=True) + await _callback.answer("✅ Пользователь назначен администратором") + await manager.switch_to(CreatorMenuSG.user_detail) + + +async def on_confirm_no(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await _callback.answer("Отменено") + await manager.switch_to(CreatorMenuSG.user_detail) + + +async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("Тесты") + + +async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(CreatorMenuSG.users_list) + + +async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("Рассылка") + + +creator_menu_dialog = Dialog( + Window( + Const("👑 Панель создателя\n\nВыберите раздел:"), + Column( + Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked), + Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked), + Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked), + ), + state=CreatorMenuSG.main, + ), + Window( + Format("👥 Пользователи\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="user_select", + item_id_getter=lambda x: x[1], + items="users", + on_click=on_user_selected, + ), + id="users_scroll", + width=1, + height=7, + ), + Column( + Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode), + Cancel(Const("◀️ Назад")), + ), + state=CreatorMenuSG.users_list, + getter=get_users_data, + ), + Window( + Const("Введите ID или @username пользователя:"), + MessageInput(on_user_input), + Back(Const("◀️ Назад")), + state=CreatorMenuSG.users_input, + ), + Window( + Format("{user_info}"), + Column( + Button(Const("👑 Сделать администратором"), id="make_admin", on_click=on_make_admin_clicked, when="show_make_admin"), + Back(Const("◀️ Назад")), + ), + state=CreatorMenuSG.user_detail, + getter=get_user_detail_data, + ), + Window( + Const("⚠️ Подтверждение\n\nВы уверены, что хотите назначить этого пользователя администратором?\n"), + Format("{user_info}"), + Row( + Button(Const("✅ Да"), id="confirm_yes", on_click=on_confirm_yes), + Button(Const("❌ Нет"), id="confirm_no", on_click=on_confirm_no), + ), + state=CreatorMenuSG.make_admin_confirm, + getter=get_confirm_data, + ), +) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py new file mode 100644 index 0000000..b8c1d05 --- /dev/null +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -0,0 +1,9 @@ +from aiogram.fsm.state import State, StatesGroup + + +class CreatorMenuSG(StatesGroup): + main = State() + users_list = State() + users_input = State() + user_detail = State() + make_admin_confirm = State() diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index d66aea4..81aad48 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,9 +1,36 @@ from aiogram import Router -from aiogram.filters import CommandStart +from aiogram.filters import Command, CommandStart from aiogram.types import Message +from aiogram_dialog import DialogManager, StartMode +from dishka.integrations.aiogram import FromDishka + +from trudex.application.bot.admin_dialogs.states import AdminMenuSG +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG +from trudex.infrastructure.database.dao.user import UserDAO + router = Router() + @router.message(CommandStart()) -async def start_handler(message: Message) -> None: +async def start_handler(message: Message, user_dao: FromDishka[UserDAO]) -> None: + assert message.from_user is not None + + await user_dao.upsert( + user_id=message.from_user.id, + first_name=message.from_user.first_name, + username=message.from_user.username, + last_name=message.from_user.last_name, + ) + await message.answer("Привет! Я бот для тестирования по охране труда.") + + +@router.message(Command("admin")) +async def admin_command(message: Message, dialog_manager: DialogManager) -> None: + await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + + +@router.message(Command("creator")) +async def creator_command(message: Message, dialog_manager: DialogManager) -> None: + await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) diff --git a/src/trudex/application/bot/middlewares/reject_not_admin.py b/src/trudex/application/bot/middlewares/reject_not_admin.py index c016590..a9d7f90 100644 --- a/src/trudex/application/bot/middlewares/reject_not_admin.py +++ b/src/trudex/application/bot/middlewares/reject_not_admin.py @@ -1,11 +1,12 @@ -from typing import Any, Callable from collections.abc import Awaitable +from typing import Any, Callable from aiogram import BaseMiddleware from aiogram.types import Message, TelegramObject from dishka import AsyncContainer from trudex.infrastructure.database.repo import UserRepository +from trudex.infrastructure.utils.config import Config class RejectNotAdminMiddleware(BaseMiddleware): @@ -23,15 +24,19 @@ class RejectNotAdminMiddleware(BaseMiddleware): container: AsyncContainer = data["dishka_container"] user_id = event.from_user.id admin_commands = ["/admin"] - if event.text: - if event.text.strip() in admin_commands: - users_dao: UserRepository = await container.get(UserRepository) - admins = await users_dao.get_admins() - if user_id in [admin.id for admin in admins]: - return await handler(event, data) - else: - pass - else: + + if event.text and event.text.strip() in admin_commands: + config: Config = await container.get(Config) + + if user_id == config.bot.creator_id: return await handler(event, data) - else: - return await handler(event, data) + + users_repo: UserRepository = await container.get(UserRepository) + is_admin = await users_repo.is_admin(user_id) + + if is_admin: + return await handler(event, data) + + return + + return await handler(event, data) diff --git a/src/trudex/application/bot/middlewares/reject_not_creator.py b/src/trudex/application/bot/middlewares/reject_not_creator.py new file mode 100644 index 0000000..8fded29 --- /dev/null +++ b/src/trudex/application/bot/middlewares/reject_not_creator.py @@ -0,0 +1,36 @@ +from collections.abc import Awaitable +from typing import Any, Callable + +from aiogram import BaseMiddleware +from aiogram.types import Message, TelegramObject +from dishka import AsyncContainer + +from trudex.infrastructure.utils.config import Config + + +class RejectNotCreatorMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + if not isinstance(event, Message): + return await handler(event, data) + + assert event.from_user is not None + + container: AsyncContainer = data["dishka_container"] + user_id = event.from_user.id + creator_commands = ["/creator"] + + if event.text and event.text.strip() in creator_commands: + config: Config = await container.get(Config) + + if user_id == config.bot.creator_id: + return await handler(event, data) + + await event.answer("У вас нет доступа к панели создателя.") + return + + return await handler(event, data) diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index a6eb442..db73904 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -31,6 +31,7 @@ class DatabaseProvider(Provider): ) -> AsyncIterable[AsyncSession]: async with session_maker() as session: yield session + await session.commit() @provide(scope=Scope.REQUEST) def get_user_dao(self, session: AsyncSession) -> UserDAO: From cfc4467b5631b8b63f30dc8c84f02f9eaaf9b447 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 1 Jan 2026 23:14:56 +0300 Subject: [PATCH 11/57] commit --- src/trudex/application/__main__.py | 8 +++++ .../bot/admin_dialogs/main_menu.py | 6 ++-- .../bot/creator_dialogs/main_menu.py | 6 ++-- src/trudex/application/bot/handlers.py | 18 ++++++++--- .../application/bot/user_dialogs/__init__.py | 1 - .../application/bot/user_dialogs/main_menu.py | 26 +++++++++++++++ .../application/bot/user_dialogs/states.py | 5 +++ .../infrastructure/utils/bot_commands.py | 32 +++++++++++++++++++ 8 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 src/trudex/application/bot/user_dialogs/main_menu.py create mode 100644 src/trudex/application/bot/user_dialogs/states.py create mode 100644 src/trudex/infrastructure/utils/bot_commands.py diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 87487e7..a728242 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -13,7 +13,9 @@ from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog from trudex.application.bot.handlers import router from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware +from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog from trudex.infrastructure.di import DatabaseProvider +from trudex.infrastructure.utils.bot_commands import setup_bot_commands from trudex.infrastructure.utils.config import Config @@ -35,6 +37,7 @@ async def main() -> None: dp.message.middleware(RejectNotCreatorMiddleware()) dp.include_router(router) + dp.include_router(user_menu_dialog) dp.include_router(admin_menu_dialog) dp.include_router(creator_menu_dialog) @@ -42,6 +45,11 @@ async def main() -> None: setup_dishka(container, dp, auto_inject=True) setup_dialogs(dp) + async with container() as request_container: + from trudex.infrastructure.database.repo.user import UserRepository + user_repo = await request_container.get(UserRepository) + await setup_bot_commands(bot, config, user_repo) + logging.info("Бот запущен") try: diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index bf8b240..7120cc3 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -1,7 +1,7 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Back, Button, Column, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import Back, Button, Column, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -129,12 +129,12 @@ admin_menu_dialog = Dialog( Window( Const("Введите ID или @username пользователя:"), MessageInput(on_user_input), - Back(Const("◀️ Назад")), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminMenuSG.users_list), state=AdminMenuSG.users_input, ), Window( Format("{user_info}"), - Back(Const("◀️ Назад")), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminMenuSG.users_list), state=AdminMenuSG.user_detail, getter=get_user_detail_data, ), diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index e651161..0972908 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -1,7 +1,7 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Row, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -173,14 +173,14 @@ creator_menu_dialog = Dialog( Window( Const("Введите ID или @username пользователя:"), MessageInput(on_user_input), - Back(Const("◀️ Назад")), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorMenuSG.users_list), state=CreatorMenuSG.users_input, ), Window( Format("{user_info}"), Column( Button(Const("👑 Сделать администратором"), id="make_admin", on_click=on_make_admin_clicked, when="show_make_admin"), - Back(Const("◀️ Назад")), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorMenuSG.users_list), ), state=CreatorMenuSG.user_detail, getter=get_user_detail_data, diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 81aad48..6df0610 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,11 +1,13 @@ from aiogram import Router from aiogram.filters import Command, CommandStart -from aiogram.types import Message +from aiogram.types import ErrorEvent, Message from aiogram_dialog import DialogManager, StartMode +from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent from dishka.integrations.aiogram import FromDishka from trudex.application.bot.admin_dialogs.states import AdminMenuSG from trudex.application.bot.creator_dialogs.states import CreatorMenuSG +from trudex.application.bot.user_dialogs.states import UserMenuSG from trudex.infrastructure.database.dao.user import UserDAO @@ -13,7 +15,7 @@ router = Router() @router.message(CommandStart()) -async def start_handler(message: Message, user_dao: FromDishka[UserDAO]) -> None: +async def start_handler(message: Message, user_dao: FromDishka[UserDAO], dialog_manager: DialogManager) -> None: assert message.from_user is not None await user_dao.upsert( @@ -23,14 +25,20 @@ async def start_handler(message: Message, user_dao: FromDishka[UserDAO]) -> None last_name=message.from_user.last_name, ) - await message.answer("Привет! Я бот для тестирования по охране труда.") + await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) @router.message(Command("admin")) -async def admin_command(message: Message, dialog_manager: DialogManager) -> None: +async def admin_command(_message: Message, dialog_manager: DialogManager) -> None: await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) @router.message(Command("creator")) -async def creator_command(message: Message, dialog_manager: DialogManager) -> None: +async def creator_command(_message: Message, dialog_manager: DialogManager) -> None: await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + + +@router.error() +async def dialog_error_handler(event: ErrorEvent, dialog_manager: DialogManager) -> None: + if isinstance(event.exception, (UnknownIntent, OutdatedIntent)): + await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) diff --git a/src/trudex/application/bot/user_dialogs/__init__.py b/src/trudex/application/bot/user_dialogs/__init__.py index 8b13789..e69de29 100644 --- a/src/trudex/application/bot/user_dialogs/__init__.py +++ b/src/trudex/application/bot/user_dialogs/__init__.py @@ -1 +0,0 @@ - diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py new file mode 100644 index 0000000..9779efa --- /dev/null +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -0,0 +1,26 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import Dialog, DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Column +from aiogram_dialog.widgets.text import Const + +from trudex.application.bot.user_dialogs.states import UserMenuSG + + +async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("Доступные тесты") + + +async def on_results_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("Мои результаты") + + +user_menu_dialog = Dialog( + Window( + Const("📚 Главное меню\n\nВыберите раздел:"), + Column( + Button(Const("📝 Доступные тесты"), id="tests", on_click=on_tests_clicked), + Button(Const("📊 Мои результаты"), id="results", on_click=on_results_clicked), + ), + state=UserMenuSG.main, + ), +) diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/trudex/application/bot/user_dialogs/states.py new file mode 100644 index 0000000..7435483 --- /dev/null +++ b/src/trudex/application/bot/user_dialogs/states.py @@ -0,0 +1,5 @@ +from aiogram.fsm.state import State, StatesGroup + + +class UserMenuSG(StatesGroup): + main = State() diff --git a/src/trudex/infrastructure/utils/bot_commands.py b/src/trudex/infrastructure/utils/bot_commands.py new file mode 100644 index 0000000..d017f64 --- /dev/null +++ b/src/trudex/infrastructure/utils/bot_commands.py @@ -0,0 +1,32 @@ +from aiogram import Bot +from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeChat + +from trudex.infrastructure.database.repo.user import UserRepository +from trudex.infrastructure.utils.config import Config + + +async def setup_bot_commands(bot: Bot, config: Config, user_repo: UserRepository) -> None: + await bot.set_my_commands( + commands=[ + BotCommand(command="start", description="Главное меню"), + ], + scope=BotCommandScopeAllPrivateChats(), + ) + + admins = await user_repo.get_admins() + for admin in admins: + await bot.set_my_commands( + commands=[ + BotCommand(command="start", description="Главное меню"), + BotCommand(command="admin", description="Админ-панель"), + ], + scope=BotCommandScopeChat(chat_id=admin.id), + ) + + await bot.set_my_commands( + commands=[ + BotCommand(command="start", description="Главное меню"), + BotCommand(command="creator", description="Панель создателя"), + ], + scope=BotCommandScopeChat(chat_id=config.bot.creator_id), + ) From 3e51b1f95e320293ea84dcdf2644023696978d11 Mon Sep 17 00:00:00 2001 From: kolo Date: Thu, 1 Jan 2026 23:41:07 +0300 Subject: [PATCH 12/57] commit --- .../bot/admin_dialogs/main_menu.py | 63 +++++++++++++++++-- .../application/bot/admin_dialogs/states.py | 2 + .../bot/creator_dialogs/main_menu.py | 62 ++++++++++++++++-- .../application/bot/creator_dialogs/states.py | 2 + src/trudex/infrastructure/utils/broadcast.py | 35 +++++++++++ 5 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 src/trudex/infrastructure/utils/broadcast.py diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index 7120cc3..5eb37d4 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -1,13 +1,15 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Back, Button, Column, ScrollingGroup, Select, SwitchTo +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Row, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka +from dishka.integrations.aiogram import CONTAINER_NAME from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import AdminMenuSG from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.utils.broadcast import broadcast_message @inject @@ -61,7 +63,6 @@ async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: Dial async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): - from dishka.integrations.aiogram import CONTAINER_NAME container = manager.middleware_data[CONTAINER_NAME] user_dao = await container.get(UserDAO) @@ -83,6 +84,46 @@ async def on_user_input(message: Message, _widget: MessageInput, manager: Dialog await manager.switch_to(AdminMenuSG.user_detail) +async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): + manager.dialog_data["broadcast_message_id"] = message.message_id + manager.dialog_data["broadcast_chat_id"] = message.chat.id + await manager.switch_to(AdminMenuSG.broadcast_confirm) + + +@inject +async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): + message_id = manager.dialog_data.get("broadcast_message_id") + chat_id = manager.dialog_data.get("broadcast_chat_id") + + if not message_id or not chat_id or not _callback.message: + await _callback.answer("Ошибка: сообщение не найдено") + return + + await _callback.message.answer("⏳ Рассылка началась...") + + bot = _callback.bot + if not bot: + await _callback.answer("Ошибка: бот не найден") + return + + stats = await broadcast_message(bot, message_id, chat_id, user_dao) + + stats_text = ( + f"✅ Рассылка завершена\n\n" + f"Всего пользователей: {stats.total}\n" + f"Успешно отправлено: {stats.success}\n" + f"Не удалось отправить: {stats.failed}" + ) + + await _callback.message.answer(stats_text) + await manager.done() + + +async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await _callback.answer("Рассылка отменена") + await manager.switch_to(AdminMenuSG.main) + + async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: await _callback.answer("Управление тестами") @@ -91,8 +132,8 @@ async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(AdminMenuSG.users_list) -async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("Рассылка") +async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(AdminMenuSG.broadcast_input) admin_menu_dialog = Dialog( @@ -138,4 +179,18 @@ admin_menu_dialog = Dialog( state=AdminMenuSG.user_detail, getter=get_user_detail_data, ), + Window( + Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), + MessageInput(on_broadcast_input), + SwitchTo(Const("◀️ Отмена"), id="cancel_broadcast", state=AdminMenuSG.main), + state=AdminMenuSG.broadcast_input, + ), + Window( + Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), + Row( + Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), + Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), + ), + state=AdminMenuSG.broadcast_confirm, + ), ) diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 22127fe..266e10b 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -6,3 +6,5 @@ class AdminMenuSG(StatesGroup): users_list = State() users_input = State() user_detail = State() + broadcast_input = State() + broadcast_confirm = State() diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index 0972908..752904d 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -4,10 +4,12 @@ from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Row, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka +from dishka.integrations.aiogram import CONTAINER_NAME from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import CreatorMenuSG from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.utils.broadcast import broadcast_message @inject @@ -81,7 +83,6 @@ async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: Dial async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): - from dishka.integrations.aiogram import CONTAINER_NAME container = manager.middleware_data[CONTAINER_NAME] user_dao = await container.get(UserDAO) @@ -108,7 +109,6 @@ async def on_make_admin_clicked(_callback: CallbackQuery, _button: Button, manag async def on_confirm_yes(_callback: CallbackQuery, _button: Button, manager: DialogManager): - from dishka.integrations.aiogram import CONTAINER_NAME container = manager.middleware_data[CONTAINER_NAME] user_dao = await container.get(UserDAO) @@ -127,6 +127,46 @@ async def on_confirm_no(_callback: CallbackQuery, _button: Button, manager: Dial await manager.switch_to(CreatorMenuSG.user_detail) +async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): + manager.dialog_data["broadcast_message_id"] = message.message_id + manager.dialog_data["broadcast_chat_id"] = message.chat.id + await manager.switch_to(CreatorMenuSG.broadcast_confirm) + + +@inject +async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): + message_id = manager.dialog_data.get("broadcast_message_id") + chat_id = manager.dialog_data.get("broadcast_chat_id") + + if not message_id or not chat_id or not _callback.message: + await _callback.answer("Ошибка: сообщение не найдено") + return + + await _callback.message.answer("⏳ Рассылка началась...") + + bot = _callback.bot + if not bot: + await _callback.answer("Ошибка: бот не найден") + return + + stats = await broadcast_message(bot, message_id, chat_id, user_dao) + + stats_text = ( + f"✅ Рассылка завершена\n\n" + f"Всего пользователей: {stats.total}\n" + f"Успешно отправлено: {stats.success}\n" + f"Не удалось отправить: {stats.failed}" + ) + + await _callback.message.answer(stats_text) + await manager.done() + + +async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await _callback.answer("Рассылка отменена") + await manager.switch_to(CreatorMenuSG.main) + + async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: await _callback.answer("Тесты") @@ -135,8 +175,8 @@ async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(CreatorMenuSG.users_list) -async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("Рассылка") +async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(CreatorMenuSG.broadcast_input) creator_menu_dialog = Dialog( @@ -195,4 +235,18 @@ creator_menu_dialog = Dialog( state=CreatorMenuSG.make_admin_confirm, getter=get_confirm_data, ), + Window( + Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), + MessageInput(on_broadcast_input), + SwitchTo(Const("◀️ Отмена"), id="cancel_broadcast", state=CreatorMenuSG.main), + state=CreatorMenuSG.broadcast_input, + ), + Window( + Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), + Row( + Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), + Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), + ), + state=CreatorMenuSG.broadcast_confirm, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index b8c1d05..b2ee9e3 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -7,3 +7,5 @@ class CreatorMenuSG(StatesGroup): users_input = State() user_detail = State() make_admin_confirm = State() + broadcast_input = State() + broadcast_confirm = State() diff --git a/src/trudex/infrastructure/utils/broadcast.py b/src/trudex/infrastructure/utils/broadcast.py new file mode 100644 index 0000000..06612aa --- /dev/null +++ b/src/trudex/infrastructure/utils/broadcast.py @@ -0,0 +1,35 @@ +import asyncio +from dataclasses import dataclass + +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError + +from trudex.infrastructure.database.dao.user import UserDAO + + +@dataclass +class BroadcastStats: + success: int + failed: int + total: int + + +async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: UserDAO) -> BroadcastStats: + users = await user_dao.get_all() + success = 0 + failed = 0 + + for user in users: + try: + await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) + success += 1 + except TelegramForbiddenError: + failed += 1 + except TelegramBadRequest: + failed += 1 + except Exception: + failed += 1 + + await asyncio.sleep(0.1) + + return BroadcastStats(success=success, failed=failed, total=len(users)) From ac03de4db56c2f6ae5a970432072f2335d85095c Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 17:39:56 +0300 Subject: [PATCH 13/57] Initial commit --- ...test_model_add_password_and_expires_at_.py | 31 +++ src/trudex/application/__main__.py | 33 ++- .../bot/admin_dialogs/broadcast.py | 73 ++++++ .../bot/admin_dialogs/main_menu.py | 183 +------------- .../application/bot/admin_dialogs/states.py | 10 + .../application/bot/admin_dialogs/tests.py | 60 +++++ .../application/bot/admin_dialogs/users.py | 124 +++++++++ .../bot/creator_dialogs/broadcast.py | 73 ++++++ .../bot/creator_dialogs/main_menu.py | 239 +----------------- .../application/bot/creator_dialogs/states.py | 10 + .../application/bot/creator_dialogs/tests.py | 60 +++++ .../application/bot/creator_dialogs/users.py | 180 +++++++++++++ src/trudex/application/bot/handlers.py | 14 +- src/trudex/domain/schemas.py | 2 + .../infrastructure/database/dao/test.py | 10 + .../infrastructure/database/dto/test.py | 2 + src/trudex/infrastructure/database/models.py | 2 + 17 files changed, 690 insertions(+), 416 deletions(-) create mode 100644 alembic/versions/d3bd5df63c1b_test_model_add_password_and_expires_at_.py create mode 100644 src/trudex/application/bot/admin_dialogs/broadcast.py create mode 100644 src/trudex/application/bot/admin_dialogs/tests.py create mode 100644 src/trudex/application/bot/admin_dialogs/users.py create mode 100644 src/trudex/application/bot/creator_dialogs/broadcast.py create mode 100644 src/trudex/application/bot/creator_dialogs/tests.py create mode 100644 src/trudex/application/bot/creator_dialogs/users.py diff --git a/alembic/versions/d3bd5df63c1b_test_model_add_password_and_expires_at_.py b/alembic/versions/d3bd5df63c1b_test_model_add_password_and_expires_at_.py new file mode 100644 index 0000000..929972d --- /dev/null +++ b/alembic/versions/d3bd5df63c1b_test_model_add_password_and_expires_at_.py @@ -0,0 +1,31 @@ +"""test model add password and expires_at fields + +Revision ID: d3bd5df63c1b +Revises: f63140aa50c0 +Create Date: 2026-01-02 17:05:33.443875 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'd3bd5df63c1b' +down_revision: str | None = 'f63140aa50c0' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tests', sa.Column('password', sa.String(length=255), nullable=True)) + op.add_column('tests', sa.Column('expires_at', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tests', 'expires_at') + op.drop_column('tests', 'password') + # ### end Alembic commands ### diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index a728242..a2484d0 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -8,12 +8,19 @@ from aiogram_dialog import setup_dialogs from dishka import make_async_container from dishka.integrations.aiogram import setup_dishka +from trudex.application.bot.admin_dialogs.broadcast import broadcast_dialog as admin_broadcast_dialog from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog +from trudex.application.bot.admin_dialogs.tests import tests_dialog as admin_tests_dialog +from trudex.application.bot.admin_dialogs.users import users_dialog as admin_users_dialog +from trudex.application.bot.creator_dialogs.broadcast import broadcast_dialog as creator_broadcast_dialog from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog +from trudex.application.bot.creator_dialogs.tests import tests_dialog as creator_tests_dialog +from trudex.application.bot.creator_dialogs.users import users_dialog as creator_users_dialog from trudex.application.bot.handlers import router from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog +from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.di import DatabaseProvider from trudex.infrastructure.utils.bot_commands import setup_bot_commands from trudex.infrastructure.utils.config import Config @@ -33,23 +40,33 @@ async def main() -> None: ) dp = Dispatcher() - dp.message.middleware(RejectNotAdminMiddleware()) - dp.message.middleware(RejectNotCreatorMiddleware()) - dp.include_router(router) - dp.include_router(user_menu_dialog) - dp.include_router(admin_menu_dialog) - dp.include_router(creator_menu_dialog) + dp.include_routers( + router, + user_menu_dialog, + admin_menu_dialog, + admin_users_dialog, + admin_tests_dialog, + admin_broadcast_dialog, + creator_menu_dialog, + creator_users_dialog, + creator_tests_dialog, + creator_broadcast_dialog, + ) + + router.message.middleware(RejectNotAdminMiddleware()) + router.message.middleware(RejectNotCreatorMiddleware()) container = make_async_container(DatabaseProvider()) - setup_dishka(container, dp, auto_inject=True) setup_dialogs(dp) + setup_dishka(container, dp, auto_inject=True) async with container() as request_container: - from trudex.infrastructure.database.repo.user import UserRepository user_repo = await request_container.get(UserRepository) await setup_bot_commands(bot, config, user_repo) + await bot.delete_webhook(drop_pending_updates=True) + logging.info("Бот запущен") try: diff --git a/src/trudex/application/bot/admin_dialogs/broadcast.py b/src/trudex/application/bot/admin_dialogs/broadcast.py new file mode 100644 index 0000000..69f9e8b --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/broadcast.py @@ -0,0 +1,73 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.admin_dialogs.states import AdminBroadcastSG, AdminMenuSG +from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.utils.broadcast import broadcast_message + + +async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): + manager.dialog_data["broadcast_message_id"] = message.message_id + manager.dialog_data["broadcast_chat_id"] = message.chat.id + await manager.switch_to(AdminBroadcastSG.broadcast_confirm) + + +@inject +async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): + message_id = manager.dialog_data.get("broadcast_message_id") + chat_id = manager.dialog_data.get("broadcast_chat_id") + + if not message_id or not chat_id or not _callback.message: + await _callback.answer("Ошибка: сообщение не найдено") + return + + await _callback.message.answer("⏳ Рассылка началась...") + + bot = _callback.bot + if not bot: + await _callback.answer("Ошибка: бот не найден") + return + + stats = await broadcast_message(bot, message_id, chat_id, user_dao) + + stats_text = ( + f"✅ Рассылка завершена\n\n" + f"Всего пользователей: {stats.total}\n" + f"Успешно отправлено: {stats.success}\n" + f"Не удалось отправить: {stats.failed}" + ) + + await _callback.message.answer(stats_text) + await manager.done() + + +async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await _callback.answer("Рассылка отменена") + await manager.done() + + +async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + + +broadcast_dialog = Dialog( + Window( + Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), + MessageInput(on_broadcast_input), + Button(Const("◀️ Отмена"), id="back", on_click=on_back_to_main), + state=AdminBroadcastSG.broadcast_input, + ), + Window( + Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), + Row( + Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), + Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), + ), + state=AdminBroadcastSG.broadcast_confirm, + ), +) diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index 5eb37d4..dc967bd 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -1,139 +1,21 @@ -from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Row, ScrollingGroup, Select, SwitchTo -from aiogram_dialog.widgets.text import Const, Format -from dishka import FromDishka -from dishka.integrations.aiogram import CONTAINER_NAME -from dishka.integrations.aiogram_dialog import inject +from aiogram.types import CallbackQuery +from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog.widgets.kbd import Button, Column +from aiogram_dialog.widgets.text import Const -from trudex.application.bot.admin_dialogs.states import AdminMenuSG -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.utils.broadcast import broadcast_message +from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG, AdminTestsSG, AdminBroadcastSG -@inject -async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): - users = await user_dao.get_all() - - return { - "users": [ - (f"{u.first_name} (@{u.username or 'нет'})", u.id) - for u in users - ], - "count": len(users), - } - - -@inject -async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): - user_id = dialog_manager.dialog_data.get("selected_user_id") - if not user_id: - return {"user_info": "Пользователь не выбран"} - - user = await user_dao.get_by_id(user_id) - if not user: - return {"user_info": "Пользователь не найден"} - - username_str = f"@{user.username}" if user.username else "—" - last_name_str = user.last_name or "—" - group_str = str(user.group) if user.group else "—" - admin_status = "✅ Да" if user.is_admin else "❌ Нет" - - user_info = ( - f"👤 Информация о пользователе\n\n" - f"ID: {user.id}\n" - f"Имя: {user.first_name}\n" - f"Фамилия: {last_name_str}\n" - f"Username: {username_str}\n" - f"Группа: {group_str}\n" - f"Администратор: {admin_status}" - ) - - return {"user_info": user_info} - - -async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): - manager.dialog_data["selected_user_id"] = int(item_id) - await manager.switch_to(AdminMenuSG.user_detail) - - -async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminMenuSG.users_input) - - -async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - user_dao = await container.get(UserDAO) - - text = (message.text or "").strip() - - user = None - if text.startswith("@"): - username = text[1:] - all_users = await user_dao.get_all() - user = next((u for u in all_users if u.username == username), None) - elif text.isdigit(): - user = await user_dao.get_by_id(int(text)) - - if not user: - await message.answer("❌ Пользователь не найден в базе данных.") - return - - manager.dialog_data["selected_user_id"] = user.id - await manager.switch_to(AdminMenuSG.user_detail) - - -async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): - manager.dialog_data["broadcast_message_id"] = message.message_id - manager.dialog_data["broadcast_chat_id"] = message.chat.id - await manager.switch_to(AdminMenuSG.broadcast_confirm) - - -@inject -async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): - message_id = manager.dialog_data.get("broadcast_message_id") - chat_id = manager.dialog_data.get("broadcast_chat_id") - - if not message_id or not chat_id or not _callback.message: - await _callback.answer("Ошибка: сообщение не найдено") - return - - await _callback.message.answer("⏳ Рассылка началась...") - - bot = _callback.bot - if not bot: - await _callback.answer("Ошибка: бот не найден") - return - - stats = await broadcast_message(bot, message_id, chat_id, user_dao) - - stats_text = ( - f"✅ Рассылка завершена\n\n" - f"Всего пользователей: {stats.total}\n" - f"Успешно отправлено: {stats.success}\n" - f"Не удалось отправить: {stats.failed}" - ) - - await _callback.message.answer(stats_text) - await manager.done() - - -async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await _callback.answer("Рассылка отменена") - await manager.switch_to(AdminMenuSG.main) - - -async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("Управление тестами") +async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(AdminTestsSG.tests_list, mode=StartMode.RESET_STACK) async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(AdminMenuSG.users_list) + await manager.start(AdminUsersSG.users_list, mode=StartMode.RESET_STACK) async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(AdminMenuSG.broadcast_input) + await manager.start(AdminBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) admin_menu_dialog = Dialog( @@ -146,51 +28,4 @@ admin_menu_dialog = Dialog( ), state=AdminMenuSG.main, ), - Window( - Format("👥 Пользователи\n\nВсего: {count}"), - ScrollingGroup( - Select( - Format("{item[0]}"), - id="user_select", - item_id_getter=lambda x: x[1], - items="users", - on_click=on_user_selected, - ), - id="users_scroll", - width=1, - height=7, - ), - Column( - Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode), - Back(Const("◀️ Назад")), - ), - state=AdminMenuSG.users_list, - getter=get_users_data, - ), - Window( - Const("Введите ID или @username пользователя:"), - MessageInput(on_user_input), - SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminMenuSG.users_list), - state=AdminMenuSG.users_input, - ), - Window( - Format("{user_info}"), - SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminMenuSG.users_list), - state=AdminMenuSG.user_detail, - getter=get_user_detail_data, - ), - Window( - Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), - MessageInput(on_broadcast_input), - SwitchTo(Const("◀️ Отмена"), id="cancel_broadcast", state=AdminMenuSG.main), - state=AdminMenuSG.broadcast_input, - ), - Window( - Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), - Row( - Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), - Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), - ), - state=AdminMenuSG.broadcast_confirm, - ), ) diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 266e10b..7bb6da9 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -3,8 +3,18 @@ from aiogram.fsm.state import State, StatesGroup class AdminMenuSG(StatesGroup): main = State() + + +class AdminUsersSG(StatesGroup): users_list = State() users_input = State() user_detail = State() + + +class AdminTestsSG(StatesGroup): + tests_list = State() + + +class AdminBroadcastSG(StatesGroup): broadcast_input = State() broadcast_confirm = State() diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py new file mode 100644 index 0000000..eae7fe9 --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -0,0 +1,60 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.admin_dialogs.states import AdminTestsSG, AdminMenuSG +from trudex.infrastructure.database.dao.test import TestDAO + + +@inject +async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs): + tests = await test_dao.get_all() + + return { + "tests": [ + (t.title, t.id) + for t in tests + ], + "count": len(tests), + } + + +async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_test_id"] = int(item_id) + await _callback.answer("Тест выбран") + + +async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): + await _callback.answer("Добавление теста") + + +async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + + +tests_dialog = Dialog( + Window( + Format("📝 Тесты\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="test_select", + item_id_getter=lambda x: x[1], + items="tests", + on_click=on_test_selected, + ), + id="tests_scroll", + width=1, + height=7, + ), + Column( + Button(Const("➕ Добавить тест"), id="add_test", on_click=on_add_test_clicked), + Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked), + ), + state=AdminTestsSG.tests_list, + getter=get_tests_data, + ), +) diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py new file mode 100644 index 0000000..f0d3cf8 --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -0,0 +1,124 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select, SwitchTo +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram import CONTAINER_NAME +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.admin_dialogs.states import AdminUsersSG, AdminMenuSG +from trudex.infrastructure.database.dao.user import UserDAO + + +@inject +async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): + users = await user_dao.get_all() + + return { + "users": [ + (f"{u.first_name} (@{u.username or 'нет'})", u.id) + for u in users + ], + "count": len(users), + } + + +@inject +async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"user_info": "Пользователь не выбран"} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"user_info": "Пользователь не найден"} + + username_str = f"@{user.username}" if user.username else "—" + last_name_str = user.last_name or "—" + group_str = str(user.group) if user.group else "—" + admin_status = "✅ Да" if user.is_admin else "❌ Нет" + + user_info = ( + f"👤 Информация о пользователе\n\n" + f"ID: {user.id}\n" + f"Имя: {user.first_name}\n" + f"Фамилия: {last_name_str}\n" + f"Username: {username_str}\n" + f"Группа: {group_str}\n" + f"Администратор: {admin_status}" + ) + + return {"user_info": user_info} + + +async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_user_id"] = int(item_id) + await manager.switch_to(AdminUsersSG.user_detail) + + +async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminUsersSG.users_input) + + +async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): + container = manager.middleware_data[CONTAINER_NAME] + user_dao = await container.get(UserDAO) + + text = (message.text or "").strip() + + user = None + if text.startswith("@"): + username = text[1:] + all_users = await user_dao.get_all() + user = next((u for u in all_users if u.username == username), None) + elif text.isdigit(): + user = await user_dao.get_by_id(int(text)) + + if not user: + await message.answer("❌ Пользователь не найден в базе данных.") + return + + manager.dialog_data["selected_user_id"] = user.id + await manager.switch_to(AdminUsersSG.user_detail) + + +async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + + +users_dialog = Dialog( + Window( + Format("👥 Пользователи\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="user_select", + item_id_getter=lambda x: x[1], + items="users", + on_click=on_user_selected, + ), + id="users_scroll", + width=1, + height=7, + ), + Column( + Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), + ), + state=AdminUsersSG.users_list, + getter=get_users_data, + ), + Window( + Const("Введите ID или @username пользователя:"), + MessageInput(on_user_input), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminUsersSG.users_list), + state=AdminUsersSG.users_input, + ), + Window( + Format("{user_info}"), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminUsersSG.users_list), + state=AdminUsersSG.user_detail, + getter=get_user_detail_data, + ), +) diff --git a/src/trudex/application/bot/creator_dialogs/broadcast.py b/src/trudex/application/bot/creator_dialogs/broadcast.py new file mode 100644 index 0000000..5e42087 --- /dev/null +++ b/src/trudex/application/bot/creator_dialogs/broadcast.py @@ -0,0 +1,73 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Row, Cancel +from aiogram_dialog.widgets.text import Const +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.creator_dialogs.states import CreatorBroadcastSG +from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.utils.broadcast import broadcast_message + + +async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): + manager.dialog_data["broadcast_message_id"] = message.message_id + manager.dialog_data["broadcast_chat_id"] = message.chat.id + await manager.switch_to(CreatorBroadcastSG.broadcast_confirm) + + +@inject +async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): + message_id = manager.dialog_data.get("broadcast_message_id") + chat_id = manager.dialog_data.get("broadcast_chat_id") + + if not message_id or not chat_id or not _callback.message: + await _callback.answer("Ошибка: сообщение не найдено") + return + + await _callback.message.answer("⏳ Рассылка началась...") + + bot = _callback.bot + if not bot: + await _callback.answer("Ошибка: бот не найден") + return + + stats = await broadcast_message(bot, message_id, chat_id, user_dao) + + stats_text = ( + f"✅ Рассылка завершена\n\n" + f"Всего пользователей: {stats.total}\n" + f"Успешно отправлено: {stats.success}\n" + f"Не удалось отправить: {stats.failed}" + ) + + await _callback.message.answer(stats_text) + await manager.done() + + +async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await _callback.answer("Рассылка отменена") + await manager.done() + + +async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + + +broadcast_dialog = Dialog( + Window( + Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), + MessageInput(on_broadcast_input), + Button(Const("◀️ Отмена"), id="back", on_click=on_back_to_main), + state=CreatorBroadcastSG.broadcast_input, + ), + Window( + Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), + Row( + Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), + Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), + ), + state=CreatorBroadcastSG.broadcast_confirm, + ), +) diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index 752904d..6159fcc 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -1,182 +1,21 @@ -from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Row, ScrollingGroup, Select, SwitchTo -from aiogram_dialog.widgets.text import Const, Format -from dishka import FromDishka -from dishka.integrations.aiogram import CONTAINER_NAME -from dishka.integrations.aiogram_dialog import inject +from aiogram.types import CallbackQuery +from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog.widgets.kbd import Button, Column +from aiogram_dialog.widgets.text import Const -from trudex.application.bot.creator_dialogs.states import CreatorMenuSG -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.utils.broadcast import broadcast_message +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG, CreatorTestsSG, CreatorBroadcastSG -@inject -async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): - users = await user_dao.get_all() - - return { - "users": [ - (f"{u.first_name} (@{u.username or 'нет'})", u.id) - for u in users - ], - "count": len(users), - } - - -@inject -async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): - user_id = dialog_manager.dialog_data.get("selected_user_id") - if not user_id: - return {"user_info": "Пользователь не выбран", "is_admin": True, "show_make_admin": False} - - user = await user_dao.get_by_id(user_id) - if not user: - return {"user_info": "Пользователь не найден", "is_admin": True, "show_make_admin": False} - - username_str = f"@{user.username}" if user.username else "—" - last_name_str = user.last_name or "—" - group_str = str(user.group) if user.group else "—" - admin_status = "✅ Да" if user.is_admin else "❌ Нет" - - user_info = ( - f"👤 Информация о пользователе\n\n" - f"ID: {user.id}\n" - f"Имя: {user.first_name}\n" - f"Фамилия: {last_name_str}\n" - f"Username: {username_str}\n" - f"Группа: {group_str}\n" - f"Администратор: {admin_status}" - ) - - return { - "user_info": user_info, - "is_admin": user.is_admin, - "show_make_admin": not user.is_admin, - } - - -@inject -async def get_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): - user_id = dialog_manager.dialog_data.get("selected_user_id") - if not user_id: - return {"user_info": "Пользователь не выбран"} - - user = await user_dao.get_by_id(user_id) - if not user: - return {"user_info": "Пользователь не найден"} - - username_str = f"@{user.username}" if user.username else "—" - return { - "user_info": f"{user.first_name}\n{username_str}\nID: {user.id}" - } - - -async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): - manager.dialog_data["selected_user_id"] = int(item_id) - await manager.switch_to(CreatorMenuSG.user_detail) - - -async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorMenuSG.users_input) - - -async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - user_dao = await container.get(UserDAO) - - text = (message.text or "").strip() - - user = None - if text.startswith("@"): - username = text[1:] - all_users = await user_dao.get_all() - user = next((u for u in all_users if u.username == username), None) - elif text.isdigit(): - user = await user_dao.get_by_id(int(text)) - - if not user: - await message.answer("❌ Пользователь не найден в базе данных.") - return - - manager.dialog_data["selected_user_id"] = user.id - await manager.switch_to(CreatorMenuSG.user_detail) - - -async def on_make_admin_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorMenuSG.make_admin_confirm) - - -async def on_confirm_yes(_callback: CallbackQuery, _button: Button, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - user_dao = await container.get(UserDAO) - - user_id = manager.dialog_data.get("selected_user_id") - if not user_id: - await _callback.answer("Ошибка: пользователь не выбран") - return - - await user_dao.update(user_id=user_id, is_admin=True) - await _callback.answer("✅ Пользователь назначен администратором") - await manager.switch_to(CreatorMenuSG.user_detail) - - -async def on_confirm_no(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await _callback.answer("Отменено") - await manager.switch_to(CreatorMenuSG.user_detail) - - -async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): - manager.dialog_data["broadcast_message_id"] = message.message_id - manager.dialog_data["broadcast_chat_id"] = message.chat.id - await manager.switch_to(CreatorMenuSG.broadcast_confirm) - - -@inject -async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): - message_id = manager.dialog_data.get("broadcast_message_id") - chat_id = manager.dialog_data.get("broadcast_chat_id") - - if not message_id or not chat_id or not _callback.message: - await _callback.answer("Ошибка: сообщение не найдено") - return - - await _callback.message.answer("⏳ Рассылка началась...") - - bot = _callback.bot - if not bot: - await _callback.answer("Ошибка: бот не найден") - return - - stats = await broadcast_message(bot, message_id, chat_id, user_dao) - - stats_text = ( - f"✅ Рассылка завершена\n\n" - f"Всего пользователей: {stats.total}\n" - f"Успешно отправлено: {stats.success}\n" - f"Не удалось отправить: {stats.failed}" - ) - - await _callback.message.answer(stats_text) - await manager.done() - - -async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await _callback.answer("Рассылка отменена") - await manager.switch_to(CreatorMenuSG.main) - - -async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("Тесты") +async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK) async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(CreatorMenuSG.users_list) + await manager.start(CreatorUsersSG.users_list, mode=StartMode.RESET_STACK) async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(CreatorMenuSG.broadcast_input) + await manager.start(CreatorBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) creator_menu_dialog = Dialog( @@ -189,64 +28,4 @@ creator_menu_dialog = Dialog( ), state=CreatorMenuSG.main, ), - Window( - Format("👥 Пользователи\n\nВсего: {count}"), - ScrollingGroup( - Select( - Format("{item[0]}"), - id="user_select", - item_id_getter=lambda x: x[1], - items="users", - on_click=on_user_selected, - ), - id="users_scroll", - width=1, - height=7, - ), - Column( - Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode), - Cancel(Const("◀️ Назад")), - ), - state=CreatorMenuSG.users_list, - getter=get_users_data, - ), - Window( - Const("Введите ID или @username пользователя:"), - MessageInput(on_user_input), - SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorMenuSG.users_list), - state=CreatorMenuSG.users_input, - ), - Window( - Format("{user_info}"), - Column( - Button(Const("👑 Сделать администратором"), id="make_admin", on_click=on_make_admin_clicked, when="show_make_admin"), - SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorMenuSG.users_list), - ), - state=CreatorMenuSG.user_detail, - getter=get_user_detail_data, - ), - Window( - Const("⚠️ Подтверждение\n\nВы уверены, что хотите назначить этого пользователя администратором?\n"), - Format("{user_info}"), - Row( - Button(Const("✅ Да"), id="confirm_yes", on_click=on_confirm_yes), - Button(Const("❌ Нет"), id="confirm_no", on_click=on_confirm_no), - ), - state=CreatorMenuSG.make_admin_confirm, - getter=get_confirm_data, - ), - Window( - Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), - MessageInput(on_broadcast_input), - SwitchTo(Const("◀️ Отмена"), id="cancel_broadcast", state=CreatorMenuSG.main), - state=CreatorMenuSG.broadcast_input, - ), - Window( - Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), - Row( - Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), - Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), - ), - state=CreatorMenuSG.broadcast_confirm, - ), ) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index b2ee9e3..8db1252 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -3,9 +3,19 @@ from aiogram.fsm.state import State, StatesGroup class CreatorMenuSG(StatesGroup): main = State() + + +class CreatorUsersSG(StatesGroup): users_list = State() users_input = State() user_detail = State() make_admin_confirm = State() + + +class CreatorTestsSG(StatesGroup): + tests_list = State() + + +class CreatorBroadcastSG(StatesGroup): broadcast_input = State() broadcast_confirm = State() diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py new file mode 100644 index 0000000..403742f --- /dev/null +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -0,0 +1,60 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.creator_dialogs.states import CreatorTestsSG, CreatorMenuSG +from trudex.infrastructure.database.dao.test import TestDAO + + +@inject +async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs): + tests = await test_dao.get_all() + + return { + "tests": [ + (t.title, t.id) + for t in tests + ], + "count": len(tests), + } + + +async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_test_id"] = int(item_id) + await _callback.answer("Тест выбран") + + +async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): + await _callback.answer("Добавление теста") + + +async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + + +tests_dialog = Dialog( + Window( + Format("📝 Тесты\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="test_select", + item_id_getter=lambda x: x[1], + items="tests", + on_click=on_test_selected, + ), + id="tests_scroll", + width=1, + height=7, + ), + Column( + Button(Const("➕ Добавить тест"), id="add_test", on_click=on_add_test_clicked), + Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked), + ), + state=CreatorTestsSG.tests_list, + getter=get_tests_data, + ), +) diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py new file mode 100644 index 0000000..6702510 --- /dev/null +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -0,0 +1,180 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select, SwitchTo +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram import CONTAINER_NAME +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.creator_dialogs.states import CreatorUsersSG, CreatorMenuSG +from trudex.infrastructure.database.dao.user import UserDAO + + +@inject +async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): + users = await user_dao.get_all() + + return { + "users": [ + (f"{u.first_name} (@{u.username or 'нет'})", u.id) + for u in users + ], + "count": len(users), + } + + +@inject +async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"user_info": "Пользователь не выбран", "is_admin": True, "show_make_admin": False} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"user_info": "Пользователь не найден", "is_admin": True, "show_make_admin": False} + + username_str = f"@{user.username}" if user.username else "—" + last_name_str = user.last_name or "—" + group_str = str(user.group) if user.group else "—" + admin_status = "✅ Да" if user.is_admin else "❌ Нет" + + user_info = ( + f"👤 Информация о пользователе\n\n" + f"ID: {user.id}\n" + f"Имя: {user.first_name}\n" + f"Фамилия: {last_name_str}\n" + f"Username: {username_str}\n" + f"Группа: {group_str}\n" + f"Администратор: {admin_status}" + ) + + return { + "user_info": user_info, + "is_admin": user.is_admin, + "show_make_admin": not user.is_admin, + } + + +@inject +async def get_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"user_info": "Пользователь не выбран"} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"user_info": "Пользователь не найден"} + + username_str = f"@{user.username}" if user.username else "—" + return { + "user_info": f"{user.first_name}\n{username_str}\nID: {user.id}" + } + + +async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_user_id"] = int(item_id) + await manager.switch_to(CreatorUsersSG.user_detail) + + +async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorUsersSG.users_input) + + +async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): + container = manager.middleware_data[CONTAINER_NAME] + user_dao = await container.get(UserDAO) + + text = (message.text or "").strip() + + user = None + if text.startswith("@"): + username = text[1:] + all_users = await user_dao.get_all() + user = next((u for u in all_users if u.username == username), None) + elif text.isdigit(): + user = await user_dao.get_by_id(int(text)) + + if not user: + await message.answer("❌ Пользователь не найден в базе данных.") + return + + manager.dialog_data["selected_user_id"] = user.id + await manager.switch_to(CreatorUsersSG.user_detail) + + +async def on_make_admin_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorUsersSG.make_admin_confirm) + + +async def on_confirm_yes(_callback: CallbackQuery, _button: Button, manager: DialogManager): + container = manager.middleware_data[CONTAINER_NAME] + user_dao = await container.get(UserDAO) + + user_id = manager.dialog_data.get("selected_user_id") + if not user_id: + await _callback.answer("Ошибка: пользователь не выбран") + return + + await user_dao.update(user_id=user_id, is_admin=True) + await _callback.answer("✅ Пользователь назначен администратором") + await manager.switch_to(CreatorUsersSG.user_detail) + + +async def on_confirm_no(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await _callback.answer("Отменено") + await manager.switch_to(CreatorUsersSG.user_detail) + + +async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + + +users_dialog = Dialog( + Window( + Format("👥 Пользователи\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="user_select", + item_id_getter=lambda x: x[1], + items="users", + on_click=on_user_selected, + ), + id="users_scroll", + width=1, + height=7, + ), + Column( + Button(Const("✏️ Ввести ID/Username"), id="input_mode", on_click=on_input_mode), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), + ), + state=CreatorUsersSG.users_list, + getter=get_users_data, + ), + Window( + Const("Введите ID или @username пользователя:"), + MessageInput(on_user_input), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorUsersSG.users_list), + state=CreatorUsersSG.users_input, + ), + Window( + Format("{user_info}"), + Column( + Button(Const("👑 Сделать администратором"), id="make_admin", on_click=on_make_admin_clicked, when="show_make_admin"), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorUsersSG.users_list), + ), + state=CreatorUsersSG.user_detail, + getter=get_user_detail_data, + ), + Window( + Const("⚠️ Подтверждение\n\nВы уверены, что хотите назначить этого пользователя администратором?\n\n"), + Format("{user_info}"), + Row( + Button(Const("✅ Да"), id="confirm_yes", on_click=on_confirm_yes), + Button(Const("❌ Нет"), id="confirm_no", on_click=on_confirm_no), + ), + state=CreatorUsersSG.make_admin_confirm, + getter=get_confirm_data, + ), +) diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 6df0610..63ba8d4 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -29,13 +29,19 @@ async def start_handler(message: Message, user_dao: FromDishka[UserDAO], dialog_ @router.message(Command("admin")) -async def admin_command(_message: Message, dialog_manager: DialogManager) -> None: - await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) +async def admin_command(message: Message, dialog_manager: DialogManager) -> None: + try: + await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + except Exception as e: + await message.answer(f"Ошибка запуска диалога: {e}") @router.message(Command("creator")) -async def creator_command(_message: Message, dialog_manager: DialogManager) -> None: - await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) +async def creator_command(message: Message, dialog_manager: DialogManager) -> None: + try: + await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + except Exception as e: + await message.answer(f"Ошибка запуска диалога: {e}") @router.error() diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index ad55ab9..2ea0e68 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -20,6 +20,8 @@ class Test: title: str description: str | None = None for_group: int | None = None + password: str | None = None + expires_at: datetime | None = None is_active: bool = True created_at: datetime | None = None updated_at: datetime | None = None diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/trudex/infrastructure/database/dao/test.py index 6996c7f..6a67452 100644 --- a/src/trudex/infrastructure/database/dao/test.py +++ b/src/trudex/infrastructure/database/dao/test.py @@ -27,12 +27,16 @@ class TestDAO: title: str, description: str | None = None, for_group: int | None = None, + password: str | None = None, + expires_at: str | None = None, is_active: bool = True, ) -> DomainTest: test = Test( title=title, description=description, for_group=for_group, + password=password, + expires_at=expires_at, is_active=is_active, ) self.session.add(test) @@ -46,6 +50,8 @@ class TestDAO: title: str | None = None, description: str | None = None, for_group: int | None = None, + password: str | None = None, + expires_at: str | None = None, is_active: bool | None = None, ) -> DomainTest | None: result = await self.session.execute( @@ -61,6 +67,10 @@ class TestDAO: test.description = description if for_group is not None: test.for_group = for_group + if password is not None: + test.password = password + if expires_at is not None: + test.expires_at = expires_at if is_active is not None: test.is_active = is_active diff --git a/src/trudex/infrastructure/database/dto/test.py b/src/trudex/infrastructure/database/dto/test.py index 55971fc..f3fa61e 100644 --- a/src/trudex/infrastructure/database/dto/test.py +++ b/src/trudex/infrastructure/database/dto/test.py @@ -12,6 +12,8 @@ class TestDTO: title=self.model.title, description=self.model.description, for_group=self.model.for_group, + password=self.model.password, + expires_at=self.model.expires_at, is_active=self.model.is_active, created_at=self.model.created_at, updated_at=self.model.updated_at, diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 561c657..fe7d7b9 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -38,6 +38,8 @@ class Test(Base): title: Mapped[str] = mapped_column(String(255)) description: Mapped[str | None] = mapped_column(Text) for_group: Mapped[int | None] = mapped_column(default=None) + password: Mapped[str | None] = mapped_column(String(255), default=None) + expires_at: Mapped[datetime | None] = mapped_column(default=None) is_active: Mapped[bool] = mapped_column(default=True) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) From b2b49fbe516cc89883336c4a1f79e30fee0f6921 Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 19:19:16 +0300 Subject: [PATCH 14/57] Initial commit --- src/trudex/application/__main__.py | 2 + .../bot/creator_dialogs/create_test.py | 505 ++++++++++++++++++ .../application/bot/creator_dialogs/states.py | 17 + .../application/bot/creator_dialogs/tests.py | 6 +- 4 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 src/trudex/application/bot/creator_dialogs/create_test.py diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index a2484d0..4714fc2 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -13,6 +13,7 @@ from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog from trudex.application.bot.admin_dialogs.tests import tests_dialog as admin_tests_dialog from trudex.application.bot.admin_dialogs.users import users_dialog as admin_users_dialog from trudex.application.bot.creator_dialogs.broadcast import broadcast_dialog as creator_broadcast_dialog +from trudex.application.bot.creator_dialogs.create_test import create_test_dialog from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog from trudex.application.bot.creator_dialogs.tests import tests_dialog as creator_tests_dialog from trudex.application.bot.creator_dialogs.users import users_dialog as creator_users_dialog @@ -52,6 +53,7 @@ async def main() -> None: creator_users_dialog, creator_tests_dialog, creator_broadcast_dialog, + create_test_dialog, ) router.message.middleware(RejectNotAdminMiddleware()) diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py new file mode 100644 index 0000000..c1e96e8 --- /dev/null +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -0,0 +1,505 @@ +from datetime import date, datetime + +from aiogram.types import CallbackQuery, ContentType, Message +from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka.integrations.aiogram import CONTAINER_NAME +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorTestsSG +from trudex.infrastructure.database.dao.option import OptionDAO +from trudex.infrastructure.database.dao.question import QuestionDAO +from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.repo.test import TestRepository + + +async def on_title_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Название не может быть пустым") + return + + title = message.text.strip() + if not title: + await message.answer("❌ Название не может быть пустым") + return + + if len(title) > 255: + await message.answer("❌ Название слишком длинное (максимум 255 символов)") + return + + manager.dialog_data["title"] = title + await manager.switch_to(CreateTestSG.input_description) + + +async def on_description_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Описание не может быть пустым") + return + + description = message.text.strip() + if not description: + await message.answer("❌ Описание не может быть пустым") + return + + if len(description) > 2000: + await message.answer("❌ Описание слишком длинное (максимум 2000 символов)") + return + + manager.dialog_data["description"] = description + await manager.switch_to(CreateTestSG.input_password) + + +async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Пароль не может быть пустым") + return + + password = message.text.strip() + if not password: + await message.answer("❌ Пароль не может быть пустым") + return + + if len(password) > 255: + await message.answer("❌ Пароль слишком длинный (максимум 255 символов)") + return + + manager.dialog_data["password"] = password + await manager.switch_to(CreateTestSG.input_expires_at) + + +async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["password"] = None + await manager.switch_to(CreateTestSG.input_expires_at) + + +async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): + manager.dialog_data["expires_at"] = datetime.combine(selected_date, datetime.min.time()) + await manager.switch_to(CreateTestSG.input_for_group) + + +async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["expires_at"] = None + await manager.switch_to(CreateTestSG.input_for_group) + + +async def on_group_input(message: Message, _widget: MessageInput, manager: DialogManager): + text = (message.text or "").strip() + if text.isdigit() and len(text) == 4: + manager.dialog_data["for_group"] = int(text) + await manager.switch_to(CreateTestSG.confirm_test_info) + else: + await message.answer("❌ Группа должна быть 4-значным числом") + + +async def on_skip_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["for_group"] = None + await manager.switch_to(CreateTestSG.confirm_test_info) + + +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") + 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 "Без пароля" + expires_str = expires_at.strftime("%d.%m.%Y") if expires_at else "Без срока" + group_str = str(for_group) if for_group else "Для всех" + + return { + "info": ( + f"📝 Информация о тесте\n\n" + f"Название: {title}\n" + f"Описание: {description}\n" + f"Пароль: {password_str}\n" + f"Истекает: {expires_str}\n" + f"Для группы: {group_str}" + ) + } + + +async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): + container = manager.middleware_data[CONTAINER_NAME] + test_dao = await container.get(TestDAO) + + title = manager.dialog_data.get("title") + description = manager.dialog_data.get("description") + password = manager.dialog_data.get("password") + expires_at = manager.dialog_data.get("expires_at") + for_group = manager.dialog_data.get("for_group") + + test = await test_dao.create( + title=title, + description=description, + password=password, + expires_at=expires_at, + for_group=for_group, + ) + + manager.dialog_data["test_id"] = test.id + manager.dialog_data["questions"] = [] + await manager.switch_to(CreateTestSG.add_question) + + +async def on_add_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["current_question"] = {} + await manager.switch_to(CreateTestSG.input_question_text) + + +async def on_question_input(message: Message, _widget: MessageInput, manager: DialogManager): + current_question = manager.dialog_data.get("current_question", {}) + + if message.content_type == ContentType.PHOTO: + photo = message.photo[-1] if message.photo else None + if photo: + current_question["tg_file_id"] = photo.file_id + text = (message.caption or "").strip() + if len(text) > 2000: + await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)") + return + current_question["text"] = text + elif message.content_type == ContentType.TEXT and message.text: + text = message.text.strip() + if not text: + await message.answer("❌ Текст вопроса не может быть пустым") + return + if len(text) > 2000: + await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)") + return + current_question["text"] = text + current_question["tg_file_id"] = None + else: + await message.answer("❌ Отправьте текст или фото с подписью") + return + + manager.dialog_data["current_question"] = current_question + await manager.switch_to(CreateTestSG.select_question_type) + + +async def get_question_type_data(**_kwargs): + return { + "question_types": [ + ("single", "📌 Один правильный ответ"), + ("multiple", "� Ннесколько правильных ответов"), + ("input", "✏️ Ввод текста"), + ] + } + + +async def on_question_type_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + current_question = manager.dialog_data.get("current_question", {}) + current_question["question_type"] = item_id + manager.dialog_data["current_question"] = current_question + + if item_id == "input": + await manager.switch_to(CreateTestSG.input_correct_answer) + else: + manager.dialog_data["current_options"] = [] + await manager.switch_to(CreateTestSG.input_options) + + +async def on_correct_answer_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Правильный ответ не может быть пустым") + return + + answer = message.text.strip() + if not answer: + await message.answer("❌ Правильный ответ не может быть пустым") + return + + if len(answer) > 255: + await message.answer("❌ Ответ слишком длинный (максимум 255 символов)") + return + + current_question = manager.dialog_data.get("current_question", {}) + current_question["correct_answer"] = answer + manager.dialog_data["current_question"] = current_question + await manager.switch_to(CreateTestSG.confirm_question) + + +async def on_option_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Вариант ответа не может быть пустым") + return + + option_text = message.text.strip() + if not option_text: + await message.answer("❌ Вариант ответа не может быть пустым") + return + + if len(option_text) > 255: + await message.answer("❌ Вариант ответа слишком длинный (максимум 255 символов)") + return + + current_options = manager.dialog_data.get("current_options", []) + + if len(current_options) >= 10: + await message.answer("❌ Максимум 10 вариантов ответа") + return + + current_options.append({"text": option_text, "is_correct": False}) + manager.dialog_data["current_options"] = current_options + + await message.answer(f"✅ Вариант {len(current_options)} добавлен") + + +async def on_finish_options(_callback: CallbackQuery, _button: Button, manager: DialogManager): + current_options = manager.dialog_data.get("current_options", []) + if len(current_options) < 2: + await _callback.answer("❌ Добавьте минимум 2 варианта ответа", show_alert=True) + return + + await manager.switch_to(CreateTestSG.mark_correct_options) + + +async def get_options_data(dialog_manager: DialogManager, **_kwargs): + current_options = dialog_manager.dialog_data.get("current_options", []) + formatted_options = [] + for i, opt in enumerate(current_options): + marker = "✅" if opt["is_correct"] else "❌" + formatted_options.append((str(i), f"{marker} {opt['text']}")) + return { + "options": formatted_options, + "options_count": len(current_options), + } + + +async def on_option_toggle(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + current_options = manager.dialog_data.get("current_options", []) + current_question = manager.dialog_data.get("current_question", {}) + question_type = current_question.get("question_type", "single") + + option_idx = int(item_id) + + if question_type == "single": + for opt in current_options: + opt["is_correct"] = False + current_options[option_idx]["is_correct"] = True + else: + current_options[option_idx]["is_correct"] = not current_options[option_idx]["is_correct"] + + manager.dialog_data["current_options"] = current_options + await _callback.answer() + + +async def on_confirm_correct(_callback: CallbackQuery, _button: Button, manager: DialogManager): + current_options = manager.dialog_data.get("current_options", []) + + if not any(opt["is_correct"] for opt in current_options): + await _callback.answer("❌ Отметьте хотя бы один правильный ответ", show_alert=True) + return + + await manager.switch_to(CreateTestSG.confirm_question) + + +async def get_question_preview(dialog_manager: DialogManager, **_kwargs): + current_question = dialog_manager.dialog_data.get("current_question", {}) + current_options = dialog_manager.dialog_data.get("current_options", []) + + text = current_question.get("text", "") + question_type = current_question.get("question_type", "single") + has_image = current_question.get("tg_file_id") is not None + + type_names = { + "single": "📌 Один правильный ответ", + "multiple": "📋 Несколько правильных ответов", + "input": "✏️ Ввод текста", + } + + preview = f"📝 Предпросмотр вопроса\n\n" + preview += f"Текст: {text}\n" + preview += f"Тип: {type_names[question_type]}\n" + preview += f"Изображение: {'✅ Да' if has_image else '❌ Нет'}\n\n" + + if question_type == "input": + correct_answer = current_question.get("correct_answer", "") + preview += f"Правильный ответ: {correct_answer}" + else: + preview += "Варианты ответов:\n" + for i, opt in enumerate(current_options, 1): + marker = "✅" if opt["is_correct"] else "❌" + preview += f"{i}. {marker} {opt['text']}\n" + + return {"preview": preview} + + +async def on_save_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): + container = manager.middleware_data[CONTAINER_NAME] + question_dao = await container.get(QuestionDAO) + option_dao = await container.get(OptionDAO) + test_repo = await container.get(TestRepository) + + test_id = manager.dialog_data.get("test_id") + current_question = manager.dialog_data.get("current_question", {}) + current_options = manager.dialog_data.get("current_options", []) + + questions_count = await test_repo.count_questions_in_test(test_id) + + question = await question_dao.create( + test_id=test_id, + text=current_question.get("text", ""), + position=questions_count, + question_type=current_question.get("question_type", "single"), + tg_file_id=current_question.get("tg_file_id"), + ) + + if current_question.get("question_type") == "input": + await option_dao.create( + question_id=question.id, + text=current_question.get("correct_answer", ""), + is_correct=True, + ) + else: + for opt in current_options: + await option_dao.create( + question_id=question.id, + text=opt["text"], + is_correct=opt["is_correct"], + ) + + questions = manager.dialog_data.get("questions", []) + questions.append(question.id) + manager.dialog_data["questions"] = questions + + manager.dialog_data.pop("current_question", None) + manager.dialog_data.pop("current_options", None) + + await _callback.answer("✅ Вопрос добавлен") + await manager.switch_to(CreateTestSG.add_question) + + +async def on_cancel_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data.pop("current_question", None) + manager.dialog_data.pop("current_options", None) + await manager.switch_to(CreateTestSG.add_question) + + +async def get_questions_count(dialog_manager: DialogManager, **_kwargs): + questions = dialog_manager.dialog_data.get("questions", []) + return {"questions_count": len(questions)} + + +async def on_finish_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): + questions = manager.dialog_data.get("questions", []) + + if len(questions) == 0: + await _callback.answer("❌ Добавьте хотя бы один вопрос", show_alert=True) + return + + await _callback.answer("✅ Тест создан") + await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK) + + +async def on_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK) + + +create_test_dialog = Dialog( + Window( + Const("📝 Создание теста\n\n💬 Введите название теста:\n(максимум 255 символов)"), + MessageInput(on_title_input), + Cancel(Const("◀️ Отмена")), + state=CreateTestSG.input_title, + ), + Window( + Const("📝 Создание теста\n\n📄 Введите описание теста:\n(максимум 2000 символов)"), + MessageInput(on_description_input), + state=CreateTestSG.input_description, + ), + Window( + Const("🔒 Пароль\n\n🔑 Введите пароль для доступа к тесту или пропустите этот шаг:\n(максимум 255 символов)"), + MessageInput(on_password_input), + Button(Const("⏭️ Без пароля"), id="skip_password", on_click=on_skip_password), + state=CreateTestSG.input_password, + ), + Window( + Const("📅 Срок действия\n\n🗓 Выберите дату истечения теста или пропустите:"), + Calendar(id="calendar", on_click=on_date_selected), + Button(Const("⏭️ Без срока"), id="skip_expires", on_click=on_skip_expires), + state=CreateTestSG.input_expires_at, + ), + Window( + Const("👥 Группа\n\n🎓 Введите номер группы (4 цифры) или пропустите для всех:"), + MessageInput(on_group_input), + Button(Const("⏭️ Для всех"), id="skip_group", on_click=on_skip_group), + state=CreateTestSG.input_for_group, + ), + Window( + Format("{info}\n\n✅ Подтвердите создание теста:"), + Row( + Button(Const("✅ Создать"), id="confirm", on_click=on_confirm_test), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel), + ), + state=CreateTestSG.confirm_test_info, + getter=get_test_info, + ), + Window( + Format("➕ Добавление вопросов\n\n📊 Вопросов добавлено: {questions_count}\n\n💡 Добавьте вопросы к тесту:"), + Column( + Button(Const("➕ Добавить вопрос"), id="add_question", on_click=on_add_question), + Button(Const("✅ Завершить создание"), id="finish", on_click=on_finish_test), + ), + state=CreateTestSG.add_question, + getter=get_questions_count, + ), + Window( + Const("❓ Текст вопроса\n\n📝 Отправьте текст вопроса или 📷 фото с подписью:\n(максимум 2000 символов)"), + MessageInput(on_question_input, content_types=[ContentType.TEXT, ContentType.PHOTO]), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=CreateTestSG.input_question_text, + ), + Window( + Const("📋 Тип вопроса\n\n🎯 Выберите тип вопроса:"), + Select( + Format("{item[1]}"), + id="question_type", + item_id_getter=lambda x: x[0], + items="question_types", + on_click=on_question_type_selected, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=CreateTestSG.select_question_type, + getter=get_question_type_data, + ), + Window( + Const("✏️ Правильный ответ\n\n💬 Введите правильный ответ (для проверки будет использоваться точное совпадение):\n(максимум 255 символов)"), + MessageInput(on_correct_answer_input), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=CreateTestSG.input_correct_answer, + ), + Window( + Format("📝 Варианты ответов\n\n📊 Добавлено вариантов: {options_count}/10\n\n💬 Введите вариант ответа:\n(максимум 255 символов)"), + MessageInput(on_option_input), + Button(Const("✅ Завершить добавление вариантов"), id="finish_options", on_click=on_finish_options), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=CreateTestSG.input_options, + getter=get_options_data, + ), + Window( + Const("✅ Правильные ответы\n\nОтметьте правильные варианты ответов:"), + Select( + Format("{item[1]}"), + id="options", + item_id_getter=lambda x: x[0], + items="options", + on_click=on_option_toggle, + ), + Button(Const("✅ Подтвердить выбор"), id="confirm", on_click=on_confirm_correct), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=CreateTestSG.mark_correct_options, + getter=get_options_data, + ), + Window( + Format("{preview}\n\n💾 Сохранить вопрос?"), + Row( + Button(Const("✅ Сохранить"), id="save", on_click=on_save_question), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_question), + ), + state=CreateTestSG.confirm_question, + getter=get_question_preview, + ), +) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 8db1252..20a2c2e 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -19,3 +19,20 @@ class CreatorTestsSG(StatesGroup): class CreatorBroadcastSG(StatesGroup): broadcast_input = State() broadcast_confirm = State() + + +class CreateTestSG(StatesGroup): + input_title = State() + input_description = State() + input_password = State() + input_expires_at = State() + input_for_group = State() + confirm_test_info = State() + add_question = State() + input_question_text = State() + select_question_type = State() + input_correct_answer = State() + input_options = State() + mark_correct_options = State() + confirm_question = State() + test_created = State() diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 403742f..67d29e9 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -5,7 +5,7 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreatorTestsSG, CreatorMenuSG +from trudex.application.bot.creator_dialogs.states import CreatorTestsSG, CreatorMenuSG, CreateTestSG from trudex.infrastructure.database.dao.test import TestDAO @@ -27,8 +27,8 @@ async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: D await _callback.answer("Тест выбран") -async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): - await _callback.answer("Добавление теста") +async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(CreateTestSG.input_title, mode=StartMode.RESET_STACK) async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): From 3a70802256e5c37d5dc05f3444b93b31ee37a5a7 Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 20:18:42 +0300 Subject: [PATCH 15/57] Initial commit --- alembic/versions/520eccd2e55f_add_group.py | 38 ++++ src/trudex/application/__main__.py | 4 + .../application/bot/admin_dialogs/groups.py | 193 +++++++++++++++++ .../bot/admin_dialogs/main_menu.py | 7 +- .../application/bot/admin_dialogs/states.py | 7 + .../bot/creator_dialogs/create_test.py | 70 +++++-- .../application/bot/creator_dialogs/groups.py | 194 ++++++++++++++++++ .../bot/creator_dialogs/main_menu.py | 7 +- .../application/bot/creator_dialogs/states.py | 7 + src/trudex/domain/schemas.py | 8 + .../infrastructure/database/dao/group.py | 73 +++++++ .../infrastructure/database/dto/group.py | 15 ++ src/trudex/infrastructure/database/models.py | 17 ++ src/trudex/infrastructure/di.py | 5 + 14 files changed, 626 insertions(+), 19 deletions(-) create mode 100644 alembic/versions/520eccd2e55f_add_group.py create mode 100644 src/trudex/application/bot/admin_dialogs/groups.py create mode 100644 src/trudex/application/bot/creator_dialogs/groups.py create mode 100644 src/trudex/infrastructure/database/dao/group.py create mode 100644 src/trudex/infrastructure/database/dto/group.py diff --git a/alembic/versions/520eccd2e55f_add_group.py b/alembic/versions/520eccd2e55f_add_group.py new file mode 100644 index 0000000..bd5d52b --- /dev/null +++ b/alembic/versions/520eccd2e55f_add_group.py @@ -0,0 +1,38 @@ +"""add group + +Revision ID: 520eccd2e55f +Revises: d3bd5df63c1b +Create Date: 2026-01-02 19:42:09.264423 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = '520eccd2e55f' +down_revision: str | None = 'd3bd5df63c1b' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('groups', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('number', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('number >= 1000 AND number <= 9999', name='check_group_number'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_groups_number'), 'groups', ['number'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_groups_number'), table_name='groups') + op.drop_table('groups') + # ### end Alembic commands ### diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 4714fc2..72bc31b 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -9,11 +9,13 @@ from dishka import make_async_container from dishka.integrations.aiogram import setup_dishka from trudex.application.bot.admin_dialogs.broadcast import broadcast_dialog as admin_broadcast_dialog +from trudex.application.bot.admin_dialogs.groups import groups_dialog as admin_groups_dialog from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog from trudex.application.bot.admin_dialogs.tests import tests_dialog as admin_tests_dialog from trudex.application.bot.admin_dialogs.users import users_dialog as admin_users_dialog from trudex.application.bot.creator_dialogs.broadcast import broadcast_dialog as creator_broadcast_dialog from trudex.application.bot.creator_dialogs.create_test import create_test_dialog +from trudex.application.bot.creator_dialogs.groups import groups_dialog as creator_groups_dialog from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog from trudex.application.bot.creator_dialogs.tests import tests_dialog as creator_tests_dialog from trudex.application.bot.creator_dialogs.users import users_dialog as creator_users_dialog @@ -48,10 +50,12 @@ async def main() -> None: admin_menu_dialog, admin_users_dialog, admin_tests_dialog, + admin_groups_dialog, admin_broadcast_dialog, creator_menu_dialog, creator_users_dialog, creator_tests_dialog, + creator_groups_dialog, creator_broadcast_dialog, create_test_dialog, ) diff --git a/src/trudex/application/bot/admin_dialogs/groups.py b/src/trudex/application/bot/admin_dialogs/groups.py new file mode 100644 index 0000000..821bc5b --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/groups.py @@ -0,0 +1,193 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka.integrations.aiogram import CONTAINER_NAME + +from trudex.application.bot.admin_dialogs.states import AdminGroupsSG, AdminMenuSG +from trudex.infrastructure.database.dao.group import GroupDAO + + +async def on_group_click(_callback: CallbackQuery, _widget, _manager: DialogManager, _item_id: str): + await _callback.answer("ℹ️ Для удаления используйте кнопку 'Удалить группу'") + + +async def get_groups_data(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + groups = await group_dao.get_all() + + success_message = dialog_manager.dialog_data.pop("success_message", None) + + message_text = "👥 Управление группами\n\n" + if success_message: + message_text += f"{success_message}\n\n" + message_text += f"📊 Всего групп: {len(groups)}\n\nСписок групп:" + + return { + "groups": [(str(g.id), str(g.number)) for g in groups], + "groups_count": len(groups), + "message_text": message_text, + } + + +async def on_add_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminGroupsSG.add_group_input_number) + + +async def on_delete_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminGroupsSG.delete_groups_list) + + +async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + + +async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Номер группы не может быть пустым") + return + + number_str = message.text.strip() + + if not number_str.isdigit(): + await message.answer("❌ Номер группы должен содержать только цифры") + return + + number = int(number_str) + + if number < 1000 or number > 9999: + await message.answer("❌ Номер группы должен быть четырехзначным (1000-9999)") + return + + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + existing = await group_dao.get_by_number(number) + if existing: + await message.answer(f"❌ Группа с номером {number} уже существует") + return + + try: + await group_dao.create(number=number) + manager.dialog_data["success_message"] = f"✅ Группа {number} создана" + except Exception as e: + await message.answer(f"❌ Ошибка создания группы: {e}") + return + + await manager.switch_to(AdminGroupsSG.groups_list) + + +async def on_cancel_add(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminGroupsSG.groups_list) + + +async def get_delete_groups_data(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + groups = await group_dao.get_all() + + return { + "groups": [(str(g.id), f"{g.number}") for g in groups], + "groups_count": len(groups), + } + + +async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + group = await group_dao.get_by_id(int(item_id)) + if not group: + await _callback.answer("❌ Группа не найдена", show_alert=True) + return + + manager.dialog_data["delete_group_id"] = group.id + manager.dialog_data["delete_group_number"] = group.number + await manager.switch_to(AdminGroupsSG.delete_confirm) + + +async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs): + number = dialog_manager.dialog_data.get("delete_group_number", "") + + return { + "group_info": str(number) + } + + +async def on_confirm_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + group_id = manager.dialog_data.get("delete_group_id") + + await group_dao.delete(group_id) + + manager.dialog_data["success_message"] = "✅ Группа удалена" + await manager.switch_to(AdminGroupsSG.groups_list) + + +async def on_cancel_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminGroupsSG.delete_groups_list) + + +groups_dialog = Dialog( + Window( + Format("{message_text}"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_click, + ), + id="groups_scroll", + width=2, + height=7, + ), + Column( + Button(Const("➕ Добавить группу"), id="add", on_click=on_add_group), + Button(Const("🗑 Удалить группу"), id="delete", on_click=on_delete_group), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu), + ), + state=AdminGroupsSG.groups_list, + getter=get_groups_data, + ), + Window( + Const("➕ Добавление группы\n\n🔢 Введите номер группы (четырехзначное число 1000-9999):"), + MessageInput(on_group_number_input), + Button(Const("◀️ Отмена"), id="cancel", on_click=on_cancel_add), + state=AdminGroupsSG.add_group_input_number, + ), + Window( + Format("🗑 Удаление группы\n\nВыберите группу для удаления:\n\n📊 Всего групп: {groups_count}"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="delete_groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_select_group_to_delete, + ), + id="delete_groups_scroll", + width=2, + height=7, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_add), + state=AdminGroupsSG.delete_groups_list, + getter=get_delete_groups_data, + ), + Window( + Format("⚠️ Подтверждение удаления\n\nТочно хотите удалить группу?\n\n👥 {group_info}"), + Row( + Button(Const("✅ Да, удалить"), id="confirm", on_click=on_confirm_delete), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_delete), + ), + state=AdminGroupsSG.delete_confirm, + getter=get_delete_confirm_data, + ), +) diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index dc967bd..007c039 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -3,7 +3,7 @@ from aiogram_dialog import Dialog, DialogManager, Window, StartMode from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG, AdminTestsSG, AdminBroadcastSG +from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG, AdminTestsSG, AdminBroadcastSG, AdminGroupsSG async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: @@ -14,6 +14,10 @@ async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: D await manager.start(AdminUsersSG.users_list, mode=StartMode.RESET_STACK) +async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(AdminGroupsSG.groups_list, mode=StartMode.RESET_STACK) + + async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: await manager.start(AdminBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) @@ -24,6 +28,7 @@ admin_menu_dialog = Dialog( Column( Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked), Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked), + Button(Const("🎓 Группы"), id="groups", on_click=on_groups_clicked), Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked), ), state=AdminMenuSG.main, diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 7bb6da9..312340b 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -18,3 +18,10 @@ class AdminTestsSG(StatesGroup): class AdminBroadcastSG(StatesGroup): broadcast_input = State() broadcast_confirm = State() + + +class AdminGroupsSG(StatesGroup): + groups_list = State() + add_group_input_number = State() + delete_groups_list = State() + delete_confirm = State() diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index c1e96e8..50c9cc0 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -3,12 +3,13 @@ from datetime import date, datetime from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, Window, StartMode from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, Select +from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka.integrations.aiogram import CONTAINER_NAME from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorTestsSG +from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO @@ -66,12 +67,29 @@ async def on_password_input(message: Message, _widget: MessageInput, manager: Di return manager.dialog_data["password"] = password - await manager.switch_to(CreateTestSG.input_expires_at) + + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + 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) async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): manager.dialog_data["password"] = None - await manager.switch_to(CreateTestSG.input_expires_at) + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + 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) async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): @@ -84,13 +102,19 @@ async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(CreateTestSG.input_for_group) -async def on_group_input(message: Message, _widget: MessageInput, manager: DialogManager): - text = (message.text or "").strip() - if text.isdigit() and len(text) == 4: - manager.dialog_data["for_group"] = int(text) - await manager.switch_to(CreateTestSG.confirm_test_info) - else: - await message.answer("❌ Группа должна быть 4-значным числом") +async def get_groups_for_test(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + groups = await group_dao.get_all() + + return { + "groups": [(str(g.number), str(g.number)) for g in groups], + } + + +async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + manager.dialog_data["for_group"] = int(item_id) + await manager.switch_to(CreateTestSG.confirm_test_info) async def on_skip_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -183,7 +207,7 @@ async def get_question_type_data(**_kwargs): return { "question_types": [ ("single", "📌 Один правильный ответ"), - ("multiple", "� Ннесколько правильных ответов"), + ("multiple", "� Несколько правильных ответов"), ("input", "✏️ Ввод текста"), ] } @@ -423,10 +447,22 @@ create_test_dialog = Dialog( state=CreateTestSG.input_expires_at, ), Window( - Const("👥 Группа\n\n🎓 Введите номер группы (4 цифры) или пропустите для всех:"), - MessageInput(on_group_input), + Const("👥 Группа\n\n🎓 Выберите группу или пропустите для всех:"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_selected, + ), + id="groups_scroll", + width=2, + height=7, + ), Button(Const("⏭️ Для всех"), id="skip_group", on_click=on_skip_group), state=CreateTestSG.input_for_group, + getter=get_groups_for_test, ), Window( Format("{info}\n\n✅ Подтвердите создание теста:"), @@ -454,13 +490,13 @@ create_test_dialog = Dialog( ), Window( Const("📋 Тип вопроса\n\n🎯 Выберите тип вопроса:"), - Select( + Column(Select( Format("{item[1]}"), id="question_type", item_id_getter=lambda x: x[0], items="question_types", on_click=on_question_type_selected, - ), + )), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), state=CreateTestSG.select_question_type, getter=get_question_type_data, @@ -481,13 +517,13 @@ create_test_dialog = Dialog( ), Window( Const("✅ Правильные ответы\n\nОтметьте правильные варианты ответов:"), - Select( + Column(Select( Format("{item[1]}"), id="options", item_id_getter=lambda x: x[0], items="options", on_click=on_option_toggle, - ), + )), Button(Const("✅ Подтвердить выбор"), id="confirm", on_click=on_confirm_correct), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), state=CreateTestSG.mark_correct_options, diff --git a/src/trudex/application/bot/creator_dialogs/groups.py b/src/trudex/application/bot/creator_dialogs/groups.py new file mode 100644 index 0000000..6f5bd97 --- /dev/null +++ b/src/trudex/application/bot/creator_dialogs/groups.py @@ -0,0 +1,194 @@ +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka.integrations.aiogram import CONTAINER_NAME + +from trudex.application.bot.creator_dialogs.states import CreatorGroupsSG +from trudex.infrastructure.database.dao.group import GroupDAO + + +async def on_group_click(_callback: CallbackQuery, _widget, _manager: DialogManager, _item_id: str): + await _callback.answer("ℹ️ Для удаления используйте кнопку 'Удалить группу'") + + +async def get_groups_data(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + groups = await group_dao.get_all() + + success_message = dialog_manager.dialog_data.pop("success_message", None) + + message_text = "👥 Управление группами\n\n" + if success_message: + message_text += f"{success_message}\n\n" + message_text += f"📊 Всего групп: {len(groups)}\n\nСписок групп:" + + return { + "groups": [(str(g.id), str(g.number)) for g in groups], + "groups_count": len(groups), + "message_text": message_text, + } + + +async def on_add_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorGroupsSG.add_group_input_number) + + +async def on_delete_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorGroupsSG.delete_groups_list) + + +async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + from trudex.application.bot.creator_dialogs.states import CreatorMenuSG + await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + + +async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Номер группы не может быть пустым") + return + + number_str = message.text.strip() + + if not number_str.isdigit(): + await message.answer("❌ Номер группы должен содержать только цифры") + return + + number = int(number_str) + + if number < 1000 or number > 9999: + await message.answer("❌ Номер группы должен быть четырехзначным (1000-9999)") + return + + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + existing = await group_dao.get_by_number(number) + if existing: + await message.answer(f"❌ Группа с номером {number} уже существует") + return + + try: + await group_dao.create(number=number) + manager.dialog_data["success_message"] = f"✅ Группа {number} создана" + except Exception as e: + await message.answer(f"❌ Ошибка создания группы: {e}") + return + + await manager.switch_to(CreatorGroupsSG.groups_list) + + +async def on_cancel_add(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorGroupsSG.groups_list) + + +async def get_delete_groups_data(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + groups = await group_dao.get_all() + + return { + "groups": [(str(g.id), str(g.number)) for g in groups], + "groups_count": len(groups), + } + + +async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + group = await group_dao.get_by_id(int(item_id)) + if not group: + await _callback.answer("❌ Группа не найдена", show_alert=True) + return + + manager.dialog_data["delete_group_id"] = group.id + manager.dialog_data["delete_group_number"] = group.number + await manager.switch_to(CreatorGroupsSG.delete_confirm) + + +async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs): + number = dialog_manager.dialog_data.get("delete_group_number", "") + + return { + "group_info": str(number) + } + + +async def on_confirm_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): + container = manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + group_id = manager.dialog_data.get("delete_group_id") + + await group_dao.delete(group_id) + + manager.dialog_data["success_message"] = "✅ Группа удалена" + await manager.switch_to(CreatorGroupsSG.groups_list) + + +async def on_cancel_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorGroupsSG.delete_groups_list) + + +groups_dialog = Dialog( + Window( + Format("{message_text}"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_click, + ), + id="groups_scroll", + width=2, + height=7, + ), + Column( + Button(Const("➕ Добавить группу"), id="add", on_click=on_add_group), + Button(Const("🗑 Удалить группу"), id="delete", on_click=on_delete_group), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu), + ), + state=CreatorGroupsSG.groups_list, + getter=get_groups_data, + ), + Window( + Const("➕ Добавление группы\n\n🔢 Введите номер группы (четырехзначное число 1000-9999):"), + MessageInput(on_group_number_input), + Button(Const("◀️ Отмена"), id="cancel", on_click=on_cancel_add), + state=CreatorGroupsSG.add_group_input_number, + ), + Window( + Format("🗑 Удаление группы\n\nВыберите группу для удаления:\n\n📊 Всего групп: {groups_count}"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="delete_groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_select_group_to_delete, + ), + id="delete_groups_scroll", + width=2, + height=7, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_add), + state=CreatorGroupsSG.delete_groups_list, + getter=get_delete_groups_data, + ), + Window( + Format("⚠️ Подтверждение удаления\n\nТочно хотите удалить группу?\n\n👥 {group_info}"), + Row( + Button(Const("✅ Да, удалить"), id="confirm", on_click=on_confirm_delete), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_delete), + ), + state=CreatorGroupsSG.delete_confirm, + getter=get_delete_confirm_data, + ), +) diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index 6159fcc..ccede3f 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -3,7 +3,7 @@ from aiogram_dialog import Dialog, DialogManager, Window, StartMode from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG, CreatorTestsSG, CreatorBroadcastSG +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG, CreatorTestsSG, CreatorBroadcastSG, CreatorGroupsSG async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: @@ -14,6 +14,10 @@ async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: D await manager.start(CreatorUsersSG.users_list, mode=StartMode.RESET_STACK) +async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(CreatorGroupsSG.groups_list, mode=StartMode.RESET_STACK) + + async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: await manager.start(CreatorBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) @@ -24,6 +28,7 @@ creator_menu_dialog = Dialog( Column( Button(Const("📝 Тесты"), id="tests", on_click=on_tests_clicked), Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked), + Button(Const("🎓 Группы"), id="groups", on_click=on_groups_clicked), Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked), ), state=CreatorMenuSG.main, diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 20a2c2e..876135f 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -21,6 +21,13 @@ class CreatorBroadcastSG(StatesGroup): broadcast_confirm = State() +class CreatorGroupsSG(StatesGroup): + groups_list = State() + add_group_input_number = State() + delete_groups_list = State() + delete_confirm = State() + + class CreateTestSG(StatesGroup): input_title = State() input_description = State() diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index 2ea0e68..f8c4634 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -14,6 +14,14 @@ class User: updated_at: datetime | None = None +@dataclass +class Group: + id: int + number: int + created_at: datetime | None = None + updated_at: datetime | None = None + + @dataclass class Test: id: int diff --git a/src/trudex/infrastructure/database/dao/group.py b/src/trudex/infrastructure/database/dao/group.py new file mode 100644 index 0000000..42a0487 --- /dev/null +++ b/src/trudex/infrastructure/database/dao/group.py @@ -0,0 +1,73 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from trudex.domain.schemas import Group as DomainGroup +from trudex.infrastructure.database.dto.group import GroupDTO +from trudex.infrastructure.database.models import Group + + +class GroupDAO: + def __init__(self, session: AsyncSession) -> None: + self.session: AsyncSession = session + + async def get_by_id(self, group_id: int) -> DomainGroup | None: + result = await self.session.execute( + select(Group).where(Group.id == group_id) + ) + model = result.scalar_one_or_none() + return GroupDTO(model).to_domain() if model else None + + async def get_by_number(self, number: int) -> DomainGroup | None: + result = await self.session.execute( + select(Group).where(Group.number == number) + ) + model = result.scalar_one_or_none() + return GroupDTO(model).to_domain() if model else None + + async def get_all(self) -> list[DomainGroup]: + result = await self.session.execute(select(Group)) + models = list(result.scalars().all()) + return [GroupDTO(model).to_domain() for model in models] + + async def create( + self, + number: int, + ) -> DomainGroup: + group = Group( + number=number, + ) + self.session.add(group) + await self.session.flush() + await self.session.refresh(group) + return GroupDTO(group).to_domain() + + async def update( + self, + group_id: int, + number: int | None = None + ) -> DomainGroup | None: + result = await self.session.execute( + select(Group).where(Group.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + return None + + if number is not None: + group.number = number + + await self.session.flush() + await self.session.refresh(group) + return GroupDTO(group).to_domain() + + async def delete(self, group_id: int) -> bool: + result = await self.session.execute( + select(Group).where(Group.id == group_id) + ) + group = result.scalar_one_or_none() + if not group: + return False + + await self.session.delete(group) + await self.session.flush() + return True diff --git a/src/trudex/infrastructure/database/dto/group.py b/src/trudex/infrastructure/database/dto/group.py new file mode 100644 index 0000000..767e847 --- /dev/null +++ b/src/trudex/infrastructure/database/dto/group.py @@ -0,0 +1,15 @@ +from trudex.domain.schemas import Group as DomainGroup +from trudex.infrastructure.database.models import Group as GroupModel + + +class GroupDTO: + def __init__(self, model: GroupModel) -> None: + self.model: GroupModel = model + + def to_domain(self) -> DomainGroup: + return DomainGroup( + id=self.model.id, + number=self.model.number, + created_at=self.model.created_at, + updated_at=self.model.updated_at, + ) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index fe7d7b9..bac500f 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -24,6 +24,23 @@ class User(Base): updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) +@final +class Group(Base): + __tablename__ = "groups" + + id: Mapped[int] = mapped_column(primary_key=True) + number: Mapped[int] = mapped_column(Integer, unique=True, index=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + CheckConstraint("number >= 1000 AND number <= 9999", name="check_group_number"), + ) + __table_args__ = ( + CheckConstraint("number >= 1000 AND number <= 9999", name="check_group_number"), + ) + + class QuestionType(str, Enum): SINGLE = "single" MULTIPLE = "multiple" diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index db73904..06756ed 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -4,6 +4,7 @@ from dishka import Provider, Scope, provide from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from trudex.infrastructure.database.config import new_session_maker +from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO @@ -37,6 +38,10 @@ class DatabaseProvider(Provider): def get_user_dao(self, session: AsyncSession) -> UserDAO: return UserDAO(session) + @provide(scope=Scope.REQUEST) + def get_group_dao(self, session: AsyncSession) -> GroupDAO: + return GroupDAO(session) + @provide(scope=Scope.REQUEST) def get_test_dao(self, session: AsyncSession) -> TestDAO: return TestDAO(session) From 9613ecee54af9ae74dc526e757e134fdb848dcc0 Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 20:45:38 +0300 Subject: [PATCH 16/57] commit --- src/trudex/application/__main__.py | 2 + src/trudex/application/bot/handlers.py | 66 ++++++++++++++++--- .../bot/user_dialogs/registration.py | 57 ++++++++++++++++ .../application/bot/user_dialogs/states.py | 4 ++ 4 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 src/trudex/application/bot/user_dialogs/registration.py diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 72bc31b..b76d899 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -23,6 +23,7 @@ from trudex.application.bot.handlers import router from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog +from trudex.application.bot.user_dialogs.registration import registration_dialog from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.di import DatabaseProvider from trudex.infrastructure.utils.bot_commands import setup_bot_commands @@ -47,6 +48,7 @@ async def main() -> None: dp.include_routers( router, user_menu_dialog, + registration_dialog, admin_menu_dialog, admin_users_dialog, admin_tests_dialog, diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 63ba8d4..5455a9c 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -7,7 +7,8 @@ from dishka.integrations.aiogram import FromDishka from trudex.application.bot.admin_dialogs.states import AdminMenuSG from trudex.application.bot.creator_dialogs.states import CreatorMenuSG -from trudex.application.bot.user_dialogs.states import UserMenuSG +from trudex.application.bot.user_dialogs.states import UserMenuSG, UserRegistrationSG +from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO @@ -15,17 +16,64 @@ router = Router() @router.message(CommandStart()) -async def start_handler(message: Message, user_dao: FromDishka[UserDAO], dialog_manager: DialogManager) -> None: +async def start_handler( + message: Message, + user_dao: FromDishka[UserDAO], + group_dao: FromDishka[GroupDAO], + dialog_manager: DialogManager +) -> None: assert message.from_user is not None - await user_dao.upsert( - user_id=message.from_user.id, - first_name=message.from_user.first_name, - username=message.from_user.username, - last_name=message.from_user.last_name, - ) + # Проверяем, существует ли пользователь + existing_user = await user_dao.get_by_id(message.from_user.id) - await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + if existing_user is None: + # Новый пользователь - проверяем наличие групп + groups = await group_dao.get_all() + + if len(groups) > 0: + # Есть группы - создаем пользователя без группы и показываем выбор + await user_dao.create( + user_id=message.from_user.id, + first_name=message.from_user.first_name, + username=message.from_user.username, + last_name=message.from_user.last_name, + ) + await dialog_manager.start( + UserRegistrationSG.select_group, + mode=StartMode.RESET_STACK, + data={"user_id": message.from_user.id} + ) + else: + # Нет групп - просто создаем пользователя + await user_dao.create( + user_id=message.from_user.id, + first_name=message.from_user.first_name, + username=message.from_user.username, + last_name=message.from_user.last_name, + ) + await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + else: + # Существующий пользователь + # Проверяем, выбрал ли он группу + groups = await group_dao.get_all() + + if len(groups) > 0 and existing_user.group is None: + # Есть группы, но пользователь не выбрал группу - показываем выбор + await dialog_manager.start( + UserRegistrationSG.select_group, + mode=StartMode.RESET_STACK, + data={"user_id": message.from_user.id} + ) + else: + # Группа выбрана или групп нет - обновляем данные и открываем меню + await user_dao.upsert( + user_id=message.from_user.id, + first_name=message.from_user.first_name, + username=message.from_user.username, + last_name=message.from_user.last_name, + ) + await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) @router.message(Command("admin")) diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/trudex/application/bot/user_dialogs/registration.py new file mode 100644 index 0000000..f597895 --- /dev/null +++ b/src/trudex/application/bot/user_dialogs/registration.py @@ -0,0 +1,57 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.kbd import ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka.integrations.aiogram import CONTAINER_NAME + +from trudex.application.bot.user_dialogs.states import UserMenuSG, UserRegistrationSG +from trudex.infrastructure.database.dao.group import GroupDAO +from trudex.infrastructure.database.dao.user import UserDAO + + +async def get_groups_for_registration(dialog_manager: DialogManager, **_kwargs): + container = dialog_manager.middleware_data[CONTAINER_NAME] + group_dao = await container.get(GroupDAO) + + groups = await group_dao.get_all() + + return { + "groups": [(str(g.number), str(g.number)) for g in groups], + } + + +async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + container = manager.middleware_data[CONTAINER_NAME] + user_dao = await container.get(UserDAO) + + user_id = manager.start_data.get("user_id") + + await user_dao.update(user_id=user_id, group=int(item_id)) + + await _callback.answer("✅ Группа выбрана! Вы можете изменить её через 24 часа", show_alert=True) + await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + + +registration_dialog = Dialog( + Window( + Const( + "👋 Добро пожаловать!\n\n" + "🎓 Выберите вашу группу:\n\n" + "⚠️ Внимание: Изменить группу можно будет только через 24 часа!" + ), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_selected, + ), + id="groups_scroll", + width=2, + height=7, + ), + state=UserRegistrationSG.select_group, + getter=get_groups_for_registration, + ), +) diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/trudex/application/bot/user_dialogs/states.py index 7435483..e89b889 100644 --- a/src/trudex/application/bot/user_dialogs/states.py +++ b/src/trudex/application/bot/user_dialogs/states.py @@ -3,3 +3,7 @@ from aiogram.fsm.state import State, StatesGroup class UserMenuSG(StatesGroup): main = State() + + +class UserRegistrationSG(StatesGroup): + select_group = State() From aeeaee4adddc4572a35b9093885baf45a5ca3fb5 Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 21:06:58 +0300 Subject: [PATCH 17/57] commit --- src/trudex/application/__main__.py | 39 +++++--- .../bot/admin_dialogs/broadcast.py | 5 +- .../application/bot/admin_dialogs/groups.py | 39 ++++---- .../bot/admin_dialogs/main_menu.py | 8 +- .../application/bot/admin_dialogs/states.py | 1 + .../application/bot/admin_dialogs/tests.py | 91 +++++++++++++++++- .../application/bot/admin_dialogs/users.py | 15 ++- .../bot/creator_dialogs/broadcast.py | 2 +- .../bot/creator_dialogs/create_test.py | 46 +++++----- .../application/bot/creator_dialogs/groups.py | 39 ++++---- .../bot/creator_dialogs/main_menu.py | 8 +- .../application/bot/creator_dialogs/states.py | 1 + .../application/bot/creator_dialogs/tests.py | 92 ++++++++++++++++++- .../application/bot/creator_dialogs/users.py | 21 ++--- src/trudex/application/bot/handlers.py | 4 +- .../bot/user_dialogs/registration.py | 18 ++-- .../infrastructure/database/dao/__init__.py | 4 +- .../database/dto/test_attempt.py | 3 +- src/trudex/infrastructure/database/models.py | 3 +- .../infrastructure/database/repo/__init__.py | 3 +- .../infrastructure/database/repo/test.py | 8 +- .../database/repo/test_attempt.py | 10 +- src/trudex/infrastructure/di.py | 3 +- .../infrastructure/utils/bot_commands.py | 3 +- 24 files changed, 320 insertions(+), 146 deletions(-) diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index b76d899..054d2ce 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -8,22 +8,35 @@ from aiogram_dialog import setup_dialogs from dishka import make_async_container from dishka.integrations.aiogram import setup_dishka -from trudex.application.bot.admin_dialogs.broadcast import broadcast_dialog as admin_broadcast_dialog -from trudex.application.bot.admin_dialogs.groups import groups_dialog as admin_groups_dialog +from trudex.application.bot.admin_dialogs.broadcast import \ + broadcast_dialog as admin_broadcast_dialog +from trudex.application.bot.admin_dialogs.groups import \ + groups_dialog as admin_groups_dialog from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog -from trudex.application.bot.admin_dialogs.tests import tests_dialog as admin_tests_dialog -from trudex.application.bot.admin_dialogs.users import users_dialog as admin_users_dialog -from trudex.application.bot.creator_dialogs.broadcast import broadcast_dialog as creator_broadcast_dialog -from trudex.application.bot.creator_dialogs.create_test import create_test_dialog -from trudex.application.bot.creator_dialogs.groups import groups_dialog as creator_groups_dialog -from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog -from trudex.application.bot.creator_dialogs.tests import tests_dialog as creator_tests_dialog -from trudex.application.bot.creator_dialogs.users import users_dialog as creator_users_dialog +from trudex.application.bot.admin_dialogs.tests import \ + tests_dialog as admin_tests_dialog +from trudex.application.bot.admin_dialogs.users import \ + users_dialog as admin_users_dialog +from trudex.application.bot.creator_dialogs.broadcast import \ + broadcast_dialog as creator_broadcast_dialog +from trudex.application.bot.creator_dialogs.create_test import \ + create_test_dialog +from trudex.application.bot.creator_dialogs.groups import \ + groups_dialog as creator_groups_dialog +from trudex.application.bot.creator_dialogs.main_menu import \ + creator_menu_dialog +from trudex.application.bot.creator_dialogs.tests import \ + tests_dialog as creator_tests_dialog +from trudex.application.bot.creator_dialogs.users import \ + users_dialog as creator_users_dialog from trudex.application.bot.handlers import router -from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware -from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware +from trudex.application.bot.middlewares.reject_not_admin import \ + RejectNotAdminMiddleware +from trudex.application.bot.middlewares.reject_not_creator import \ + RejectNotCreatorMiddleware from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog -from trudex.application.bot.user_dialogs.registration import registration_dialog +from trudex.application.bot.user_dialogs.registration import \ + registration_dialog from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.di import DatabaseProvider from trudex.infrastructure.utils.bot_commands import setup_bot_commands diff --git a/src/trudex/application/bot/admin_dialogs/broadcast.py b/src/trudex/application/bot/admin_dialogs/broadcast.py index 69f9e8b..937ccfd 100644 --- a/src/trudex/application/bot/admin_dialogs/broadcast.py +++ b/src/trudex/application/bot/admin_dialogs/broadcast.py @@ -1,12 +1,13 @@ from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminBroadcastSG, AdminMenuSG +from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, + AdminMenuSG) from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.broadcast import broadcast_message diff --git a/src/trudex/application/bot/admin_dialogs/groups.py b/src/trudex/application/bot/admin_dialogs/groups.py index 821bc5b..4d62d10 100644 --- a/src/trudex/application/bot/admin_dialogs/groups.py +++ b/src/trudex/application/bot/admin_dialogs/groups.py @@ -1,11 +1,14 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, + Select) from aiogram_dialog.widgets.text import Const, Format -from dishka.integrations.aiogram import CONTAINER_NAME +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminGroupsSG, AdminMenuSG +from trudex.application.bot.admin_dialogs.states import (AdminGroupsSG, + AdminMenuSG) from trudex.infrastructure.database.dao.group import GroupDAO @@ -13,10 +16,8 @@ async def on_group_click(_callback: CallbackQuery, _widget, _manager: DialogMana await _callback.answer("ℹ️ Для удаления используйте кнопку 'Удалить группу'") -async def get_groups_data(dialog_manager: DialogManager, **_kwargs): - container = dialog_manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - +@inject +async def get_groups_data(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() success_message = dialog_manager.dialog_data.pop("success_message", None) @@ -45,7 +46,8 @@ async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: Di await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) -async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager): +@inject +async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager, group_dao: FromDishka[GroupDAO]): if not message.text: await message.answer("❌ Номер группы не может быть пустым") return @@ -62,9 +64,6 @@ async def on_group_number_input(message: Message, _widget: MessageInput, manager await message.answer("❌ Номер группы должен быть четырехзначным (1000-9999)") return - container = manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - existing = await group_dao.get_by_number(number) if existing: await message.answer(f"❌ Группа с номером {number} уже существует") @@ -84,10 +83,8 @@ async def on_cancel_add(_callback: CallbackQuery, _button: Button, manager: Dial await manager.switch_to(AdminGroupsSG.groups_list) -async def get_delete_groups_data(dialog_manager: DialogManager, **_kwargs): - container = dialog_manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - +@inject +async def get_delete_groups_data(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() return { @@ -96,10 +93,8 @@ async def get_delete_groups_data(dialog_manager: DialogManager, **_kwargs): } -async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): - container = manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - +@inject +async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, group_dao: FromDishka[GroupDAO]): group = await group_dao.get_by_id(int(item_id)) if not group: await _callback.answer("❌ Группа не найдена", show_alert=True) @@ -118,10 +113,8 @@ async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs): } -async def on_confirm_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - +@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") await group_dao.delete(group_id) diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index 007c039..9f0c52f 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -1,9 +1,13 @@ from aiogram.types import CallbackQuery -from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG, AdminTestsSG, AdminBroadcastSG, AdminGroupsSG +from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, + AdminGroupsSG, + AdminMenuSG, + AdminTestsSG, + AdminUsersSG) async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 312340b..71bd8f1 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -13,6 +13,7 @@ class AdminUsersSG(StatesGroup): class AdminTestsSG(StatesGroup): tests_list = State() + test_detail = State() class AdminBroadcastSG(StatesGroup): diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index eae7fe9..1cf6a51 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -1,12 +1,15 @@ from aiogram.types import CallbackQuery -from aiogram_dialog import Dialog, DialogManager, Window, StartMode -from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, + Select) from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminTestsSG, AdminMenuSG +from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, + AdminTestsSG) from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.repo.test import TestRepository @inject @@ -24,7 +27,74 @@ async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs): async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): manager.dialog_data["selected_test_id"] = int(item_id) - await _callback.answer("Тест выбран") + await manager.switch_to(AdminTestsSG.test_detail) + + +@inject +async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], dialog_manager: DialogManager, **_kwargs): + test_id = dialog_manager.dialog_data.get("selected_test_id") + + if not test_id: + return { + "test_info": "Тест не найден", + "is_active": False, + "button_text": "◀️ Назад", + } + + test = await test_dao.get_by_id(test_id) + questions_count = await test_repo.count_questions_in_test(test_id) + + if not test: + return { + "test_info": "Тест не найден", + "is_active": False, + "button_text": "◀️ Назад", + } + + status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" + password_str = f"🔒 {test.password}" if test.password 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 "👥 Для всех" + + test_info = ( + f"📝 Информация о тесте\n\n" + f"Название: {test.title}\n" + f"Описание: {test.description or '—'}\n\n" + f"Статус: {status}\n" + f"Вопросов: {questions_count}\n" + f"{password_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 '—'}" + ) + + button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" + + return { + "test_info": test_info, + "is_active": test.is_active, + "button_text": button_text, + } + + +@inject +async def on_toggle_active(_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 + + test = await test_dao.get_by_id(test_id) + + if test: + await test_dao.update(test_id, is_active=not test.is_active) + action = "деактивирован" if test.is_active else "активирован" + await _callback.answer(f"✅ Тест {action}") + await manager.switch_to(AdminTestsSG.test_detail) + + +async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.tests_list) async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): @@ -57,4 +127,17 @@ tests_dialog = Dialog( state=AdminTestsSG.tests_list, getter=get_tests_data, ), + Window( + Format("{test_info}"), + Row( + Button( + Format("{button_text}"), + id="toggle_active", + on_click=on_toggle_active + ), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=AdminTestsSG.test_detail, + getter=get_test_detail, + ), ) diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index f0d3cf8..a9c2f20 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -1,13 +1,14 @@ from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select, SwitchTo +from aiogram_dialog.widgets.kbd import (Button, Column, ScrollingGroup, Select, + SwitchTo) from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka -from dishka.integrations.aiogram import CONTAINER_NAME from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminUsersSG, AdminMenuSG +from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, + AdminUsersSG) from trudex.infrastructure.database.dao.user import UserDAO @@ -61,10 +62,8 @@ async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: Dial await manager.switch_to(AdminUsersSG.users_input) -async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - user_dao = await container.get(UserDAO) - +@inject +async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager, user_dao: FromDishka[UserDAO]): text = (message.text or "").strip() user = None diff --git a/src/trudex/application/bot/creator_dialogs/broadcast.py b/src/trudex/application/bot/creator_dialogs/broadcast.py index 5e42087..5663b79 100644 --- a/src/trudex/application/bot/creator_dialogs/broadcast.py +++ b/src/trudex/application/bot/creator_dialogs/broadcast.py @@ -1,7 +1,7 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Row, Cancel +from aiogram_dialog.widgets.kbd import Button, Cancel, Row from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index 50c9cc0..bb96ce3 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -1,14 +1,16 @@ from datetime import date, datetime from aiogram.types import CallbackQuery, ContentType, Message -from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import (Button, Calendar, Cancel, Column, Row, + ScrollingGroup, Select) from aiogram_dialog.widgets.text import Const, Format -from dishka.integrations.aiogram import CONTAINER_NAME +from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorTestsSG +from trudex.application.bot.creator_dialogs.states import (CreateTestSG, + CreatorTestsSG) from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO @@ -52,7 +54,8 @@ async def on_description_input(message: Message, _widget: MessageInput, manager: await manager.switch_to(CreateTestSG.input_password) -async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager): +@inject +async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager, group_dao: FromDishka[GroupDAO]): if not message.text: await message.answer("❌ Пароль не может быть пустым") return @@ -68,8 +71,6 @@ async def on_password_input(message: Message, _widget: MessageInput, manager: Di manager.dialog_data["password"] = password - container = manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) groups = await group_dao.get_all() if len(groups) == 0: @@ -79,10 +80,9 @@ async def on_password_input(message: Message, _widget: MessageInput, manager: Di await manager.switch_to(CreateTestSG.input_expires_at) -async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): +@inject +async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager, group_dao: FromDishka[GroupDAO]): manager.dialog_data["password"] = None - container = manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) groups = await group_dao.get_all() if len(groups) == 0: @@ -102,9 +102,8 @@ async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(CreateTestSG.input_for_group) -async def get_groups_for_test(dialog_manager: DialogManager, **_kwargs): - container = dialog_manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) +@inject +async def get_groups_for_test(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() return { @@ -145,10 +144,8 @@ async def get_test_info(dialog_manager: DialogManager, **_kwargs): } -async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - test_dao = await container.get(TestDAO) - +@inject +async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, test_dao: FromDishka[TestDAO]): title = manager.dialog_data.get("title") description = manager.dialog_data.get("description") password = manager.dialog_data.get("password") @@ -351,12 +348,15 @@ async def get_question_preview(dialog_manager: DialogManager, **_kwargs): return {"preview": preview} -async def on_save_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - question_dao = await container.get(QuestionDAO) - option_dao = await container.get(OptionDAO) - test_repo = await container.get(TestRepository) - +@inject +async def on_save_question( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + question_dao: FromDishka[QuestionDAO], + option_dao: FromDishka[OptionDAO], + test_repo: FromDishka[TestRepository], +): test_id = manager.dialog_data.get("test_id") current_question = manager.dialog_data.get("current_question", {}) current_options = manager.dialog_data.get("current_options", []) diff --git a/src/trudex/application/bot/creator_dialogs/groups.py b/src/trudex/application/bot/creator_dialogs/groups.py index 6f5bd97..9642616 100644 --- a/src/trudex/application/bot/creator_dialogs/groups.py +++ b/src/trudex/application/bot/creator_dialogs/groups.py @@ -1,11 +1,14 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, + Select) from aiogram_dialog.widgets.text import Const, Format -from dishka.integrations.aiogram import CONTAINER_NAME +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreatorGroupsSG +from trudex.application.bot.creator_dialogs.states import (CreatorGroupsSG, + CreatorMenuSG) from trudex.infrastructure.database.dao.group import GroupDAO @@ -13,10 +16,8 @@ async def on_group_click(_callback: CallbackQuery, _widget, _manager: DialogMana await _callback.answer("ℹ️ Для удаления используйте кнопку 'Удалить группу'") -async def get_groups_data(dialog_manager: DialogManager, **_kwargs): - container = dialog_manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - +@inject +async def get_groups_data(group_dao: FromDishka[GroupDAO], dialog_manager: DialogManager, **_kwargs): groups = await group_dao.get_all() success_message = dialog_manager.dialog_data.pop("success_message", None) @@ -46,7 +47,8 @@ async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: Di await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) -async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager): +@inject +async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager, group_dao: FromDishka[GroupDAO]): if not message.text: await message.answer("❌ Номер группы не может быть пустым") return @@ -63,9 +65,6 @@ async def on_group_number_input(message: Message, _widget: MessageInput, manager await message.answer("❌ Номер группы должен быть четырехзначным (1000-9999)") return - container = manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - existing = await group_dao.get_by_number(number) if existing: await message.answer(f"❌ Группа с номером {number} уже существует") @@ -85,10 +84,8 @@ async def on_cancel_add(_callback: CallbackQuery, _button: Button, manager: Dial await manager.switch_to(CreatorGroupsSG.groups_list) -async def get_delete_groups_data(dialog_manager: DialogManager, **_kwargs): - container = dialog_manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - +@inject +async def get_delete_groups_data(group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() return { @@ -97,10 +94,8 @@ async def get_delete_groups_data(dialog_manager: DialogManager, **_kwargs): } -async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): - container = manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - +@inject +async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, group_dao: FromDishka[GroupDAO]): group = await group_dao.get_by_id(int(item_id)) if not group: await _callback.answer("❌ Группа не найдена", show_alert=True) @@ -119,10 +114,8 @@ async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs): } -async def on_confirm_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - +@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") await group_dao.delete(group_id) diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index ccede3f..3d63ee0 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -1,9 +1,13 @@ from aiogram.types import CallbackQuery -from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG, CreatorTestsSG, CreatorBroadcastSG, CreatorGroupsSG +from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, + CreatorGroupsSG, + CreatorMenuSG, + CreatorTestsSG, + CreatorUsersSG) async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 876135f..f6744a4 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -14,6 +14,7 @@ class CreatorUsersSG(StatesGroup): class CreatorTestsSG(StatesGroup): tests_list = State() + test_detail = State() class CreatorBroadcastSG(StatesGroup): diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 67d29e9..9c6a63d 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -1,12 +1,16 @@ from aiogram.types import CallbackQuery -from aiogram_dialog import Dialog, DialogManager, Window, StartMode -from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, + Select) from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreatorTestsSG, CreatorMenuSG, CreateTestSG +from trudex.application.bot.creator_dialogs.states import (CreateTestSG, + CreatorMenuSG, + CreatorTestsSG) from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.repo.test import TestRepository @inject @@ -24,7 +28,74 @@ async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs): async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): manager.dialog_data["selected_test_id"] = int(item_id) - await _callback.answer("Тест выбран") + await manager.switch_to(CreatorTestsSG.test_detail) + + +@inject +async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], dialog_manager: DialogManager, **_kwargs): + test_id = dialog_manager.dialog_data.get("selected_test_id") + + if not test_id: + return { + "test_info": "Тест не найден", + "is_active": False, + "button_text": "◀️ Назад", + } + + test = await test_dao.get_by_id(test_id) + questions_count = await test_repo.count_questions_in_test(test_id) + + if not test: + return { + "test_info": "Тест не найден", + "is_active": False, + "button_text": "◀️ Назад", + } + + status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" + password_str = f"🔒 {test.password}" if test.password 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 "👥 Для всех" + + test_info = ( + f"📝 Информация о тесте\n\n" + f"Название: {test.title}\n" + f"Описание: {test.description or '—'}\n\n" + f"Статус: {status}\n" + f"Вопросов: {questions_count}\n" + f"{password_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 '—'}" + ) + + button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" + + return { + "test_info": test_info, + "is_active": test.is_active, + "button_text": button_text, + } + + +@inject +async def on_toggle_active(_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 + + test = await test_dao.get_by_id(test_id) + + if test: + await test_dao.update(test_id, is_active=not test.is_active) + action = "деактивирован" if test.is_active else "активирован" + await _callback.answer(f"✅ Тест {action}") + await manager.switch_to(CreatorTestsSG.test_detail) + + +async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.tests_list) async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -57,4 +128,17 @@ tests_dialog = Dialog( state=CreatorTestsSG.tests_list, getter=get_tests_data, ), + Window( + Format("{test_info}"), + Row( + Button( + Format("{button_text}"), + id="toggle_active", + on_click=on_toggle_active + ), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=CreatorTestsSG.test_detail, + getter=get_test_detail, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index 6702510..a42b207 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -1,13 +1,14 @@ from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, Window, StartMode +from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select, SwitchTo +from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, + Select, SwitchTo) from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka -from dishka.integrations.aiogram import CONTAINER_NAME from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreatorUsersSG, CreatorMenuSG +from trudex.application.bot.creator_dialogs.states import (CreatorMenuSG, + CreatorUsersSG) from trudex.infrastructure.database.dao.user import UserDAO @@ -81,10 +82,8 @@ async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: Dial await manager.switch_to(CreatorUsersSG.users_input) -async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - user_dao = await container.get(UserDAO) - +@inject +async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager, user_dao: FromDishka[UserDAO]): text = (message.text or "").strip() user = None @@ -107,10 +106,8 @@ async def on_make_admin_clicked(_callback: CallbackQuery, _button: Button, manag await manager.switch_to(CreatorUsersSG.make_admin_confirm) -async def on_confirm_yes(_callback: CallbackQuery, _button: Button, manager: DialogManager): - container = manager.middleware_data[CONTAINER_NAME] - user_dao = await container.get(UserDAO) - +@inject +async def on_confirm_yes(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): user_id = manager.dialog_data.get("selected_user_id") if not user_id: await _callback.answer("Ошибка: пользователь не выбран") diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 5455a9c..c19724e 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -7,11 +7,11 @@ from dishka.integrations.aiogram import FromDishka from trudex.application.bot.admin_dialogs.states import AdminMenuSG from trudex.application.bot.creator_dialogs.states import CreatorMenuSG -from trudex.application.bot.user_dialogs.states import UserMenuSG, UserRegistrationSG +from trudex.application.bot.user_dialogs.states import (UserMenuSG, + UserRegistrationSG) from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO - router = Router() diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/trudex/application/bot/user_dialogs/registration.py index f597895..d1d77f0 100644 --- a/src/trudex/application/bot/user_dialogs/registration.py +++ b/src/trudex/application/bot/user_dialogs/registration.py @@ -2,17 +2,17 @@ from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.kbd import ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format -from dishka.integrations.aiogram import CONTAINER_NAME +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.user_dialogs.states import UserMenuSG, UserRegistrationSG +from trudex.application.bot.user_dialogs.states import (UserMenuSG, + UserRegistrationSG) from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO -async def get_groups_for_registration(dialog_manager: DialogManager, **_kwargs): - container = dialog_manager.middleware_data[CONTAINER_NAME] - group_dao = await container.get(GroupDAO) - +@inject +async def get_groups_for_registration(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() return { @@ -20,10 +20,8 @@ async def get_groups_for_registration(dialog_manager: DialogManager, **_kwargs): } -async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): - container = manager.middleware_data[CONTAINER_NAME] - user_dao = await container.get(UserDAO) - +@inject +async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, user_dao: FromDishka[UserDAO]): user_id = manager.start_data.get("user_id") await user_dao.update(user_id=user_id, group=int(item_id)) diff --git a/src/trudex/infrastructure/database/dao/__init__.py b/src/trudex/infrastructure/database/dao/__init__.py index ecf61f2..9d85bbf 100644 --- a/src/trudex/infrastructure/database/dao/__init__.py +++ b/src/trudex/infrastructure/database/dao/__init__.py @@ -1,5 +1,5 @@ +from .option import OptionDAO as OptionDAO +from .question import QuestionDAO as QuestionDAO from .test import TestDAO as TestDAO from .user import UserDAO as UserDAO -from .question import QuestionDAO as QuestionDAO -from .option import OptionDAO as OptionDAO \ No newline at end of file diff --git a/src/trudex/infrastructure/database/dto/test_attempt.py b/src/trudex/infrastructure/database/dto/test_attempt.py index 786eb38..9fb3255 100644 --- a/src/trudex/infrastructure/database/dto/test_attempt.py +++ b/src/trudex/infrastructure/database/dto/test_attempt.py @@ -1,5 +1,6 @@ from trudex.domain.schemas import TestAttempt as DomainTestAttempt -from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel +from trudex.infrastructure.database.models import \ + TestAttempt as TestAttemptModel class TestAttemptDTO: diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index bac500f..2d03310 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -2,7 +2,8 @@ from datetime import datetime from enum import Enum from typing import final -from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, Text, func +from sqlalchemy import (BigInteger, CheckConstraint, ForeignKey, Integer, + String, Text, func) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship diff --git a/src/trudex/infrastructure/database/repo/__init__.py b/src/trudex/infrastructure/database/repo/__init__.py index 3d25dad..1f6a9b8 100644 --- a/src/trudex/infrastructure/database/repo/__init__.py +++ b/src/trudex/infrastructure/database/repo/__init__.py @@ -1,5 +1,6 @@ from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository +from trudex.infrastructure.database.repo.test_attempt import \ + TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository __all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"] diff --git a/src/trudex/infrastructure/database/repo/test.py b/src/trudex/infrastructure/database/repo/test.py index fc36ad5..fba422b 100644 --- a/src/trudex/infrastructure/database/repo/test.py +++ b/src/trudex/infrastructure/database/repo/test.py @@ -11,11 +11,9 @@ from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dto.option import OptionDTO from trudex.infrastructure.database.dto.question import QuestionDTO from trudex.infrastructure.database.dto.test import TestDTO -from trudex.infrastructure.database.models import ( - Option as OptionModel, - Question as QuestionModel, - Test as TestModel, -) +from trudex.infrastructure.database.models import Option as OptionModel +from trudex.infrastructure.database.models import Question as QuestionModel +from trudex.infrastructure.database.models import Test as TestModel @final diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index d6043cb..476cb23 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -10,10 +10,9 @@ from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.dto.test_attempt import TestAttemptDTO from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO -from trudex.infrastructure.database.models import ( - TestAttempt as TestAttemptModel, - UserAnswer as UserAnswerModel, -) +from trudex.infrastructure.database.models import \ + TestAttempt as TestAttemptModel +from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel @final @@ -177,7 +176,8 @@ class TestAttemptRepository: } async def get_most_difficult_questions(self, test_id: int, limit: int = 10) -> list[tuple[int, float]]: - from trudex.infrastructure.database.models import Question as QuestionModel + from trudex.infrastructure.database.models import \ + Question as QuestionModel result = await self.session.execute( select( diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index 06756ed..536735d 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -12,7 +12,8 @@ from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository +from trudex.infrastructure.database.repo.test_attempt import \ + TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.config import Config diff --git a/src/trudex/infrastructure/utils/bot_commands.py b/src/trudex/infrastructure/utils/bot_commands.py index d017f64..15b73ab 100644 --- a/src/trudex/infrastructure/utils/bot_commands.py +++ b/src/trudex/infrastructure/utils/bot_commands.py @@ -1,5 +1,6 @@ from aiogram import Bot -from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeChat +from aiogram.types import (BotCommand, BotCommandScopeAllPrivateChats, + BotCommandScopeChat) from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.config import Config From 8e38fd6d56d5e51acee1775083704b765131603a Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 21:10:39 +0300 Subject: [PATCH 18/57] Initial commit --- src/trudex/application/bot/admin_dialogs/tests.py | 2 +- src/trudex/application/bot/creator_dialogs/tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 1cf6a51..7c665a4 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -18,7 +18,7 @@ async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs): return { "tests": [ - (t.title, t.id) + (f"{'🟢' if t.is_active else '🔴'} {t.title}", t.id) for t in tests ], "count": len(tests), diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 9c6a63d..9740562 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -19,7 +19,7 @@ async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs): return { "tests": [ - (t.title, t.id) + (f"{'🟢' if t.is_active else '🔴'} {t.title}", t.id) for t in tests ], "count": len(tests), From a0e9467b0d583ebdc6a81741902dec8c1189aeef Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 21:31:44 +0300 Subject: [PATCH 19/57] commit --- .../versions/a879badde4a5_add_name_to_user.py | 25 +++++++++++++ .../application/bot/admin_dialogs/users.py | 4 ++- .../application/bot/creator_dialogs/users.py | 11 +++--- src/trudex/application/bot/handlers.py | 31 ++++++++++------ .../bot/user_dialogs/registration.py | 35 +++++++++++++++++-- .../application/bot/user_dialogs/states.py | 1 + src/trudex/domain/schemas.py | 1 + .../infrastructure/database/dao/test.py | 4 ++- .../infrastructure/database/dao/user.py | 9 +++++ .../infrastructure/database/dto/user.py | 1 + src/trudex/infrastructure/database/models.py | 1 + 11 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 alembic/versions/a879badde4a5_add_name_to_user.py diff --git a/alembic/versions/a879badde4a5_add_name_to_user.py b/alembic/versions/a879badde4a5_add_name_to_user.py new file mode 100644 index 0000000..dd8dd66 --- /dev/null +++ b/alembic/versions/a879badde4a5_add_name_to_user.py @@ -0,0 +1,25 @@ +"""add_name_to_user + +Revision ID: a879badde4a5 +Revises: 520eccd2e55f +Create Date: 2026-01-02 21:21:22.159248 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'a879badde4a5' +down_revision: str | None = '520eccd2e55f' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column('users', sa.Column('name', sa.String(length=128), nullable=True)) + + +def downgrade() -> None: + op.drop_column('users', 'name') diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index a9c2f20..34a08b6 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -18,7 +18,7 @@ async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): return { "users": [ - (f"{u.first_name} (@{u.username or 'нет'})", u.id) + (f"{u.name or u.first_name} (@{u.username or 'нет'})", u.id) for u in users ], "count": len(users), @@ -37,6 +37,7 @@ async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDish username_str = f"@{user.username}" if user.username else "—" last_name_str = user.last_name or "—" + name_str = user.name or "—" group_str = str(user.group) if user.group else "—" admin_status = "✅ Да" if user.is_admin else "❌ Нет" @@ -45,6 +46,7 @@ async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDish f"ID: {user.id}\n" f"Имя: {user.first_name}\n" f"Фамилия: {last_name_str}\n" + f"Имя и фамилия: {name_str}\n" f"Username: {username_str}\n" f"Группа: {group_str}\n" f"Администратор: {admin_status}" diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index a42b207..9b92fb8 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -18,7 +18,7 @@ async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): return { "users": [ - (f"{u.first_name} (@{u.username or 'нет'})", u.id) + (f"{u.name or u.first_name} (@{u.username or 'нет'})", u.id) for u in users ], "count": len(users), @@ -36,15 +36,15 @@ async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDish return {"user_info": "Пользователь не найден", "is_admin": True, "show_make_admin": False} username_str = f"@{user.username}" if user.username else "—" - last_name_str = user.last_name or "—" + name_str = user.name or "—" group_str = str(user.group) if user.group else "—" admin_status = "✅ Да" if user.is_admin else "❌ Нет" user_info = ( f"👤 Информация о пользователе\n\n" f"ID: {user.id}\n" - f"Имя: {user.first_name}\n" - f"Фамилия: {last_name_str}\n" + f"Ник: {user.first_name}\n" + f"Имя и фамилия: {name_str}\n" f"Username: {username_str}\n" f"Группа: {group_str}\n" f"Администратор: {admin_status}" @@ -68,8 +68,9 @@ async def get_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[U return {"user_info": "Пользователь не найден"} username_str = f"@{user.username}" if user.username else "—" + name_str = user.name or f"{user.first_name} {user.last_name or ''}".strip() return { - "user_info": f"{user.first_name}\n{username_str}\nID: {user.id}" + "user_info": f"{name_str}\n{username_str}\nID: {user.id}" } diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index c19724e..663f378 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -32,7 +32,7 @@ async def start_handler( groups = await group_dao.get_all() if len(groups) > 0: - # Есть группы - создаем пользователя без группы и показываем выбор + # Есть группы - создаем пользователя без группы и имени, показываем регистрацию await user_dao.create( user_id=message.from_user.id, first_name=message.from_user.first_name, @@ -40,7 +40,7 @@ async def start_handler( last_name=message.from_user.last_name, ) await dialog_manager.start( - UserRegistrationSG.select_group, + UserRegistrationSG.input_name, mode=StartMode.RESET_STACK, data={"user_id": message.from_user.id} ) @@ -55,18 +55,27 @@ async def start_handler( await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) else: # Существующий пользователь - # Проверяем, выбрал ли он группу + # Проверяем, заполнил ли он имя и группу groups = await group_dao.get_all() - if len(groups) > 0 and existing_user.group is None: - # Есть группы, но пользователь не выбрал группу - показываем выбор - await dialog_manager.start( - UserRegistrationSG.select_group, - mode=StartMode.RESET_STACK, - data={"user_id": message.from_user.id} - ) + if len(groups) > 0 and (existing_user.name is None or existing_user.group is None): + # Есть группы, но пользователь не завершил регистрацию + if existing_user.name is None: + # Начинаем с ввода имени + await dialog_manager.start( + UserRegistrationSG.input_name, + mode=StartMode.RESET_STACK, + data={"user_id": message.from_user.id} + ) + else: + # Имя есть, но нет группы + await dialog_manager.start( + UserRegistrationSG.select_group, + mode=StartMode.RESET_STACK, + data={"user_id": message.from_user.id} + ) else: - # Группа выбрана или групп нет - обновляем данные и открываем меню + # Регистрация завершена или групп нет - обновляем данные и открываем меню await user_dao.upsert( user_id=message.from_user.id, first_name=message.from_user.first_name, diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/trudex/application/bot/user_dialogs/registration.py index d1d77f0..632e983 100644 --- a/src/trudex/application/bot/user_dialogs/registration.py +++ b/src/trudex/application/bot/user_dialogs/registration.py @@ -1,5 +1,6 @@ -from aiogram.types import CallbackQuery +from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka @@ -11,6 +12,28 @@ from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO +@inject +async def on_name_input(message: Message, _widget: MessageInput, manager: DialogManager, user_dao: FromDishka[UserDAO]): + if not message.text: + await message.answer("❌ Имя и фамилия не могут быть пустыми") + return + + name = message.text.strip() + if not name: + await message.answer("❌ Имя и фамилия не могут быть пустыми") + return + + if len(name) > 128: + await message.answer("❌ Имя и фамилия слишком длинные (максимум 128 символов)") + return + + user_id = manager.start_data.get("user_id") + await user_dao.update(user_id=user_id, name=name) + + manager.dialog_data["name"] = name + await manager.switch_to(UserRegistrationSG.select_group) + + @inject async def get_groups_for_registration(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() @@ -34,7 +57,15 @@ registration_dialog = Dialog( Window( Const( "👋 Добро пожаловать!\n\n" - "🎓 Выберите вашу группу:\n\n" + "✏️ Введите ваше имя и фамилию:\n\n" + "⚠️ Внимание: Изменить данные можно будет только через 24 часа!" + ), + MessageInput(on_name_input), + state=UserRegistrationSG.input_name, + ), + Window( + Const( + "🎓 Выберите вашу группу:\n\n" "⚠️ Внимание: Изменить группу можно будет только через 24 часа!" ), ScrollingGroup( diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/trudex/application/bot/user_dialogs/states.py index e89b889..efb7e10 100644 --- a/src/trudex/application/bot/user_dialogs/states.py +++ b/src/trudex/application/bot/user_dialogs/states.py @@ -6,4 +6,5 @@ class UserMenuSG(StatesGroup): class UserRegistrationSG(StatesGroup): + input_name = State() select_group = State() diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index f8c4634..40c5949 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -8,6 +8,7 @@ class User: first_name: str username: str | None = None last_name: str | None = None + name: str | None = None group: int | None = None is_admin: bool = False created_at: datetime | None = None diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/trudex/infrastructure/database/dao/test.py index 6a67452..88266b5 100644 --- a/src/trudex/infrastructure/database/dao/test.py +++ b/src/trudex/infrastructure/database/dao/test.py @@ -18,7 +18,9 @@ class TestDAO: return TestDTO(model).to_domain() if model else None async def get_all(self) -> list[DomainTest]: - result = await self.session.execute(select(Test)) + result = await self.session.execute( + select(Test).order_by(Test.id) + ) models = list(result.scalars().all()) return [TestDTO(model).to_domain() for model in models] diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index 8464bf9..a515a8d 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -28,6 +28,7 @@ class UserDAO: first_name: str, username: str | None = None, last_name: str | None = None, + name: str | None = None, group: int | None = None, is_admin: bool = False, ) -> DomainUser: @@ -36,6 +37,7 @@ class UserDAO: username=username, first_name=first_name, last_name=last_name, + name=name, group=group, is_admin=is_admin, ) @@ -50,6 +52,7 @@ class UserDAO: username: str | None = None, first_name: str | None = None, last_name: str | None = None, + name: str | None = None, group: int | None = None, is_admin: bool | None = None, ) -> DomainUser | None: @@ -66,6 +69,8 @@ class UserDAO: user.first_name = first_name if last_name is not None: user.last_name = last_name + if name is not None: + user.name = name if group is not None: user.group = group if is_admin is not None: @@ -93,6 +98,7 @@ class UserDAO: first_name: str, username: str | None = None, last_name: str | None = None, + name: str | None = None, group: int | None = None, is_admin: bool = False, ) -> DomainUser: @@ -108,6 +114,8 @@ class UserDAO: user.first_name = first_name if last_name is not None: user.last_name = last_name + if name is not None: + user.name = name if group is not None: user.group = group if is_admin is not None: @@ -121,6 +129,7 @@ class UserDAO: username=username, first_name=first_name, last_name=last_name, + name=name, group=group, is_admin=is_admin, ) diff --git a/src/trudex/infrastructure/database/dto/user.py b/src/trudex/infrastructure/database/dto/user.py index 5e69349..4354ed6 100644 --- a/src/trudex/infrastructure/database/dto/user.py +++ b/src/trudex/infrastructure/database/dto/user.py @@ -12,6 +12,7 @@ class UserDTO: username=self.model.username, first_name=self.model.first_name, last_name=self.model.last_name, + name=self.model.name, group=self.model.group, is_admin=self.model.is_admin, created_at=self.model.created_at, diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 2d03310..123f9e6 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -19,6 +19,7 @@ class User(Base): username: Mapped[str | None] = mapped_column(String(32)) first_name: Mapped[str] = mapped_column(String(64)) last_name: Mapped[str | None] = mapped_column(String(64)) + name: Mapped[str | None] = mapped_column(String(128)) group: Mapped[int | None] = mapped_column(CheckConstraint("group >= 1000 AND group <= 9999")) is_admin: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) From 4e93aabe11b0b2dfa7ffbddb557d820a7e0bc1f5 Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 21:42:32 +0300 Subject: [PATCH 20/57] commit --- .../application/bot/admin_dialogs/states.py | 3 + .../application/bot/admin_dialogs/tests.py | 157 +++++++++++++++++- .../bot/creator_dialogs/create_test.py | 5 +- .../application/bot/creator_dialogs/states.py | 3 + .../application/bot/creator_dialogs/tests.py | 157 +++++++++++++++++- .../infrastructure/database/dao/test.py | 6 +- 6 files changed, 320 insertions(+), 11 deletions(-) diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 71bd8f1..ca0540e 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -14,6 +14,9 @@ class AdminUsersSG(StatesGroup): class AdminTestsSG(StatesGroup): tests_list = State() test_detail = State() + edit_password = State() + edit_group = State() + edit_expires = State() class AdminBroadcastSG(StatesGroup): diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 7c665a4..9ce003e 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -1,13 +1,17 @@ -from aiogram.types import CallbackQuery +from datetime import date, datetime + +from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window -from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, - Select) +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, Row, + ScrollingGroup, Select) from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, AdminTestsSG) +from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository @@ -97,6 +101,109 @@ async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(AdminTestsSG.tests_list) +async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.edit_password) + + +async def on_edit_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.edit_group) + + +async def on_edit_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.edit_expires) + + +@inject +async def on_password_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 + + password = message.text.strip() + if len(password) > 255: + await message.answer("❌ Пароль слишком длинный (максимум 255 символов)") + return + + await test_dao.update(test_id, password=password) + await message.answer("✅ Пароль обновлен") + await manager.switch_to(AdminTestsSG.test_detail) + + +@inject +async def on_remove_password(_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, password=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() + + return { + "groups": [(str(g.number), str(g.number)) for g in groups], + } + + +@inject +async def on_group_selected_for_test(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, 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, for_group=int(item_id)) + await _callback.answer("✅ Группа обновлена") + await manager.switch_to(AdminTestsSG.test_detail) + + +@inject +async def on_remove_group(_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, for_group=None) + await _callback.answer("✅ Тест теперь доступен для всех групп") + await manager.switch_to(AdminTestsSG.test_detail) + + +@inject +async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, selected_date: date, test_dao: FromDishka[TestDAO]): + test_id = manager.dialog_data.get("selected_test_id") + if not test_id: + await _callback.answer("❌ Тест не найден") + return + + expires_at = datetime.combine(selected_date, datetime.min.time()) + await test_dao.update(test_id, expires_at=expires_at) + await _callback.answer("✅ Срок действия обновлен") + await manager.switch_to(AdminTestsSG.test_detail) + + +@inject +async def on_remove_expires(_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, expires_at=None) + await _callback.answer("✅ Срок действия удален") + await manager.switch_to(AdminTestsSG.test_detail) + + async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): await _callback.answer("Добавление теста") @@ -129,15 +236,57 @@ tests_dialog = Dialog( ), Window( Format("{test_info}"), - Row( + Column( Button( Format("{button_text}"), id="toggle_active", on_click=on_toggle_active ), + Button(Const("🔑 Изменить пароль"), id="edit_password", on_click=on_edit_password), + 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), ), state=AdminTestsSG.test_detail, getter=get_test_detail, ), + Window( + Const("🔑 Изменение пароля\n\n💬 Введите новый пароль или удалите текущий:\n(максимум 255 символов)"), + MessageInput(on_password_input), + Column( + Button(Const("🗑 Удалить пароль"), id="remove_password", on_click=on_remove_password), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=AdminTestsSG.edit_password, + ), + Window( + Const("👥 Изменение группы\n\n🎓 Выберите группу или удалите привязку:"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_selected_for_test, + ), + id="groups_scroll", + width=2, + height=7, + ), + Column( + Button(Const("🗑 Для всех групп"), id="remove_group", on_click=on_remove_group), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=AdminTestsSG.edit_group, + getter=get_groups_for_edit, + ), + Window( + Const("📅 Изменение срока действия\n\n🗓 Выберите новую дату или удалите срок:"), + Calendar(id="calendar", on_click=on_date_selected_for_test), + Column( + Button(Const("🗑 Удалить срок"), id="remove_expires", on_click=on_remove_expires), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=AdminTestsSG.edit_expires, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index bb96ce3..cc82112 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -176,11 +176,14 @@ async def on_question_input(message: Message, _widget: MessageInput, manager: Di if message.content_type == ContentType.PHOTO: photo = message.photo[-1] if message.photo else None if photo: - current_question["tg_file_id"] = photo.file_id text = (message.caption or "").strip() + if not text: + await message.answer("❌ Изображение должно содержать подпись с текстом вопроса") + return if len(text) > 2000: await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)") return + current_question["tg_file_id"] = photo.file_id current_question["text"] = text elif message.content_type == ContentType.TEXT and message.text: text = message.text.strip() diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index f6744a4..d237f72 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -15,6 +15,9 @@ class CreatorUsersSG(StatesGroup): class CreatorTestsSG(StatesGroup): tests_list = State() test_detail = State() + edit_password = State() + edit_group = State() + edit_expires = State() class CreatorBroadcastSG(StatesGroup): diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 9740562..c09bb8d 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -1,7 +1,10 @@ -from aiogram.types import CallbackQuery +from datetime import date, datetime + +from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window -from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, - Select) +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, Row, + ScrollingGroup, Select) from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -9,6 +12,7 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import (CreateTestSG, CreatorMenuSG, CreatorTestsSG) +from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository @@ -98,6 +102,109 @@ async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(CreatorTestsSG.tests_list) +async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.edit_password) + + +async def on_edit_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.edit_group) + + +async def on_edit_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.edit_expires) + + +@inject +async def on_password_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 + + password = message.text.strip() + if len(password) > 255: + await message.answer("❌ Пароль слишком длинный (максимум 255 символов)") + return + + await test_dao.update(test_id, password=password) + await message.answer("✅ Пароль обновлен") + await manager.switch_to(CreatorTestsSG.test_detail) + + +@inject +async def on_remove_password(_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, password=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() + + return { + "groups": [(str(g.number), str(g.number)) for g in groups], + } + + +@inject +async def on_group_selected_for_test(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, 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, for_group=int(item_id)) + await _callback.answer("✅ Группа обновлена") + await manager.switch_to(CreatorTestsSG.test_detail) + + +@inject +async def on_remove_group(_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, for_group=None) + await _callback.answer("✅ Тест теперь доступен для всех групп") + await manager.switch_to(CreatorTestsSG.test_detail) + + +@inject +async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, selected_date: date, test_dao: FromDishka[TestDAO]): + test_id = manager.dialog_data.get("selected_test_id") + if not test_id: + await _callback.answer("❌ Тест не найден") + return + + expires_at = datetime.combine(selected_date, datetime.min.time()) + await test_dao.update(test_id, expires_at=expires_at) + await _callback.answer("✅ Срок действия обновлен") + await manager.switch_to(CreatorTestsSG.test_detail) + + +@inject +async def on_remove_expires(_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, expires_at=None) + await _callback.answer("✅ Срок действия удален") + await manager.switch_to(CreatorTestsSG.test_detail) + + async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.start(CreateTestSG.input_title, mode=StartMode.RESET_STACK) @@ -130,15 +237,57 @@ tests_dialog = Dialog( ), Window( Format("{test_info}"), - Row( + Column( Button( Format("{button_text}"), id="toggle_active", on_click=on_toggle_active ), + Button(Const("🔑 Изменить пароль"), id="edit_password", on_click=on_edit_password), + 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), ), state=CreatorTestsSG.test_detail, getter=get_test_detail, ), + Window( + Const("🔑 Изменение пароля\n\n💬 Введите новый пароль или удалите текущий:\n(максимум 255 символов)"), + MessageInput(on_password_input), + Column( + Button(Const("🗑 Удалить пароль"), id="remove_password", on_click=on_remove_password), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=CreatorTestsSG.edit_password, + ), + Window( + Const("👥 Изменение группы\n\n🎓 Выберите группу или удалите привязку:"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_selected_for_test, + ), + id="groups_scroll", + width=2, + height=7, + ), + Column( + Button(Const("🗑 Для всех групп"), id="remove_group", on_click=on_remove_group), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=CreatorTestsSG.edit_group, + getter=get_groups_for_edit, + ), + Window( + Const("📅 Изменение срока действия\n\n🗓 Выберите новую дату или удалите срок:"), + Calendar(id="calendar", on_click=on_date_selected_for_test), + Column( + Button(Const("🗑 Удалить срок"), id="remove_expires", on_click=on_remove_expires), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + ), + state=CreatorTestsSG.edit_expires, + ), ) diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/trudex/infrastructure/database/dao/test.py index 88266b5..7bd0172 100644 --- a/src/trudex/infrastructure/database/dao/test.py +++ b/src/trudex/infrastructure/database/dao/test.py @@ -1,3 +1,5 @@ +from datetime import datetime + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -30,7 +32,7 @@ class TestDAO: description: str | None = None, for_group: int | None = None, password: str | None = None, - expires_at: str | None = None, + expires_at: datetime | None = None, is_active: bool = True, ) -> DomainTest: test = Test( @@ -53,7 +55,7 @@ class TestDAO: description: str | None = None, for_group: int | None = None, password: str | None = None, - expires_at: str | None = None, + expires_at: datetime | None = None, is_active: bool | None = None, ) -> DomainTest | None: result = await self.session.execute( From 9e822789d211ab78ae69a7a1d972de8774fd747a Mon Sep 17 00:00:00 2001 From: kolo Date: Fri, 2 Jan 2026 21:54:53 +0300 Subject: [PATCH 21/57] Initial commit --- alembic/versions/bec177451434_test_fix.py | 29 +++++++++++++++++++ src/trudex/domain/schemas.py | 1 + .../infrastructure/database/dao/test.py | 5 ++++ .../infrastructure/database/dto/test.py | 1 + src/trudex/infrastructure/database/models.py | 1 + 5 files changed, 37 insertions(+) create mode 100644 alembic/versions/bec177451434_test_fix.py diff --git a/alembic/versions/bec177451434_test_fix.py b/alembic/versions/bec177451434_test_fix.py new file mode 100644 index 0000000..a42ae55 --- /dev/null +++ b/alembic/versions/bec177451434_test_fix.py @@ -0,0 +1,29 @@ +"""test fix + +Revision ID: bec177451434 +Revises: a879badde4a5 +Create Date: 2026-01-02 21:54:33.772279 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'bec177451434' +down_revision: str | None = 'a879badde4a5' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tests', sa.Column('attempts', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tests', 'attempts') + # ### end Alembic commands ### diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index 40c5949..eb67188 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -31,6 +31,7 @@ class Test: for_group: int | None = None password: str | None = None expires_at: datetime | None = None + attempts: int | None = None is_active: bool = True created_at: datetime | None = None updated_at: datetime | None = None diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/trudex/infrastructure/database/dao/test.py index 7bd0172..505a90c 100644 --- a/src/trudex/infrastructure/database/dao/test.py +++ b/src/trudex/infrastructure/database/dao/test.py @@ -33,6 +33,7 @@ class TestDAO: for_group: int | None = None, password: str | None = None, expires_at: datetime | None = None, + attempts: int | None = None, is_active: bool = True, ) -> DomainTest: test = Test( @@ -41,6 +42,7 @@ class TestDAO: for_group=for_group, password=password, expires_at=expires_at, + attempts=attempts, is_active=is_active, ) self.session.add(test) @@ -56,6 +58,7 @@ class TestDAO: for_group: int | None = None, password: str | None = None, expires_at: datetime | None = None, + attempts: int | None = None, is_active: bool | None = None, ) -> DomainTest | None: result = await self.session.execute( @@ -75,6 +78,8 @@ class TestDAO: test.password = password if expires_at is not None: test.expires_at = expires_at + if attempts is not None: + test.attempts = attempts if is_active is not None: test.is_active = is_active diff --git a/src/trudex/infrastructure/database/dto/test.py b/src/trudex/infrastructure/database/dto/test.py index f3fa61e..0be7a2c 100644 --- a/src/trudex/infrastructure/database/dto/test.py +++ b/src/trudex/infrastructure/database/dto/test.py @@ -14,6 +14,7 @@ class TestDTO: for_group=self.model.for_group, password=self.model.password, expires_at=self.model.expires_at, + attempts=self.model.attempts, is_active=self.model.is_active, created_at=self.model.created_at, updated_at=self.model.updated_at, diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 123f9e6..043b135 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -59,6 +59,7 @@ class Test(Base): for_group: Mapped[int | None] = mapped_column(default=None) 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) is_active: Mapped[bool] = mapped_column(default=True) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) From 8273ede06955fe209a81731533976feaed1fb202 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 02:12:28 +0300 Subject: [PATCH 22/57] commit --- config.example.toml | 4 + main.py | 6 -- src/trudex/application/__main__.py | 4 +- .../application/bot/admin_dialogs/states.py | 2 + .../application/bot/admin_dialogs/tests.py | 95 +++++++++++++++++++ .../bot/creator_dialogs/create_test.py | 58 ++++++++--- .../application/bot/creator_dialogs/groups.py | 2 + .../application/bot/creator_dialogs/states.py | 3 + .../application/bot/creator_dialogs/tests.py | 95 +++++++++++++++++++ src/trudex/infrastructure/di.py | 4 - src/trudex/infrastructure/utils/config.py | 12 +++ .../infrastructure/utils/test_id_to_hash.py | 25 +++++ 12 files changed, 285 insertions(+), 25 deletions(-) delete mode 100644 main.py create mode 100644 src/trudex/infrastructure/utils/test_id_to_hash.py 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] From ce938fe1fc8c7e17da01b36620a8c3a32a3a1a4b Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 02:48:52 +0300 Subject: [PATCH 23/57] Initial commit --- pyproject.toml | 1 + .../application/bot/admin_dialogs/tests.py | 43 ++++++++--- .../application/bot/creator_dialogs/tests.py | 59 +++++++++++---- .../infrastructure/utils/qr_generator.py | 11 +++ uv.lock | 72 +++++++++++++++++++ 5 files changed, 160 insertions(+), 26 deletions(-) create mode 100644 src/trudex/infrastructure/utils/qr_generator.py diff --git a/pyproject.toml b/pyproject.toml index d234a4a..07ba0d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "sqlalchemy>=2.0.45", "apscheduler>=3.10.4", "pydantic>=2.10.5", + "qrcode[pil]>=8.2", ] [dependency-groups] diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 2d73fda..5010c2e 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -1,11 +1,15 @@ +import asyncio +import functools from datetime import date, datetime from aiogram import Bot -from aiogram.types import CallbackQuery, Message +from aiogram.enums import ContentType +from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, Row, ScrollingGroup, Select) +from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -16,6 +20,7 @@ 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.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import generate_alpha_id @@ -111,12 +116,15 @@ async def on_share_test(_callback: CallbackQuery, _button: Button, manager: Dial @inject -async def get_share_link(dialog_manager: DialogManager, config: FromDishka[Config], bot: FromDishka[Bot], **_kwargs): +async def get_share_data(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": "Ошибка: тест не найден"} + return { + "share_link": "Ошибка: тест не найден" + } + # Генерируем хэш и ссылку test_hash = generate_alpha_id( test_id, config.security.test_hash_salt, @@ -127,7 +135,20 @@ async def get_share_link(dialog_manager: DialogManager, config: FromDishka[Confi bot_username = bot_info.username or "your_bot" share_link = f"https://t.me/{bot_username}?start={test_hash}" - return {"share_link": share_link} + # Генерируем QR-код в отдельном потоке + loop = asyncio.get_running_loop() + qr_bytes = await loop.run_in_executor( + None, + functools.partial(generate_qr_bytes, share_link) + ) + + # Сохраняем в dialog_data для использования в media selector + dialog_manager.dialog_data["qr_bytes"] = qr_bytes + + return { + "share_link": share_link, + "qr_media": BufferedInputFile(qr_bytes, filename="qr.png") + } async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -329,13 +350,6 @@ 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), @@ -384,4 +398,11 @@ tests_dialog = Dialog( ), state=AdminTestsSG.edit_expires, ), + Window( + DynamicMedia("qr_media"), + Format("🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста"), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + state=AdminTestsSG.share_test, + getter=get_share_data, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 6742751..7ac4730 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -1,11 +1,15 @@ +import asyncio +import functools from datetime import date, datetime +import logging from aiogram import Bot -from aiogram.types import CallbackQuery, Message +from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, Row, ScrollingGroup, Select) +from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -17,6 +21,7 @@ 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.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import generate_alpha_id @@ -110,25 +115,48 @@ async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: Di async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(CreatorTestsSG.share_test) +def debug_getter(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + logging.exception(f"CRASH in getter {func.__name__}: {e}") + raise e # Пробрасываем ошибку дальше, чтобы диалог всё равно упал + return wrapper +@debug_getter @inject -async def get_share_link(dialog_manager: DialogManager, config: FromDishka[Config], **_kwargs): +async def get_share_data(dialog_manager: DialogManager, config: FromDishka[Config], bot_inst: FromDishka[Bot], **_kwargs): test_id = dialog_manager.dialog_data.get("selected_test_id") + if not test_id: - return {"share_link": "Ошибка: тест не найден"} + 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_info = await bot_inst.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} + loop = asyncio.get_running_loop() + qr_bytes = await loop.run_in_executor( + None, + functools.partial(generate_qr_bytes, share_link) + ) + + dialog_manager.dialog_data["qr_bytes"] = qr_bytes + + return { + "share_link": share_link, + "qr_media": BufferedInputFile(qr_bytes, filename="qr.png") + } async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -331,14 +359,7 @@ tests_dialog = Dialog( 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 символов)"), + Const("� Измеенение пароля\n\n� СВведите новый пароль или удалите текущий:\n(максимум 255 символов)"), MessageInput(on_password_input), Column( Button(Const("🗑 Удалить пароль"), id="remove_password", on_click=on_remove_password), @@ -385,4 +406,12 @@ tests_dialog = Dialog( ), state=CreatorTestsSG.edit_expires, ), + Window( + DynamicMedia("qr_media"), + Format("🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста"), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + state=CreatorTestsSG.share_test, + getter=get_share_data, + ), ) + diff --git a/src/trudex/infrastructure/utils/qr_generator.py b/src/trudex/infrastructure/utils/qr_generator.py new file mode 100644 index 0000000..2b48b9d --- /dev/null +++ b/src/trudex/infrastructure/utils/qr_generator.py @@ -0,0 +1,11 @@ +import io + +import qrcode + + +def generate_qr_bytes(text: str) -> bytes: + """Generate QR code as PNG bytes.""" + img = qrcode.make(text) + with io.BytesIO() as buffer: + img.save(buffer) + return buffer.getvalue() diff --git a/uv.lock b/uv.lock index 2254a7e..bb72a8d 100644 --- a/uv.lock +++ b/uv.lock @@ -214,6 +214,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "dishka" version = "1.7.2" @@ -496,6 +505,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, ] +[[package]] +name = "pillow" +version = "12.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, + { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, + { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, + { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, + { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, + { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, + { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, + { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, + { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -608,6 +661,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[package.optional-dependencies] +pil = [ + { name = "pillow" }, +] + [[package]] name = "ruff" version = "0.14.10" @@ -674,6 +744,7 @@ dependencies = [ { name = "dishka" }, { name = "httpx" }, { name = "pydantic" }, + { name = "qrcode", extra = ["pil"] }, { name = "sqlalchemy" }, ] @@ -694,6 +765,7 @@ requires-dist = [ { name = "dishka", specifier = ">=1.7.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.10.5" }, + { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, { name = "sqlalchemy", specifier = ">=2.0.45" }, ] From 30bfe55c0c246931040d8519eb03c75cbb1b37c1 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 03:04:54 +0300 Subject: [PATCH 24/57] Initial commit --- .../application/bot/admin_dialogs/tests.py | 14 +++++- .../application/bot/creator_dialogs/tests.py | 43 ++++++------------- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 5010c2e..e136982 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -147,7 +147,17 @@ async def get_share_data(dialog_manager: DialogManager, config: FromDishka[Confi return { "share_link": share_link, - "qr_media": BufferedInputFile(qr_bytes, filename="qr.png") + } + + +async def qr_media_selector(data: dict, widget, manager: DialogManager): + """Селектор для получения QR-кода из dialog_data""" + qr_bytes = manager.dialog_data.get("qr_bytes") + if not qr_bytes: + return None + return { + "type": ContentType.PHOTO, + "media": BufferedInputFile(qr_bytes, filename="qr.png") } @@ -399,8 +409,8 @@ tests_dialog = Dialog( state=AdminTestsSG.edit_expires, ), Window( - DynamicMedia("qr_media"), Format("🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста"), + DynamicMedia(selector=qr_media_selector), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), state=AdminTestsSG.share_test, getter=get_share_data, diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 7ac4730..4e37bc3 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -4,8 +4,10 @@ from datetime import date, datetime import logging from aiogram import Bot +from aiogram.enums import ContentType from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.api.entities import MediaAttachment from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, Row, ScrollingGroup, Select) @@ -112,23 +114,9 @@ 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) - -def debug_getter(func): - @functools.wraps(func) - async def wrapper(*args, **kwargs): - try: - return await func(*args, **kwargs) - except Exception as e: - logging.exception(f"CRASH in getter {func.__name__}: {e}") - raise e # Пробрасываем ошибку дальше, чтобы диалог всё равно упал - return wrapper - -@debug_getter @inject -async def get_share_data(dialog_manager: DialogManager, config: FromDishka[Config], bot_inst: FromDishka[Bot], **_kwargs): - test_id = dialog_manager.dialog_data.get("selected_test_id") +async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, config: FromDishka[Config], bot_inst: FromDishka[Bot]): + test_id = manager.dialog_data.get("selected_test_id") if not test_id: return { @@ -150,13 +138,13 @@ async def get_share_data(dialog_manager: DialogManager, config: FromDishka[Confi None, functools.partial(generate_qr_bytes, share_link) ) - - dialog_manager.dialog_data["qr_bytes"] = qr_bytes - - return { - "share_link": share_link, - "qr_media": BufferedInputFile(qr_bytes, filename="qr.png") - } + + assert _callback.message is not None + + await _callback.message.answer_photo( + photo=BufferedInputFile(qr_bytes, filename="qr.png"), + caption=f"🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста" + ) async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -405,13 +393,6 @@ tests_dialog = Dialog( Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), ), state=CreatorTestsSG.edit_expires, - ), - Window( - DynamicMedia("qr_media"), - Format("🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста"), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), - state=CreatorTestsSG.share_test, - getter=get_share_data, - ), + ) ) From 1a8027167cb2177665e8c9d2a5e0b36f3772b86a Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 03:19:35 +0300 Subject: [PATCH 25/57] Initial commit --- src/trudex/infrastructure/utils/qr_generator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/trudex/infrastructure/utils/qr_generator.py b/src/trudex/infrastructure/utils/qr_generator.py index 2b48b9d..4c625c2 100644 --- a/src/trudex/infrastructure/utils/qr_generator.py +++ b/src/trudex/infrastructure/utils/qr_generator.py @@ -4,7 +4,6 @@ import qrcode def generate_qr_bytes(text: str) -> bytes: - """Generate QR code as PNG bytes.""" img = qrcode.make(text) with io.BytesIO() as buffer: img.save(buffer) From 40255fc6d4441d2f1306e0f7859bf3e4cf107c24 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 14:46:18 +0300 Subject: [PATCH 26/57] commit --- .../application/bot/admin_dialogs/states.py | 1 + .../application/bot/admin_dialogs/tests.py | 50 ++++++++++++------ .../application/bot/creator_dialogs/states.py | 1 + .../application/bot/creator_dialogs/tests.py | 52 +++++++++++++------ 4 files changed, 73 insertions(+), 31 deletions(-) diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 9ce9066..b55d1ae 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -15,6 +15,7 @@ class AdminTestsSG(StatesGroup): tests_list = State() test_detail = State() share_test = State() + edit_menu = State() edit_password = State() edit_attempts = State() edit_group = State() diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index e136982..6771860 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -66,19 +66,19 @@ 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 "♾️ Без срока" + expires_str = f"📅 {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 "👥 Для всех" test_info = ( f"📝 Информация о тесте\n\n" - f"Название: {test.title}\n" - f"Описание: {test.description or '—'}\n\n" + f"Название:\n
{test.title}
\n" + f"Описание:\n
{test.description or '—'}
\n\n" 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"Пароль: {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 '—'}" ) @@ -161,6 +161,18 @@ async def qr_media_selector(data: dict, widget, manager: DialogManager): } +async def on_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.edit_menu) + + +async def on_back_to_detail(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.test_detail) + + +async def on_back_to_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.edit_menu) + + async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(AdminTestsSG.edit_password) @@ -351,21 +363,29 @@ tests_dialog = Dialog( 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="edit_menu", on_click=on_edit_menu), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), ), state=AdminTestsSG.test_detail, getter=get_test_detail, ), + Window( + Const("✏️ Изменить тест\n\nВыберите, что хотите изменить:"), + 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_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), + ), + state=AdminTestsSG.edit_menu, + ), Window( Const("🔑 Изменение пароля\n\n💬 Введите новый пароль или удалите текущий:\n(максимум 255 символов)"), MessageInput(on_password_input), Column( Button(Const("🗑 Удалить пароль"), id="remove_password", on_click=on_remove_password), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), state=AdminTestsSG.edit_password, ), @@ -374,7 +394,7 @@ tests_dialog = Dialog( 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), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), state=AdminTestsSG.edit_attempts, ), @@ -394,7 +414,7 @@ tests_dialog = Dialog( ), Column( Button(Const("🗑 Для всех групп"), id="remove_group", on_click=on_remove_group), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), state=AdminTestsSG.edit_group, getter=get_groups_for_edit, @@ -404,7 +424,7 @@ tests_dialog = Dialog( Calendar(id="calendar", on_click=on_date_selected_for_test), Column( Button(Const("🗑 Удалить срок"), id="remove_expires", on_click=on_remove_expires), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), state=AdminTestsSG.edit_expires, ), diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 071c43d..2d5fbe0 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -16,6 +16,7 @@ class CreatorTestsSG(StatesGroup): tests_list = State() test_detail = State() share_test = State() + edit_menu = State() edit_password = State() edit_attempts = State() edit_group = State() diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 4e37bc3..e8c9f9b 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -69,19 +69,19 @@ 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 "♾️ Без срока" + expires_str = f"📅 {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 "👥 Для всех" test_info = ( f"📝 Информация о тесте\n\n" - f"Название: {test.title}\n" - f"Описание: {test.description or '—'}\n\n" + f"Название:\n
{test.title}
\n" + f"Описание:\n
{test.description or '—'}
\n\n" 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"Пароль: {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 '—'}" ) @@ -147,6 +147,18 @@ async def on_share_test(_callback: CallbackQuery, _button: Button, manager: Dial ) +async def on_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.edit_menu) + + +async def on_back_to_detail(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.test_detail) + + +async def on_back_to_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.edit_menu) + + async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(CreatorTestsSG.edit_password) @@ -337,21 +349,29 @@ tests_dialog = Dialog( 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="edit_menu", on_click=on_edit_menu), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), ), state=CreatorTestsSG.test_detail, getter=get_test_detail, ), Window( - Const("� Измеенение пароля\n\n� СВведите новый пароль или удалите текущий:\n(максимум 255 символов)"), + Const("✏️ Изменить тест\n\nВыберите, что хотите изменить:"), + 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_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), + ), + state=CreatorTestsSG.edit_menu, + ), + Window( + Const("🔑 Изменение пароля\n\n💬 Введите новый пароль или удалите текущий:\n(максимум 255 символов)"), MessageInput(on_password_input), Column( Button(Const("🗑 Удалить пароль"), id="remove_password", on_click=on_remove_password), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), state=CreatorTestsSG.edit_password, ), @@ -360,7 +380,7 @@ tests_dialog = Dialog( 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), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), state=CreatorTestsSG.edit_attempts, ), @@ -380,7 +400,7 @@ tests_dialog = Dialog( ), Column( Button(Const("🗑 Для всех групп"), id="remove_group", on_click=on_remove_group), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), state=CreatorTestsSG.edit_group, getter=get_groups_for_edit, @@ -390,7 +410,7 @@ tests_dialog = Dialog( Calendar(id="calendar", on_click=on_date_selected_for_test), Column( Button(Const("🗑 Удалить срок"), id="remove_expires", on_click=on_remove_expires), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), state=CreatorTestsSG.edit_expires, ) From 1009845d31bde7264be5ba779873cac6ac92957e Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 15:19:55 +0300 Subject: [PATCH 27/57] commit --- pyproject.toml | 1 + .../bot/admin_dialogs/broadcast.py | 2 +- .../application/bot/admin_dialogs/tests.py | 58 +++++--------- .../bot/creator_dialogs/broadcast.py | 9 ++- .../application/bot/creator_dialogs/tests.py | 13 +++- src/trudex/infrastructure/utils/config.py | 8 +- .../infrastructure/utils/test_id_to_hash.py | 77 +++++++++++++++---- uv.lock | 32 ++++++++ 8 files changed, 131 insertions(+), 69 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 07ba0d3..cd614cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "apscheduler>=3.10.4", "pydantic>=2.10.5", "qrcode[pil]>=8.2", + "pycryptodome>=3.23.0", ] [dependency-groups] diff --git a/src/trudex/application/bot/admin_dialogs/broadcast.py b/src/trudex/application/bot/admin_dialogs/broadcast.py index 937ccfd..7f49806 100644 --- a/src/trudex/application/bot/admin_dialogs/broadcast.py +++ b/src/trudex/application/bot/admin_dialogs/broadcast.py @@ -49,7 +49,7 @@ async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manage async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): await _callback.answer("Рассылка отменена") - await manager.done() + await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 6771860..2c07051 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -3,13 +3,11 @@ import functools from datetime import date, datetime from aiogram import Bot -from aiogram.enums import ContentType from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, Row, +from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, ScrollingGroup, Select) -from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -21,7 +19,7 @@ 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.qr_generator import generate_qr_bytes -from trudex.infrastructure.utils.test_id_to_hash import generate_alpha_id +from trudex.infrastructure.utils.test_id_to_hash import encode_id @inject @@ -111,54 +109,40 @@ 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) +async def on_statistics(_callback: CallbackQuery, _button: Button, _manager: DialogManager): + await _callback.answer("🚧 В разработке") @inject -async def get_share_data(dialog_manager: DialogManager, config: FromDishka[Config], bot: FromDishka[Bot], **_kwargs): - test_id = dialog_manager.dialog_data.get("selected_test_id") +async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, config: FromDishka[Config], bot_inst: FromDishka[Bot]): + test_id = manager.dialog_data.get("selected_test_id") if not test_id: - return { - "share_link": "Ошибка: тест не найден" - } + await _callback.answer("Ошибка: тест не найден") + return - # Генерируем хэш и ссылку - test_hash = generate_alpha_id( + test_hash = encode_id( test_id, - config.security.test_hash_salt, - config.security.test_hash_length + config.security.encode_key, + config.security.encoded_string_length ) - bot_info = await bot.get_me() + bot_info = await bot_inst.get_me() bot_username = bot_info.username or "your_bot" share_link = f"https://t.me/{bot_username}?start={test_hash}" - # Генерируем QR-код в отдельном потоке loop = asyncio.get_running_loop() qr_bytes = await loop.run_in_executor( None, functools.partial(generate_qr_bytes, share_link) ) - - # Сохраняем в dialog_data для использования в media selector - dialog_manager.dialog_data["qr_bytes"] = qr_bytes - - return { - "share_link": share_link, - } + assert _callback.message is not None -async def qr_media_selector(data: dict, widget, manager: DialogManager): - """Селектор для получения QR-кода из dialog_data""" - qr_bytes = manager.dialog_data.get("qr_bytes") - if not qr_bytes: - return None - return { - "type": ContentType.PHOTO, - "media": BufferedInputFile(qr_bytes, filename="qr.png") - } + await _callback.message.answer_photo( + photo=BufferedInputFile(qr_bytes, filename="qr.png"), + caption=f"🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста" + ) async def on_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -362,6 +346,7 @@ tests_dialog = Dialog( id="toggle_active", on_click=on_toggle_active ), + Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics), Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), @@ -428,11 +413,4 @@ tests_dialog = Dialog( ), state=AdminTestsSG.edit_expires, ), - Window( - Format("🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста"), - DynamicMedia(selector=qr_media_selector), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), - state=AdminTestsSG.share_test, - getter=get_share_data, - ), ) diff --git a/src/trudex/application/bot/creator_dialogs/broadcast.py b/src/trudex/application/bot/creator_dialogs/broadcast.py index 5663b79..52878fc 100644 --- a/src/trudex/application/bot/creator_dialogs/broadcast.py +++ b/src/trudex/application/bot/creator_dialogs/broadcast.py @@ -1,12 +1,13 @@ from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, Window +from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Cancel, Row +from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreatorBroadcastSG +from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, + CreatorMenuSG) from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.broadcast import broadcast_message @@ -48,7 +49,7 @@ async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manage async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): await _callback.answer("Рассылка отменена") - await manager.done() + await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index e8c9f9b..914fecc 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -24,7 +24,7 @@ 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.qr_generator import generate_qr_bytes -from trudex.infrastructure.utils.test_id_to_hash import generate_alpha_id +from trudex.infrastructure.utils.test_id_to_hash import encode_id @inject @@ -114,6 +114,10 @@ async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(CreatorTestsSG.tests_list) +async def on_statistics(_callback: CallbackQuery, _button: Button, _manager: DialogManager): + await _callback.answer("🚧 В разработке") + + @inject async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, config: FromDishka[Config], bot_inst: FromDishka[Bot]): test_id = manager.dialog_data.get("selected_test_id") @@ -123,10 +127,10 @@ async def on_share_test(_callback: CallbackQuery, _button: Button, manager: Dial "share_link": "Ошибка: тест не найден" } - test_hash = generate_alpha_id( + test_hash = encode_id( test_id, - config.security.test_hash_salt, - config.security.test_hash_length + config.security.encode_key, + config.security.encoded_string_length ) bot_info = await bot_inst.get_me() @@ -348,6 +352,7 @@ tests_dialog = Dialog( id="toggle_active", on_click=on_toggle_active ), + Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics), Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), diff --git a/src/trudex/infrastructure/utils/config.py b/src/trudex/infrastructure/utils/config.py index 7fb8aa8..447882e 100644 --- a/src/trudex/infrastructure/utils/config.py +++ b/src/trudex/infrastructure/utils/config.py @@ -12,8 +12,8 @@ class BotConfig: @dataclass class SecurityConfig: - test_hash_salt: str - test_hash_length: int = 16 + encode_key: str + encoded_string_length: int = 8 @dataclass @@ -57,7 +57,7 @@ class Config: 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)) + encode_key=str(security_data["encode_key"]), + encoded_string_length=int(security_data.get("encoded_string_length", 8)) ) ) diff --git a/src/trudex/infrastructure/utils/test_id_to_hash.py b/src/trudex/infrastructure/utils/test_id_to_hash.py index 41ac700..2d9b035 100644 --- a/src/trudex/infrastructure/utils/test_id_to_hash.py +++ b/src/trudex/infrastructure/utils/test_id_to_hash.py @@ -1,25 +1,70 @@ -import hashlib import hmac +import hashlib 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') +ALPHABET = string.ascii_letters + +def _feistel_round(val: int, key: bytes, rounds: int) -> int: + msg = f"{val}:{rounds}".encode() + h = hmac.new(key, msg, hashlib.sha256).digest() + return int.from_bytes(h[:4], 'big') + +def permute_id(n: int, key_str: str, bits: int) -> int: + key = key_str.encode() + split = bits // 2 + mask = (1 << split) - 1 - digest = hmac.new(key, data, hashlib.sha256).digest() - num = int.from_bytes(digest, byteorder='big') + left = (n >> split) & mask + right = n & mask - alphabet = string.ascii_letters - result = [] - - while num > 0: - num, rem = divmod(num, 52) - result.append(alphabet[rem]) + for i in range(6): + new_left = right + f_val = _feistel_round(right, key, i) + new_right = left ^ (f_val & mask) + left, right = new_left, new_right - encoded = "".join(result) + return (left << split) | right + +def unpermute_id(n: int, key_str: str, bits: int) -> int: + key = key_str.encode() + split = bits // 2 + mask = (1 << split) - 1 - if len(encoded) < length: - encoded = encoded.ljust(length, alphabet[0]) + left = (n >> split) & mask + right = n & mask + + for i in reversed(range(6)): + new_right = left + f_val = _feistel_round(left, key, i) + new_left = right ^ (f_val & mask) + left, right = new_left, new_right - return encoded[:length] + return (left << split) | right + + +def encode_id(n: int, key: str, length: int = 8) -> str: + bits = length * 5 + if length >= 8: bits = 44 + elif length == 7: bits = 38 + + permuted = permute_id(n, key, bits=bits) + + chars = [] + for _ in range(length): + permuted, rem = divmod(permuted, 52) + chars.append(ALPHABET[rem]) + + return "".join(chars) + +def decode_id(s: str, key: str) -> int: + num = 0 + for char in reversed(s): + num = num * 52 + ALPHABET.index(char) + + length = len(s) + bits = length * 5 + if length >= 8: bits = 44 + elif length == 7: bits = 38 + + return unpermute_id(num, key, bits=bits) diff --git a/uv.lock b/uv.lock index bb72a8d..c3874ba 100644 --- a/uv.lock +++ b/uv.lock @@ -603,6 +603,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -743,6 +773,7 @@ dependencies = [ { name = "asyncpg" }, { name = "dishka" }, { name = "httpx" }, + { name = "pycryptodome" }, { name = "pydantic" }, { name = "qrcode", extra = ["pil"] }, { name = "sqlalchemy" }, @@ -764,6 +795,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "dishka", specifier = ">=1.7.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, { name = "pydantic", specifier = ">=2.10.5" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, { name = "sqlalchemy", specifier = ">=2.0.45" }, From 307995e49166d7ccb4e0e48423757f7244621778 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 15:29:47 +0300 Subject: [PATCH 28/57] commit --- ...2f2b802ec_add_name_and_group_updated_at.py | 31 +++ .../application/bot/user_dialogs/main_menu.py | 180 +++++++++++++++++- .../application/bot/user_dialogs/states.py | 2 + src/trudex/domain/schemas.py | 2 + .../database/dao/test_attempt.py | 7 + .../infrastructure/database/dao/user.py | 8 + .../infrastructure/database/dto/user.py | 2 + src/trudex/infrastructure/database/models.py | 2 + .../database/repo/test_attempt.py | 16 ++ 9 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 alembic/versions/e002f2b802ec_add_name_and_group_updated_at.py diff --git a/alembic/versions/e002f2b802ec_add_name_and_group_updated_at.py b/alembic/versions/e002f2b802ec_add_name_and_group_updated_at.py new file mode 100644 index 0000000..cc5b5f6 --- /dev/null +++ b/alembic/versions/e002f2b802ec_add_name_and_group_updated_at.py @@ -0,0 +1,31 @@ +"""add_name_and_group_updated_at + +Revision ID: e002f2b802ec +Revises: bec177451434 +Create Date: 2026-01-03 15:28:05.112132 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'e002f2b802ec' +down_revision: str | None = 'bec177451434' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('name_updated_at', sa.DateTime(), nullable=True)) + op.add_column('users', sa.Column('group_updated_at', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'group_updated_at') + op.drop_column('users', 'name_updated_at') + # ### end Alembic commands ### diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index 9779efa..b6fe9a7 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -1,26 +1,190 @@ -from aiogram.types import CallbackQuery +from datetime import datetime, timedelta + +from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Column -from aiogram_dialog.widgets.text import Const +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.user_dialogs.states import UserMenuSG +from trudex.infrastructure.database.dao.group import GroupDAO +from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("Доступные тесты") +@inject +async def get_user_data( + dialog_manager: DialogManager, + user_dao: FromDishka[UserDAO], + attempt_repo: FromDishka[TestAttemptRepository], + **_kwargs +): + user_id = dialog_manager.event.from_user.id + user = await user_dao.get_by_id(user_id) + stats = await attempt_repo.get_user_stats(user_id) + + if not user: + return {"user_info": "❌ Пользователь не найден"} + + name = user.name or user.first_name + group_str = f"🎓 Группа {user.group}" if user.group else "👤 Группа не указана" + + if stats["total_attempts"] > 0: + accuracy_str = f"📊 Средняя точность: {stats['avg_score']}%" + tests_str = f"📝 Пройдено тестов: {stats['total_attempts']}" + else: + accuracy_str = "📊 Средняя точность: " + tests_str = "📝 Пройдено тестов: 0" + + user_info = ( + f"👋 Привет, {name}!\n\n" + f"
{group_str}
\n\n" + f"{tests_str}\n" + f"{accuracy_str}" + ) + + return {"user_info": user_info} -async def on_results_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("Мои результаты") +def can_edit_field(updated_at: datetime | None) -> bool: + if updated_at is None: + return True + return datetime.utcnow() - updated_at >= timedelta(hours=24) + + +def get_remaining_time(updated_at: datetime) -> str: + remaining = timedelta(hours=24) - (datetime.utcnow() - updated_at) + hours = int(remaining.total_seconds() // 3600) + minutes = int((remaining.total_seconds() % 3600) // 60) + return f"{hours}ч {minutes}м" + + +@inject +async def on_edit_name_clicked( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + user_dao: FromDishka[UserDAO] +): + user = await user_dao.get_by_id(_callback.from_user.id) + if not user: + await _callback.answer("❌ Пользователь не найден") + return + + if not can_edit_field(user.name_updated_at): + remaining = get_remaining_time(user.name_updated_at) + await _callback.answer(f"⏳ Изменить можно через {remaining}") + return + + await manager.switch_to(UserMenuSG.edit_name) + + +@inject +async def on_edit_group_clicked( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + user_dao: FromDishka[UserDAO] +): + user = await user_dao.get_by_id(_callback.from_user.id) + if not user: + await _callback.answer("❌ Пользователь не найден") + return + + if not can_edit_field(user.group_updated_at): + remaining = get_remaining_time(user.group_updated_at) + await _callback.answer(f"⏳ Изменить можно через {remaining}") + return + + await manager.switch_to(UserMenuSG.edit_group) + + +async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): + await _callback.answer("🚧 В разработке") + + +async def on_results_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): + await _callback.answer("🚧 В разработке") + + +async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(UserMenuSG.main) + + +@inject +async def on_name_input( + message: Message, + _widget: MessageInput, + manager: DialogManager, + user_dao: FromDishka[UserDAO] +): + if not message.text or len(message.text.strip()) < 2: + await message.answer("❌ Имя должно содержать минимум 2 символа") + return + + name = message.text.strip()[:128] + await user_dao.update(message.from_user.id, name=name, name_updated_at=datetime.utcnow()) + await message.answer("✅ Имя обновлено") + await manager.switch_to(UserMenuSG.main) + + +@inject +async def get_groups_data(group_dao: FromDishka[GroupDAO], **_kwargs): + groups = await group_dao.get_all() + return {"groups": [(str(g.number), str(g.number)) for g in groups]} + + +@inject +async def on_group_selected( + _callback: CallbackQuery, + _widget, + manager: DialogManager, + item_id: str, + user_dao: FromDishka[UserDAO] +): + await user_dao.update(_callback.from_user.id, group=int(item_id), group_updated_at=datetime.utcnow()) + await _callback.answer("✅ Группа обновлена") + await manager.switch_to(UserMenuSG.main) user_menu_dialog = Dialog( Window( - Const("📚 Главное меню\n\nВыберите раздел:"), + Format("{user_info}"), Column( Button(Const("📝 Доступные тесты"), id="tests", on_click=on_tests_clicked), Button(Const("📊 Мои результаты"), id="results", on_click=on_results_clicked), ), + Row( + Button(Const("✏️ Имя"), id="edit_name", on_click=on_edit_name_clicked), + Button(Const("🎓 Группа"), id="edit_group", on_click=on_edit_group_clicked), + ), state=UserMenuSG.main, + getter=get_user_data, + ), + Window( + Const("✏️ Изменение имени\n\nВведите новое имя:"), + MessageInput(on_name_input), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), + state=UserMenuSG.edit_name, + ), + Window( + Const("🎓 Изменение группы\n\nВыберите группу:"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_selected, + ), + id="groups_scroll", + width=2, + height=7, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), + state=UserMenuSG.edit_group, + getter=get_groups_data, ), ) diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/trudex/application/bot/user_dialogs/states.py index efb7e10..dfe9a07 100644 --- a/src/trudex/application/bot/user_dialogs/states.py +++ b/src/trudex/application/bot/user_dialogs/states.py @@ -3,6 +3,8 @@ from aiogram.fsm.state import State, StatesGroup class UserMenuSG(StatesGroup): main = State() + edit_name = State() + edit_group = State() class UserRegistrationSG(StatesGroup): diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index eb67188..a4b1515 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -11,6 +11,8 @@ class User: name: str | None = None group: int | None = None is_admin: bool = False + name_updated_at: datetime | None = None + group_updated_at: datetime | None = None created_at: datetime | None = None updated_at: datetime | None = None diff --git a/src/trudex/infrastructure/database/dao/test_attempt.py b/src/trudex/infrastructure/database/dao/test_attempt.py index 551c283..82d44a0 100644 --- a/src/trudex/infrastructure/database/dao/test_attempt.py +++ b/src/trudex/infrastructure/database/dao/test_attempt.py @@ -78,3 +78,10 @@ class TestAttemptDAO: await self.session.delete(attempt) await self.session.flush() return True + + async def get_by_user_id(self, user_id: int) -> list[DomainTestAttempt]: + result = await self.session.execute( + select(TestAttempt).where(TestAttempt.user_id == user_id) + ) + models = list(result.scalars().all()) + return [TestAttemptDTO(model).to_domain() for model in models] diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index a515a8d..c4144b8 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -1,3 +1,5 @@ +from datetime import datetime + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -55,6 +57,8 @@ class UserDAO: name: str | None = None, group: int | None = None, is_admin: bool | None = None, + name_updated_at: datetime | None = None, + group_updated_at: datetime | None = None, ) -> DomainUser | None: result = await self.session.execute( select(User).where(User.id == user_id) @@ -75,6 +79,10 @@ class UserDAO: user.group = group if is_admin is not None: user.is_admin = is_admin + if name_updated_at is not None: + user.name_updated_at = name_updated_at + if group_updated_at is not None: + user.group_updated_at = group_updated_at await self.session.flush() await self.session.refresh(user) diff --git a/src/trudex/infrastructure/database/dto/user.py b/src/trudex/infrastructure/database/dto/user.py index 4354ed6..cb357e1 100644 --- a/src/trudex/infrastructure/database/dto/user.py +++ b/src/trudex/infrastructure/database/dto/user.py @@ -15,6 +15,8 @@ class UserDTO: name=self.model.name, group=self.model.group, is_admin=self.model.is_admin, + name_updated_at=self.model.name_updated_at, + group_updated_at=self.model.group_updated_at, created_at=self.model.created_at, updated_at=self.model.updated_at, ) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 043b135..04914ae 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -22,6 +22,8 @@ class User(Base): name: Mapped[str | None] = mapped_column(String(128)) group: Mapped[int | None] = mapped_column(CheckConstraint("group >= 1000 AND group <= 9999")) is_admin: Mapped[bool] = mapped_column(default=False) + name_updated_at: Mapped[datetime | None] = mapped_column(default=None) + group_updated_at: Mapped[datetime | None] = mapped_column(default=None) created_at: Mapped[datetime] = mapped_column(server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index 476cb23..1938f0c 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -195,3 +195,19 @@ class TestAttemptRepository: rows = result.all() return [(row.question_id, row.correct / row.total if row.total > 0 else 0.0) for row in rows] + + async def get_user_stats(self, user_id: int) -> dict: + result = await self.session.execute( + select( + func.count(TestAttemptModel.id).label("total_attempts"), + func.avg(TestAttemptModel.score).label("avg_score"), + ).where( + TestAttemptModel.user_id == user_id, + TestAttemptModel.finished_at.isnot(None) + ) + ) + row = result.one() + return { + "total_attempts": row.total_attempts or 0, + "avg_score": round(row.avg_score, 1) if row.avg_score else 0, + } From 94ca600d3a5b8c577d7e013db8756686a665cd35 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 16:21:08 +0300 Subject: [PATCH 29/57] Initial commit --- .../application/bot/user_dialogs/main_menu.py | 48 ++++++++++++++++++- .../application/bot/user_dialogs/states.py | 1 + .../infrastructure/database/repo/test.py | 32 +++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index b6fe9a7..4254bed 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -11,6 +11,7 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.user_dialogs.states import UserMenuSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository @@ -101,8 +102,8 @@ async def on_edit_group_clicked( await manager.switch_to(UserMenuSG.edit_group) -async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): - await _callback.answer("🚧 В разработке") +async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(UserMenuSG.available_tests) async def on_results_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): @@ -149,6 +150,31 @@ async def on_group_selected( await manager.switch_to(UserMenuSG.main) +@inject +async def get_available_tests( + dialog_manager: DialogManager, + user_dao: FromDishka[UserDAO], + test_repo: FromDishka[TestRepository], + **_kwargs +): + user_id = dialog_manager.event.from_user.id + user = await user_dao.get_by_id(user_id) + + if not user: + return {"tests": [], "count": 0} + + tests = await test_repo.get_available_tests_for_user(user_id, user.group) + + return { + "tests": [(f"📝 {t.title}", t.id) for t in tests], + "count": len(tests), + } + + +async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + await _callback.answer("🚧 В разработке") + + user_menu_dialog = Dialog( Window( Format("{user_info}"), @@ -163,6 +189,24 @@ user_menu_dialog = Dialog( state=UserMenuSG.main, getter=get_user_data, ), + Window( + Format("📝 Доступные тесты\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="test_select", + item_id_getter=lambda x: x[1], + items="tests", + on_click=on_test_selected, + ), + id="tests_scroll", + width=1, + height=7, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), + state=UserMenuSG.available_tests, + getter=get_available_tests, + ), Window( Const("✏️ Изменение имени\n\nВведите новое имя:"), MessageInput(on_name_input), diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/trudex/application/bot/user_dialogs/states.py index dfe9a07..d34d148 100644 --- a/src/trudex/application/bot/user_dialogs/states.py +++ b/src/trudex/application/bot/user_dialogs/states.py @@ -3,6 +3,7 @@ from aiogram.fsm.state import State, StatesGroup class UserMenuSG(StatesGroup): main = State() + available_tests = State() edit_name = State() edit_group = State() diff --git a/src/trudex/infrastructure/database/repo/test.py b/src/trudex/infrastructure/database/repo/test.py index fba422b..4597f64 100644 --- a/src/trudex/infrastructure/database/repo/test.py +++ b/src/trudex/infrastructure/database/repo/test.py @@ -152,3 +152,35 @@ class TestRepository: ) return new_test + + async def get_available_tests_for_user(self, user_id: int, user_group: int | None) -> list[Test]: + from trudex.infrastructure.database.models import TestAttempt + + subquery = ( + select( + TestAttempt.test_id, + func.count(TestAttempt.id).label("attempts_count") + ) + .where(TestAttempt.user_id == user_id) + .where(TestAttempt.finished_at.isnot(None)) + .group_by(TestAttempt.test_id) + .subquery() + ) + + query = ( + select(TestModel) + .outerjoin(subquery, TestModel.id == subquery.c.test_id) + .where(TestModel.is_active == True) + .where( + (TestModel.for_group == user_group) | (TestModel.for_group.is_(None)) + ) + .where( + (TestModel.attempts.is_(None)) | + (subquery.c.attempts_count.is_(None)) | + (subquery.c.attempts_count < TestModel.attempts) + ) + ) + + result = await self.session.execute(query) + models = list(result.scalars().all()) + return [TestDTO(model).to_domain() for model in models] From 39c99c416578a64bc88f7c5a8394bc990b88402b Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 16:38:00 +0300 Subject: [PATCH 30/57] Initial commit --- src/trudex/application/__main__.py | 14 ++++++++++-- src/trudex/infrastructure/di.py | 22 ++++++++++++++++++- src/trudex/infrastructure/scheduling/tasks.py | 17 ++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 src/trudex/infrastructure/scheduling/tasks.py diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 8ed7b1f..62674a8 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -5,6 +5,7 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode from aiogram_dialog import setup_dialogs +from apscheduler.schedulers.asyncio import AsyncIOScheduler from dishka import make_async_container from dishka.integrations.aiogram import setup_dishka @@ -38,7 +39,7 @@ from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog from trudex.application.bot.user_dialogs.registration import \ registration_dialog from trudex.infrastructure.database.repo.user import UserRepository -from trudex.infrastructure.di import DatabaseProvider +from trudex.infrastructure.di import DatabaseProvider, SchedulerProvider from trudex.infrastructure.utils.bot_commands import setup_bot_commands from trudex.infrastructure.utils.config import Config @@ -78,7 +79,11 @@ async def main() -> None: router.message.middleware(RejectNotAdminMiddleware()) router.message.middleware(RejectNotCreatorMiddleware()) - container = make_async_container(DatabaseProvider(), context={Bot: bot, Config: config}) + container = make_async_container( + DatabaseProvider(), + SchedulerProvider(), + context={Bot: bot, Config: config} + ) setup_dialogs(dp) setup_dishka(container, dp, auto_inject=True) @@ -88,13 +93,18 @@ async def main() -> None: user_repo = await request_container.get(UserRepository) await setup_bot_commands(bot, config, user_repo) + scheduler = await container.get(AsyncIOScheduler) + scheduler.start() + await bot.delete_webhook(drop_pending_updates=True) logging.info("Бот запущен") + logging.info("Планировщик задач запущен") try: await dp.start_polling(bot) finally: + scheduler.shutdown() await bot.session.close() diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index a9c3e20..fa4b406 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -1,6 +1,8 @@ from collections.abc import AsyncIterable +import logging -from dishka import Provider, Scope, provide +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from dishka import AsyncContainer, Provider, Scope, provide from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from trudex.infrastructure.database.config import new_session_maker @@ -15,6 +17,7 @@ from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import \ TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository +from trudex.infrastructure.scheduling.tasks import deactivate_expired_tests from trudex.infrastructure.utils.config import Config @@ -70,3 +73,20 @@ class DatabaseProvider(Provider): @provide(scope=Scope.REQUEST) def get_test_attempt_repository(self, session: AsyncSession) -> TestAttemptRepository: return TestAttemptRepository(session) + + +class SchedulerProvider(Provider): + @provide(scope = Scope.APP) + def get_scheduler(self, container: AsyncContainer) -> AsyncIOScheduler: + logging.getLogger('apscheduler').setLevel(logging.WARNING) + scheduler = AsyncIOScheduler() + + scheduler.add_job( + deactivate_expired_tests, + 'interval', + minutes=5, + args=[container], + id='deactivate_expired_tests', + ) + + return scheduler diff --git a/src/trudex/infrastructure/scheduling/tasks.py b/src/trudex/infrastructure/scheduling/tasks.py new file mode 100644 index 0000000..baefebf --- /dev/null +++ b/src/trudex/infrastructure/scheduling/tasks.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from dishka import AsyncContainer + +from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.models import Test + + +async def deactivate_expired_tests(container: AsyncContainer): + async with container() as request_container: + test_dao = await request_container.get(TestDAO) + + tests = await test_dao.get_all() + + for test in tests: + if test.expires_at and test.expires_at < datetime.utcnow() and test.is_active: + await test_dao.update(test.id, is_active=False) From a82fb437d508d7147f32205d11f6d2f7ba8f6426 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 17:16:30 +0300 Subject: [PATCH 31/57] commit --- src/trudex/application/__main__.py | 2 + .../application/bot/user_dialogs/main_menu.py | 58 +- .../application/bot/user_dialogs/states.py | 10 + .../application/bot/user_dialogs/take_test.py | 501 ++++++++++++++++++ 4 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 src/trudex/application/bot/user_dialogs/take_test.py diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 62674a8..e6ada8d 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -38,6 +38,7 @@ from trudex.application.bot.middlewares.reject_not_creator import \ from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog from trudex.application.bot.user_dialogs.registration import \ registration_dialog +from trudex.application.bot.user_dialogs.take_test import take_test_dialog from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.di import DatabaseProvider, SchedulerProvider from trudex.infrastructure.utils.bot_commands import setup_bot_commands @@ -62,6 +63,7 @@ async def main() -> None: dp.include_routers( router, user_menu_dialog, + take_test_dialog, registration_dialog, admin_menu_dialog, admin_users_dialog, diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index 4254bed..de777ff 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -9,6 +9,7 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.user_dialogs.states import UserMenuSG +from trudex.application.bot.user_dialogs.take_test import on_start_test from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.repo.test import TestRepository @@ -172,7 +173,53 @@ async def get_available_tests( async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): - await _callback.answer("🚧 В разработке") + manager.dialog_data["selected_test_id"] = int(item_id) + await manager.switch_to(UserMenuSG.test_detail) + + +async def on_back_to_tests(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(UserMenuSG.available_tests) + + +@inject +async def get_test_detail( + dialog_manager: DialogManager, + test_repo: FromDishka[TestRepository], + attempt_repo: FromDishka[TestAttemptRepository], + user_dao: FromDishka[UserDAO], + **_kwargs +): + test_id = dialog_manager.dialog_data.get("selected_test_id") + user_id = dialog_manager.event.from_user.id + + if not test_id: + return {"test_info": "❌ Тест не найден"} + + test, questions = await test_repo.get_test_with_questions(test_id) + + if not test: + return {"test_info": "❌ Тест не найден"} + + user = await user_dao.get_by_id(user_id) + attempts = await attempt_repo.get_user_test_attempts(user_id, test_id) + finished_attempts = [a for a in attempts if a.finished_at] + + password_str = f"🔒 Требуется пароль" if test.password else "🔓 Без пароля" + attempts_str = f"🔄 Попыток: {len(finished_attempts)}/{test.attempts}" if test.attempts else f"🔄 Попыток: {len(finished_attempts)}/♾️" + expires_str = f"📅 До {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 "👥 Для всех" + + test_info = ( + f"📝 {test.title}\n\n" + f"
{test.description or '—'}
\n\n" + f"Вопросов: {len(questions)}\n" + f"{password_str}\n" + f"{attempts_str}\n" + f"{expires_str}\n" + f"{group_str}" + ) + + return {"test_info": test_info} user_menu_dialog = Dialog( @@ -207,6 +254,15 @@ user_menu_dialog = Dialog( state=UserMenuSG.available_tests, getter=get_available_tests, ), + Window( + Format("{test_info}"), + Column( + Button(Const("▶️ Пройти тест"), id="start_test", on_click=on_start_test), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_tests), + ), + state=UserMenuSG.test_detail, + getter=get_test_detail, + ), Window( Const("✏️ Изменение имени\n\nВведите новое имя:"), MessageInput(on_name_input), diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/trudex/application/bot/user_dialogs/states.py index d34d148..b28c737 100644 --- a/src/trudex/application/bot/user_dialogs/states.py +++ b/src/trudex/application/bot/user_dialogs/states.py @@ -4,10 +4,20 @@ from aiogram.fsm.state import State, StatesGroup class UserMenuSG(StatesGroup): main = State() available_tests = State() + test_detail = State() edit_name = State() edit_group = State() +class UserTestSG(StatesGroup): + password_input = State() + question_single = State() + question_multiple = State() + question_input = State() + results = State() + detailed_results = State() + + class UserRegistrationSG(StatesGroup): input_name = State() select_group = State() diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py new file mode 100644 index 0000000..6117ba5 --- /dev/null +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -0,0 +1,501 @@ +from datetime import datetime + +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Column, Multiselect, Radio +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.user_dialogs.states import UserMenuSG, UserTestSG +from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO +from trudex.infrastructure.database.models import QuestionType +from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository + + +async def get_state_for_question_type(question_type: str): + if question_type == QuestionType.SINGLE: + return UserTestSG.question_single + elif question_type == QuestionType.MULTIPLE: + return UserTestSG.question_multiple + else: + return UserTestSG.question_input + + +@inject +async def on_start_test( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + test_dao: FromDishka[TestDAO], + test_repo: FromDishka[TestRepository], + attempt_repo: FromDishka[TestAttemptRepository], +): + test_id = manager.dialog_data.get("selected_test_id") + user_id = _callback.from_user.id + + if not test_id: + await _callback.answer("❌ Тест не найден") + return + + test = await test_dao.get_by_id(test_id) + if not test: + await _callback.answer("❌ Тест не найден") + return + + if not test.is_active: + await _callback.answer("❌ Тест деактивирован") + return + + if test.expires_at and test.expires_at < datetime.utcnow(): + await _callback.answer("❌ Срок действия теста истек") + return + + if test.attempts: + attempts = await attempt_repo.get_user_test_attempts(user_id, test_id) + finished_attempts = [a for a in attempts if a.finished_at] + if len(finished_attempts) >= test.attempts: + await _callback.answer("❌ Вы исчерпали все попытки") + return + + active_attempt = await attempt_repo.get_active_attempt(user_id, test_id) + if active_attempt: + await _callback.answer("❌ У вас уже есть активная попытка") + return + + if test.password: + await manager.start(UserTestSG.password_input, mode=StartMode.NORMAL, data={"test_id": test_id}) + else: + _, questions = await test_repo.get_test_with_questions(test_id) + + if not questions: + await _callback.answer("❌ В тесте нет вопросов") + return + + attempt = await attempt_repo.attempt_dao.create(user_id=user_id, test_id=test_id) + + first_question, _ = await test_repo.get_question_with_options(questions[0].id) + first_state = await get_state_for_question_type(first_question.question_type if first_question else QuestionType.SINGLE) + + await manager.start( + first_state, + mode=StartMode.NORMAL, + data={ + "test_id": test_id, + "attempt_id": attempt.id, + "questions": [q.id for q in questions], + "current_question_index": 0, + "user_answers": {}, + } + ) + + +@inject +async def on_password_input( + message: Message, + _widget: MessageInput, + manager: DialogManager, + test_dao: FromDishka[TestDAO], + test_repo: FromDishka[TestRepository], + attempt_repo: FromDishka[TestAttemptRepository], +): + start_data = manager.start_data or {} + test_id = start_data.get("test_id") + + if not test_id: + await message.answer("❌ Тест не найден") + return + + test = await test_dao.get_by_id(test_id) + + if not test or not test.password: + await message.answer("❌ Ошибка проверки пароля") + return + + if message.text and message.text.strip() == test.password: + await message.answer("✅ Пароль верный, начинаем тест") + + _, questions = await test_repo.get_test_with_questions(test_id) + + if not questions: + await message.answer("❌ В тесте нет вопросов") + return + + attempt = await attempt_repo.attempt_dao.create(user_id=message.from_user.id, test_id=test_id) + + first_question, _ = await test_repo.get_question_with_options(questions[0].id) + first_state = await get_state_for_question_type(first_question.question_type if first_question else QuestionType.SINGLE) + + manager.dialog_data["attempt_id"] = attempt.id + manager.dialog_data["questions"] = [q.id for q in questions] + manager.dialog_data["current_question_index"] = 0 + manager.dialog_data["user_answers"] = {} + + await manager.switch_to(first_state) + else: + await message.answer("❌ Неверный пароль") + + +async def on_cancel_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await _callback.answer("Тест отменен") + await manager.start(UserMenuSG.available_tests, mode=StartMode.RESET_STACK) + + +@inject +async def get_question_data( + dialog_manager: DialogManager, + test_repo: FromDishka[TestRepository], + **_kwargs +): + start_data = dialog_manager.start_data or {} + + current_index = dialog_manager.dialog_data.get("current_question_index") + if current_index is None: + current_index = start_data.get("current_question_index", 0) + + questions = dialog_manager.dialog_data.get("questions") or start_data.get("questions", []) + + if not questions or current_index >= len(questions): + return {"question_text": "Ошибка", "options": []} + + question_id = questions[current_index] + question, options = await test_repo.get_question_with_options(question_id) + + if not question: + return {"question_text": "Ошибка", "options": []} + + progress = f"{current_index + 1}/{len(questions)}" + + return { + "question_text": f"Вопрос {progress}\n\n{question.text}", + "options": [(opt.text, str(opt.id)) for opt in options], + } + + +async def on_single_answer_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + start_data = manager.start_data or {} + current_index = manager.dialog_data.get("current_question_index") + if current_index is None: + current_index = start_data.get("current_question_index", 0) + questions = manager.dialog_data.get("questions") or start_data.get("questions", []) + + if not questions or current_index >= len(questions): + return + + question_id = questions[current_index] + user_answers = manager.dialog_data.get("user_answers", {}) + user_answers[str(question_id)] = {"type": "single", "answer": int(item_id)} + manager.dialog_data["user_answers"] = user_answers + + +async def on_multiple_answer_changed(_event, widget, manager: DialogManager, _data: str): + start_data = manager.start_data or {} + current_index = manager.dialog_data.get("current_question_index") + if current_index is None: + current_index = start_data.get("current_question_index", 0) + questions = manager.dialog_data.get("questions") or start_data.get("questions", []) + + if not questions or current_index >= len(questions): + return + + question_id = questions[current_index] + selected = widget.get_checked() + + user_answers = manager.dialog_data.get("user_answers", {}) + user_answers[str(question_id)] = {"type": "multiple", "answer": [int(x) for x in selected]} + manager.dialog_data["user_answers"] = user_answers + + +@inject +async def on_text_answer_input( + message: Message, + _widget: MessageInput, + manager: DialogManager, + test_repo: FromDishka[TestRepository], + attempt_repo: FromDishka[TestAttemptRepository], + answer_dao: FromDishka[UserAnswerDAO], +): + start_data = manager.start_data or {} + current_index = manager.dialog_data.get("current_question_index") + if current_index is None: + current_index = start_data.get("current_question_index", 0) + questions = manager.dialog_data.get("questions") or start_data.get("questions", []) + + if not questions or current_index >= len(questions): + return + + question_id = questions[current_index] + text_answer = message.text.strip() if message.text else "" + attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") + + if not attempt_id: + await message.answer("❌ Ошибка попытки") + return + + question, options = await test_repo.get_question_with_options(question_id) + if not question: + await message.answer("❌ Вопрос не найден") + return + + correct_options = [opt for opt in options if opt.is_correct] + user_normalized = text_answer.lower().replace(" ", "") + is_correct = any(opt.text.lower().replace(" ", "") == user_normalized for opt in correct_options) + + await answer_dao.create( + attempt_id=attempt_id, + question_id=question_id, + text_answer=text_answer, + is_correct=is_correct, + ) + + if current_index + 1 >= len(questions): + await finish_test(manager, attempt_repo, attempt_id, len(questions)) + else: + next_index = current_index + 1 + manager.dialog_data["current_question_index"] = next_index + + next_question_id = questions[next_index] + next_question, _ = await test_repo.get_question_with_options(next_question_id) + next_state = await get_state_for_question_type(next_question.question_type if next_question else QuestionType.SINGLE) + + await manager.switch_to(next_state) + + +@inject +async def on_next_question( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + test_repo: FromDishka[TestRepository], + attempt_repo: FromDishka[TestAttemptRepository], + answer_dao: FromDishka[UserAnswerDAO], +): + start_data = manager.start_data or {} + current_index = manager.dialog_data.get("current_question_index") + if current_index is None: + current_index = start_data.get("current_question_index", 0) + questions = manager.dialog_data.get("questions") or start_data.get("questions", []) + + if not questions or current_index >= len(questions): + await _callback.answer("❌ Ошибка") + return + + question_id = questions[current_index] + user_answers = manager.dialog_data.get("user_answers", {}) + + if str(question_id) not in user_answers: + await _callback.answer("❌ Выберите ответ") + return + + answer_data = user_answers[str(question_id)] + attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") + + if not attempt_id: + await _callback.answer("❌ Ошибка попытки") + return + + question, options = await test_repo.get_question_with_options(question_id) + + if not question: + await _callback.answer("❌ Вопрос не найден") + return + + if answer_data["type"] == "single": + selected_option_id = answer_data["answer"] + correct_options = [opt for opt in options if opt.is_correct] + is_correct = any(opt.id == selected_option_id for opt in correct_options) + + selected_text = next((opt.text for opt in options if opt.id == selected_option_id), "") + + await answer_dao.create( + attempt_id=attempt_id, + question_id=question_id, + selected_option_id=selected_option_id, + text_answer=selected_text, + is_correct=is_correct, + ) + + elif answer_data["type"] == "multiple": + selected_option_ids = set(answer_data["answer"]) + correct_option_ids = {opt.id for opt in options if opt.is_correct} + + selected_texts = sorted([opt.text for opt in options if opt.id in selected_option_ids]) + correct_texts = sorted([opt.text for opt in options if opt.is_correct]) + is_correct = selected_texts == correct_texts + + await answer_dao.create( + attempt_id=attempt_id, + question_id=question_id, + text_answer="|".join(selected_texts), + is_correct=is_correct, + ) + + if current_index + 1 >= len(questions): + await finish_test(manager, attempt_repo, attempt_id, len(questions)) + else: + next_index = current_index + 1 + manager.dialog_data["current_question_index"] = next_index + + next_question_id = questions[next_index] + next_question, _ = await test_repo.get_question_with_options(next_question_id) + next_state = await get_state_for_question_type(next_question.question_type if next_question else QuestionType.SINGLE) + + await manager.switch_to(next_state) + + +async def finish_test(manager: DialogManager, attempt_repo: TestAttemptRepository, attempt_id: int, total_questions: int): + correct_count = await attempt_repo.calculate_attempt_score(attempt_id) + + score = round((correct_count / total_questions * 100)) if total_questions > 0 else 0 + is_passed = score >= 50 + + await attempt_repo.finish_attempt(attempt_id, score, is_passed) + + manager.dialog_data["score"] = score + manager.dialog_data["correct_count"] = correct_count + manager.dialog_data["total_questions"] = total_questions + manager.dialog_data["is_passed"] = is_passed + + await manager.switch_to(UserTestSG.results) + + +async def get_results_data(dialog_manager: DialogManager, **_kwargs): + score = dialog_manager.dialog_data.get("score", 0) + correct_count = dialog_manager.dialog_data.get("correct_count", 0) + total_questions = dialog_manager.dialog_data.get("total_questions", 0) + is_passed = dialog_manager.dialog_data.get("is_passed", False) + + status = "✅ Тест пройден!" if is_passed else "❌ Тест не пройден" + + results_text = ( + f"{status}\n\n" + f"Результат: {score}%\n" + f"Правильных ответов: {correct_count}/{total_questions}" + ) + + return {"results_text": results_text} + + +async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + + +async def on_show_detailed_results(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(UserTestSG.detailed_results) + + +async def on_back_to_results(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(UserTestSG.results) + + +@inject +async def get_detailed_results_data( + dialog_manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], + test_repo: FromDishka[TestRepository], + **_kwargs +): + start_data = dialog_manager.start_data or {} + attempt_id = dialog_manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") + + if not attempt_id: + return {"detailed_text": "Ошибка загрузки результатов"} + + answers = await attempt_repo.get_answers_for_attempt(attempt_id) + + lines = ["📋 Подробные результаты:\n"] + + for i, answer in enumerate(answers, 1): + question, options = await test_repo.get_question_with_options(answer.question_id) + if not question: + continue + + correct_options = [opt for opt in options if opt.is_correct] + correct_texts = [opt.text for opt in correct_options] + + status = "✅" if answer.is_correct else "❌" + + user_answer = answer.text_answer or "" + if "|" in user_answer: + user_answer = ", ".join(user_answer.split("|")) + + question_text = question.text.split(" (Тест:")[0] if " (Тест:" in question.text else question.text + + lines.append(f"{status} Вопрос {i}: {question_text}") + lines.append(f"Ваш ответ: {user_answer or '—'}") + lines.append(f"Правильный ответ: {', '.join(correct_texts)}\n") + + return {"detailed_text": "\n".join(lines)} + + +take_test_dialog = Dialog( + Window( + Const("🔑 Введите пароль для доступа к тесту:"), + MessageInput(on_password_input), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), + state=UserTestSG.password_input, + ), + Window( + Format("{question_text}\n\nВыберите один вариант ответа:"), + Column( + Radio( + Format("🔘 {item[0]}"), + Format("⚪ {item[0]}"), + id="single_answer", + item_id_getter=lambda x: x[1], + items="options", + on_click=on_single_answer_selected, + ), + ), + Column( + Button(Const("➡️ Далее"), id="next", on_click=on_next_question), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), + ), + state=UserTestSG.question_single, + getter=get_question_data, + ), + Window( + Format("{question_text}\n\nВыберите несколько вариантов ответа:"), + Column( + Multiselect( + Format("✅ {item[0]}"), + Format("⬜ {item[0]}"), + id="multiple_answer", + item_id_getter=lambda x: x[1], + items="options", + on_state_changed=on_multiple_answer_changed, + ), + ), + Column( + Button(Const("➡️ Далее"), id="next", on_click=on_next_question), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), + ), + state=UserTestSG.question_multiple, + getter=get_question_data, + ), + Window( + Format("{question_text}\n\nВведите ответ:"), + MessageInput(on_text_answer_input), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), + state=UserTestSG.question_input, + getter=get_question_data, + ), + Window( + Format("{results_text}"), + Column( + Button(Const("📋 Подробные результаты"), id="detailed", on_click=on_show_detailed_results), + Button(Const("◀️ В главное меню"), id="back", on_click=on_back_to_menu), + ), + state=UserTestSG.results, + getter=get_results_data, + ), + Window( + Format("{detailed_text}"), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_results), + state=UserTestSG.detailed_results, + getter=get_detailed_results_data, + ), +) From d5130d61c9aa944d4dacab6f1dc53a816a987d04 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 22:59:38 +0300 Subject: [PATCH 32/57] commit --- src/trudex/application/bot/handlers.py | 20 ++------------- .../application/bot/user_dialogs/take_test.py | 25 ++++++++++--------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 663f378..9476abd 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -24,15 +24,12 @@ async def start_handler( ) -> None: assert message.from_user is not None - # Проверяем, существует ли пользователь existing_user = await user_dao.get_by_id(message.from_user.id) if existing_user is None: - # Новый пользователь - проверяем наличие групп groups = await group_dao.get_all() if len(groups) > 0: - # Есть группы - создаем пользователя без группы и имени, показываем регистрацию await user_dao.create( user_id=message.from_user.id, first_name=message.from_user.first_name, @@ -45,7 +42,6 @@ async def start_handler( data={"user_id": message.from_user.id} ) else: - # Нет групп - просто создаем пользователя await user_dao.create( user_id=message.from_user.id, first_name=message.from_user.first_name, @@ -54,28 +50,22 @@ async def start_handler( ) await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) else: - # Существующий пользователь - # Проверяем, заполнил ли он имя и группу groups = await group_dao.get_all() if len(groups) > 0 and (existing_user.name is None or existing_user.group is None): - # Есть группы, но пользователь не завершил регистрацию if existing_user.name is None: - # Начинаем с ввода имени await dialog_manager.start( UserRegistrationSG.input_name, mode=StartMode.RESET_STACK, data={"user_id": message.from_user.id} ) else: - # Имя есть, но нет группы await dialog_manager.start( UserRegistrationSG.select_group, mode=StartMode.RESET_STACK, data={"user_id": message.from_user.id} ) else: - # Регистрация завершена или групп нет - обновляем данные и открываем меню await user_dao.upsert( user_id=message.from_user.id, first_name=message.from_user.first_name, @@ -87,18 +77,12 @@ async def start_handler( @router.message(Command("admin")) async def admin_command(message: Message, dialog_manager: DialogManager) -> None: - try: - await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) - except Exception as e: - await message.answer(f"Ошибка запуска диалога: {e}") + await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) @router.message(Command("creator")) async def creator_command(message: Message, dialog_manager: DialogManager) -> None: - try: - await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) - except Exception as e: - await message.answer(f"Ошибка запуска диалога: {e}") + await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) @router.error() diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index 6117ba5..a103687 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -170,7 +170,7 @@ async def get_question_data( progress = f"{current_index + 1}/{len(questions)}" return { - "question_text": f"Вопрос {progress}\n\n{question.text}", + "question_text": f"📝 Вопрос {progress}\n\n
{question.text}
", "options": [(opt.text, str(opt.id)) for opt in options], } @@ -320,7 +320,6 @@ async def on_next_question( elif answer_data["type"] == "multiple": selected_option_ids = set(answer_data["answer"]) - correct_option_ids = {opt.id for opt in options if opt.is_correct} selected_texts = sorted([opt.text for opt in options if opt.id in selected_option_ids]) correct_texts = sorted([opt.text for opt in options if opt.is_correct]) @@ -368,12 +367,15 @@ async def get_results_data(dialog_manager: DialogManager, **_kwargs): total_questions = dialog_manager.dialog_data.get("total_questions", 0) is_passed = dialog_manager.dialog_data.get("is_passed", False) - status = "✅ Тест пройден!" if is_passed else "❌ Тест не пройден" + if is_passed: + status = "✅ Тест пройден!" + else: + status = "❌ Тест не пройден" results_text = ( - f"{status}\n\n" - f"Результат: {score}%\n" - f"Правильных ответов: {correct_count}/{total_questions}" + f"{status}\n\n" + f"📊 Результат: {score}%\n" + f"✏️ Правильных ответов: {correct_count} из {total_questions}" ) return {"results_text": results_text} @@ -406,7 +408,7 @@ async def get_detailed_results_data( answers = await attempt_repo.get_answers_for_attempt(attempt_id) - lines = ["📋 Подробные результаты:\n"] + lines = ["📋 Подробные результаты\n"] for i, answer in enumerate(answers, 1): question, options = await test_repo.get_question_with_options(answer.question_id) @@ -422,11 +424,10 @@ async def get_detailed_results_data( if "|" in user_answer: user_answer = ", ".join(user_answer.split("|")) - question_text = question.text.split(" (Тест:")[0] if " (Тест:" in question.text else question.text - - lines.append(f"{status} Вопрос {i}: {question_text}") - lines.append(f"Ваш ответ: {user_answer or '—'}") - lines.append(f"Правильный ответ: {', '.join(correct_texts)}\n") + lines.append(f"{status} Вопрос {i}") + lines.append(f"
{question.text}
") + lines.append(f"👤 Ваш ответ: {user_answer or '—'}") + lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") return {"detailed_text": "\n".join(lines)} From 15e3815f7171e9bb19ec1ad6e39f5b00b1ff9fa6 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 23:04:34 +0300 Subject: [PATCH 33/57] commit --- .../application/bot/user_dialogs/main_menu.py | 111 +++++++++++++++++- .../application/bot/user_dialogs/states.py | 2 + .../database/repo/test_attempt.py | 13 ++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index de777ff..e5530bc 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -107,8 +107,8 @@ async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(UserMenuSG.available_tests) -async def on_results_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): - await _callback.answer("🚧 В разработке") +async def on_results_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(UserMenuSG.my_results) async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -222,6 +222,89 @@ async def get_test_detail( return {"test_info": test_info} +@inject +async def get_my_results( + dialog_manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], + **_kwargs +): + user_id = dialog_manager.event.from_user.id + attempts_with_tests = await attempt_repo.get_finished_attempts_with_tests(user_id) + + results = [] + for attempt, test_title in attempts_with_tests: + status = "✅" if attempt.is_passed else "❌" + date_str = attempt.finished_at.strftime("%d.%m.%Y") if attempt.finished_at else "" + results.append((f"{status} {test_title} — {attempt.score}% ({date_str})", attempt.id)) + + return { + "results": results, + "count": len(results), + } + + +async def on_result_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_attempt_id"] = int(item_id) + await manager.switch_to(UserMenuSG.result_detail) + + +async def on_back_to_results(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(UserMenuSG.my_results) + + +@inject +async def get_result_detail( + dialog_manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], + test_repo: FromDishka[TestRepository], + **_kwargs +): + attempt_id = dialog_manager.dialog_data.get("selected_attempt_id") + + if not attempt_id: + return {"result_info": "❌ Результат не найден"} + + attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id) + + if not attempt: + return {"result_info": "❌ Результат не найден"} + + test, _ = await test_repo.get_test_with_questions(attempt.test_id) + test_title = test.title if test else "Неизвестный тест" + + status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" + date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "—" + + lines = [ + f"📝 {test_title}\n", + f"📊 Результат: {attempt.score}%", + f"📅 Дата: {date_str}", + f"🏆 Статус: {status}\n", + "📋 Ответы:\n", + ] + + for i, answer in enumerate(answers, 1): + question, options = await test_repo.get_question_with_options(answer.question_id) + if not question: + continue + + correct_options = [opt for opt in options if opt.is_correct] + correct_texts = [opt.text for opt in correct_options] + + status_icon = "✅" if answer.is_correct else "❌" + + user_answer = answer.text_answer or "" + if "|" in user_answer: + user_answer = ", ".join(user_answer.split("|")) + + lines.append(f"{status_icon} Вопрос {i}") + lines.append(f"
{question.text}
") + lines.append(f"👤 Ваш ответ: {user_answer or '—'}") + lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") + + return {"result_info": "\n".join(lines)} + + user_menu_dialog = Dialog( Window( Format("{user_info}"), @@ -287,4 +370,28 @@ user_menu_dialog = Dialog( state=UserMenuSG.edit_group, getter=get_groups_data, ), + Window( + Format("📊 Мои результаты\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="result_select", + item_id_getter=lambda x: x[1], + items="results", + on_click=on_result_selected, + ), + id="results_scroll", + width=1, + height=7, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_main), + state=UserMenuSG.my_results, + getter=get_my_results, + ), + Window( + Format("{result_info}"), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_results), + state=UserMenuSG.result_detail, + getter=get_result_detail, + ), ) diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/trudex/application/bot/user_dialogs/states.py index b28c737..e485906 100644 --- a/src/trudex/application/bot/user_dialogs/states.py +++ b/src/trudex/application/bot/user_dialogs/states.py @@ -7,6 +7,8 @@ class UserMenuSG(StatesGroup): test_detail = State() edit_name = State() edit_group = State() + my_results = State() + result_detail = State() class UserTestSG(StatesGroup): diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index 1938f0c..2f8be01 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -211,3 +211,16 @@ class TestAttemptRepository: "total_attempts": row.total_attempts or 0, "avg_score": round(row.avg_score, 1) if row.avg_score else 0, } + + async def get_finished_attempts_with_tests(self, user_id: int) -> list[tuple[TestAttempt, str]]: + from trudex.infrastructure.database.models import Test as TestModel + + result = await self.session.execute( + select(TestAttemptModel, TestModel.title) + .join(TestModel, TestAttemptModel.test_id == TestModel.id) + .where(TestAttemptModel.user_id == user_id) + .where(TestAttemptModel.finished_at.isnot(None)) + .order_by(TestAttemptModel.finished_at.desc()) + ) + rows = result.all() + return [(TestAttemptDTO(row[0]).to_domain(), row[1]) for row in rows] From 5de8cac43e185fb48e1bab08dbf83f6a24531e67 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 23:11:53 +0300 Subject: [PATCH 34/57] commit --- .../application/bot/user_dialogs/take_test.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index a103687..8b3b349 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -63,8 +63,7 @@ async def on_start_test( active_attempt = await attempt_repo.get_active_attempt(user_id, test_id) if active_attempt: - await _callback.answer("❌ У вас уже есть активная попытка") - return + await attempt_repo.attempt_dao.delete(active_attempt.id) if test.password: await manager.start(UserTestSG.password_input, mode=StartMode.NORMAL, data={"test_id": test_id}) @@ -139,7 +138,19 @@ async def on_password_input( await message.answer("❌ Неверный пароль") -async def on_cancel_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): +@inject +async def on_cancel_test( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], +): + start_data = manager.start_data or {} + attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") + + if attempt_id: + await attempt_repo.attempt_dao.delete(attempt_id) + await _callback.answer("Тест отменен") await manager.start(UserMenuSG.available_tests, mode=StartMode.RESET_STACK) From 02b6ad48bb25ca80bb4860644f90628b09a98cdb Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 23:29:03 +0300 Subject: [PATCH 35/57] commit --- .../application/bot/admin_dialogs/states.py | 2 + .../application/bot/admin_dialogs/tests.py | 116 ++++++++++++++++- .../application/bot/creator_dialogs/states.py | 2 + .../application/bot/creator_dialogs/tests.py | 118 +++++++++++++++++- .../bot/user_dialogs/registration.py | 3 - .../database/repo/test_attempt.py | 13 ++ 6 files changed, 246 insertions(+), 8 deletions(-) diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index b55d1ae..44f013e 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -20,6 +20,8 @@ class AdminTestsSG(StatesGroup): edit_attempts = State() edit_group = State() edit_expires = State() + statistics = State() + attempt_detail = State() class AdminBroadcastSG(StatesGroup): diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 2c07051..0bf85e0 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -17,6 +17,7 @@ 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.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id @@ -109,8 +110,92 @@ async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(AdminTestsSG.tests_list) -async def on_statistics(_callback: CallbackQuery, _button: Button, _manager: DialogManager): - await _callback.answer("🚧 В разработке") +async def on_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.statistics) + + +@inject +async def get_statistics_data( + dialog_manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], + **_kwargs +): + test_id = dialog_manager.dialog_data.get("selected_test_id") + + if not test_id: + return {"attempts": [], "count": 0} + + attempts_with_users = await attempt_repo.get_test_attempts_with_users(test_id) + + results = [] + for attempt, user_name in attempts_with_users: + status = "✅" if attempt.is_passed else "❌" + date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "" + results.append((f"{status} {user_name} — {attempt.score}% ({date_str})", attempt.id)) + + return { + "attempts": results, + "count": len(results), + } + + +async def on_attempt_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_attempt_id"] = int(item_id) + await manager.switch_to(AdminTestsSG.attempt_detail) + + +async def on_back_to_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminTestsSG.statistics) + + +@inject +async def get_attempt_detail( + dialog_manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], + test_repo: FromDishka[TestRepository], + **_kwargs +): + attempt_id = dialog_manager.dialog_data.get("selected_attempt_id") + + if not attempt_id: + return {"attempt_info": "❌ Результат не найден"} + + attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id) + + if not attempt: + return {"attempt_info": "❌ Результат не найден"} + + status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" + date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "—" + + lines = [ + f"📊 Результат прохождения\n", + f"📈 Результат: {attempt.score}%", + f"📅 Дата: {date_str}", + f"🏆 Статус: {status}\n", + "📋 Ответы:\n", + ] + + for i, answer in enumerate(answers, 1): + question, options = await test_repo.get_question_with_options(answer.question_id) + if not question: + continue + + correct_options = [opt for opt in options if opt.is_correct] + correct_texts = [opt.text for opt in correct_options] + + status_icon = "✅" if answer.is_correct else "❌" + + user_answer = answer.text_answer or "" + if "|" in user_answer: + user_answer = ", ".join(user_answer.split("|")) + + lines.append(f"{status_icon} Вопрос {i}") + lines.append(f"
{question.text}
") + lines.append(f"👤 Ответ: {user_answer or '—'}") + lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") + + return {"attempt_info": "\n".join(lines)} @inject @@ -413,4 +498,31 @@ tests_dialog = Dialog( ), state=AdminTestsSG.edit_expires, ), + Window( + Format("📊 Статистика теста\n\nПрошли тест: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="attempt_select", + item_id_getter=lambda x: x[1], + items="attempts", + on_click=on_attempt_selected, + ), + id="attempts_scroll", + width=1, + height=7, + ), + Column( + Button(Const("🔄 Обновить"), id="refresh", on_click=on_statistics), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_detail), + ), + state=AdminTestsSG.statistics, + getter=get_statistics_data, + ), + Window( + Format("{attempt_info}"), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_statistics), + state=AdminTestsSG.attempt_detail, + getter=get_attempt_detail, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 2d5fbe0..bc74887 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -21,6 +21,8 @@ class CreatorTestsSG(StatesGroup): edit_attempts = State() edit_group = State() edit_expires = State() + statistics = State() + attempt_detail = State() class CreatorBroadcastSG(StatesGroup): diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 914fecc..e690362 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -22,6 +22,7 @@ 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.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id @@ -114,8 +115,92 @@ async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: Di await manager.switch_to(CreatorTestsSG.tests_list) -async def on_statistics(_callback: CallbackQuery, _button: Button, _manager: DialogManager): - await _callback.answer("🚧 В разработке") +async def on_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.statistics) + + +@inject +async def get_statistics_data( + dialog_manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], + **_kwargs +): + test_id = dialog_manager.dialog_data.get("selected_test_id") + + if not test_id: + return {"attempts": [], "count": 0} + + attempts_with_users = await attempt_repo.get_test_attempts_with_users(test_id) + + results = [] + for attempt, user_name in attempts_with_users: + status = "✅" if attempt.is_passed else "❌" + date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "" + results.append((f"{status} {user_name} — {attempt.score}% ({date_str})", attempt.id)) + + return { + "attempts": results, + "count": len(results), + } + + +async def on_attempt_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_attempt_id"] = int(item_id) + await manager.switch_to(CreatorTestsSG.attempt_detail) + + +async def on_back_to_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorTestsSG.statistics) + + +@inject +async def get_attempt_detail( + dialog_manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], + test_repo: FromDishka[TestRepository], + **_kwargs +): + attempt_id = dialog_manager.dialog_data.get("selected_attempt_id") + + if not attempt_id: + return {"attempt_info": "❌ Результат не найден"} + + attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id) + + if not attempt: + return {"attempt_info": "❌ Результат не найден"} + + status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" + date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "—" + + lines = [ + f"📊 Результат прохождения\n", + f"📈 Результат: {attempt.score}%", + f"📅 Дата: {date_str}", + f"🏆 Статус: {status}\n", + "📋 Ответы:\n", + ] + + for i, answer in enumerate(answers, 1): + question, options = await test_repo.get_question_with_options(answer.question_id) + if not question: + continue + + correct_options = [opt for opt in options if opt.is_correct] + correct_texts = [opt.text for opt in correct_options] + + status_icon = "✅" if answer.is_correct else "❌" + + user_answer = answer.text_answer or "" + if "|" in user_answer: + user_answer = ", ".join(user_answer.split("|")) + + lines.append(f"{status_icon} Вопрос {i}") + lines.append(f"
{question.text}
") + lines.append(f"👤 Ответ: {user_answer or '—'}") + lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") + + return {"attempt_info": "\n".join(lines)} @inject @@ -418,6 +503,33 @@ tests_dialog = Dialog( Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), state=CreatorTestsSG.edit_expires, - ) + ), + Window( + Format("📊 Статистика теста\n\nПрошли тест: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="attempt_select", + item_id_getter=lambda x: x[1], + items="attempts", + on_click=on_attempt_selected, + ), + id="attempts_scroll", + width=1, + height=7, + ), + Column( + Button(Const("🔄 Обновить"), id="refresh", on_click=on_statistics), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_detail), + ), + state=CreatorTestsSG.statistics, + getter=get_statistics_data, + ), + Window( + Format("{attempt_info}"), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_statistics), + state=CreatorTestsSG.attempt_detail, + getter=get_attempt_detail, + ), ) diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/trudex/application/bot/user_dialogs/registration.py index 632e983..b7ba040 100644 --- a/src/trudex/application/bot/user_dialogs/registration.py +++ b/src/trudex/application/bot/user_dialogs/registration.py @@ -46,10 +46,7 @@ async def get_groups_for_registration(dialog_manager: DialogManager, group_dao: @inject async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, user_dao: FromDishka[UserDAO]): user_id = manager.start_data.get("user_id") - await user_dao.update(user_id=user_id, group=int(item_id)) - - await _callback.answer("✅ Группа выбрана! Вы можете изменить её через 24 часа", show_alert=True) await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index 2f8be01..9a822c2 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -224,3 +224,16 @@ class TestAttemptRepository: ) rows = result.all() return [(TestAttemptDTO(row[0]).to_domain(), row[1]) for row in rows] + + async def get_test_attempts_with_users(self, test_id: int) -> list[tuple[TestAttempt, str]]: + from trudex.infrastructure.database.models import User as UserModel + + result = await self.session.execute( + select(TestAttemptModel, UserModel.name, UserModel.first_name) + .join(UserModel, TestAttemptModel.user_id == UserModel.id) + .where(TestAttemptModel.test_id == test_id) + .where(TestAttemptModel.finished_at.isnot(None)) + .order_by(TestAttemptModel.finished_at.desc()) + ) + rows = result.all() + return [(TestAttemptDTO(row[0]).to_domain(), row[1] or row[2]) for row in rows] From 13b4597bbc94c4c8f31f991d6bec821c11ff3421 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 23:40:40 +0300 Subject: [PATCH 36/57] commit --- src/trudex/application/__main__.py | 2 + src/trudex/application/bot/handlers.py | 199 ++++++++++++----- .../application/bot/user_dialogs/deeplink.py | 204 ++++++++++++++++++ .../bot/user_dialogs/registration.py | 27 ++- .../application/bot/user_dialogs/states.py | 5 + 5 files changed, 378 insertions(+), 59 deletions(-) create mode 100644 src/trudex/application/bot/user_dialogs/deeplink.py diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index e6ada8d..727f465 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -35,6 +35,7 @@ from trudex.application.bot.middlewares.reject_not_admin import \ RejectNotAdminMiddleware from trudex.application.bot.middlewares.reject_not_creator import \ RejectNotCreatorMiddleware +from trudex.application.bot.user_dialogs.deeplink import deeplink_dialog from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog from trudex.application.bot.user_dialogs.registration import \ registration_dialog @@ -65,6 +66,7 @@ async def main() -> None: user_menu_dialog, take_test_dialog, registration_dialog, + deeplink_dialog, admin_menu_dialog, admin_users_dialog, admin_tests_dialog, diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 9476abd..a9bb513 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,5 +1,7 @@ +from datetime import datetime + from aiogram import Router -from aiogram.filters import Command, CommandStart +from aiogram.filters import Command, CommandStart, CommandObject from aiogram.types import ErrorEvent, Message from aiogram_dialog import DialogManager, StartMode from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent @@ -7,14 +9,150 @@ from dishka.integrations.aiogram import FromDishka from trudex.application.bot.admin_dialogs.states import AdminMenuSG from trudex.application.bot.creator_dialogs.states import CreatorMenuSG -from trudex.application.bot.user_dialogs.states import (UserMenuSG, - UserRegistrationSG) +from trudex.application.bot.user_dialogs.states import ( + UserDeeplinkSG, + UserMenuSG, + UserRegistrationSG, +) from trudex.infrastructure.database.dao.group import GroupDAO +from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.utils.config import Config +from trudex.infrastructure.utils.test_id_to_hash import decode_id router = Router() +async def ensure_user_registered( + user_dao: UserDAO, + group_dao: GroupDAO, + message: Message, + dialog_manager: DialogManager, + pending_test_id: int | None = None, +) -> bool: + assert message.from_user is not None + + existing_user = await user_dao.get_by_id(message.from_user.id) + groups = await group_dao.get_all() + + start_data = {"user_id": message.from_user.id} + if pending_test_id: + start_data["pending_test_id"] = pending_test_id + + if existing_user is None: + await user_dao.create( + user_id=message.from_user.id, + first_name=message.from_user.first_name, + username=message.from_user.username, + last_name=message.from_user.last_name, + ) + if len(groups) > 0: + await dialog_manager.start( + UserRegistrationSG.input_name, + mode=StartMode.RESET_STACK, + data=start_data + ) + return False + return True + + if len(groups) > 0 and (existing_user.name is None or existing_user.group is None): + if existing_user.name is None: + await dialog_manager.start( + UserRegistrationSG.input_name, + mode=StartMode.RESET_STACK, + data=start_data + ) + else: + await dialog_manager.start( + UserRegistrationSG.select_group, + mode=StartMode.RESET_STACK, + data=start_data + ) + return False + + await user_dao.upsert( + user_id=message.from_user.id, + first_name=message.from_user.first_name, + username=message.from_user.username, + last_name=message.from_user.last_name, + ) + return True + + +async def validate_deeplink_test( + test_dao: TestDAO, + user_dao: UserDAO, + test_id: int, + user_id: int, +) -> tuple[bool, str]: + test = await test_dao.get_by_id(test_id) + + if not test: + return False, "❌ Тест не найден" + + if not test.is_active: + return False, "❌ Тест деактивирован" + + if test.expires_at and test.expires_at < datetime.utcnow(): + return False, "❌ Срок действия теста истек" + + user = await user_dao.get_by_id(user_id) + if test.for_group and user and user.group != test.for_group: + return False, f"❌ Тест доступен только для группы {test.for_group}" + + return True, "" + + +@router.message(CommandStart(deep_link=True)) +async def start_with_deeplink( + message: Message, + command: CommandObject, + user_dao: FromDishka[UserDAO], + group_dao: FromDishka[GroupDAO], + test_dao: FromDishka[TestDAO], + config: FromDishka[Config], + dialog_manager: DialogManager, +) -> None: + assert message.from_user is not None + + deeplink = command.args + if not deeplink: + await start_handler(message, user_dao, group_dao, dialog_manager) + return + + try: + test_id = decode_id(deeplink, config.security.encode_key) + except (ValueError, IndexError): + await message.answer("❌ Неверная ссылка на тест") + await start_handler(message, user_dao, group_dao, dialog_manager) + return + + is_registered = await ensure_user_registered( + user_dao, group_dao, message, dialog_manager, pending_test_id=test_id + ) + + if not is_registered: + return + + is_valid, error = await validate_deeplink_test( + test_dao, user_dao, test_id, message.from_user.id + ) + + if not is_valid: + await dialog_manager.start( + UserDeeplinkSG.test_preview, + mode=StartMode.RESET_STACK, + data={"test_id": test_id, "error": error} + ) + return + + await dialog_manager.start( + UserDeeplinkSG.test_preview, + mode=StartMode.RESET_STACK, + data={"test_id": test_id} + ) + + @router.message(CommandStart()) async def start_handler( message: Message, @@ -22,57 +160,12 @@ async def start_handler( group_dao: FromDishka[GroupDAO], dialog_manager: DialogManager ) -> None: - assert message.from_user is not None + is_registered = await ensure_user_registered( + user_dao, group_dao, message, dialog_manager + ) - existing_user = await user_dao.get_by_id(message.from_user.id) - - if existing_user is None: - groups = await group_dao.get_all() - - if len(groups) > 0: - await user_dao.create( - user_id=message.from_user.id, - first_name=message.from_user.first_name, - username=message.from_user.username, - last_name=message.from_user.last_name, - ) - await dialog_manager.start( - UserRegistrationSG.input_name, - mode=StartMode.RESET_STACK, - data={"user_id": message.from_user.id} - ) - else: - await user_dao.create( - user_id=message.from_user.id, - first_name=message.from_user.first_name, - username=message.from_user.username, - last_name=message.from_user.last_name, - ) - await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) - else: - groups = await group_dao.get_all() - - if len(groups) > 0 and (existing_user.name is None or existing_user.group is None): - if existing_user.name is None: - await dialog_manager.start( - UserRegistrationSG.input_name, - mode=StartMode.RESET_STACK, - data={"user_id": message.from_user.id} - ) - else: - await dialog_manager.start( - UserRegistrationSG.select_group, - mode=StartMode.RESET_STACK, - data={"user_id": message.from_user.id} - ) - else: - await user_dao.upsert( - user_id=message.from_user.id, - first_name=message.from_user.first_name, - username=message.from_user.username, - last_name=message.from_user.last_name, - ) - await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + if is_registered: + await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) @router.message(Command("admin")) diff --git a/src/trudex/application/bot/user_dialogs/deeplink.py b/src/trudex/application/bot/user_dialogs/deeplink.py new file mode 100644 index 0000000..8f634f2 --- /dev/null +++ b/src/trudex/application/bot/user_dialogs/deeplink.py @@ -0,0 +1,204 @@ +from datetime import datetime + +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserTestSG +from trudex.domain.schemas import Test, User +from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.models import QuestionType +from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository + + +async def validate_test_access(test: Test | None, user: User | None) -> tuple[bool, str]: + if not test: + return False, "❌ Тест не найден" + + if not test.is_active: + return False, "❌ Тест деактивирован" + + if test.expires_at and test.expires_at < datetime.utcnow(): + return False, "❌ Срок действия теста истек" + + if test.for_group and user and user.group != test.for_group: + return False, f"❌ Тест доступен только для группы {test.for_group}" + + return True, "" + + +@inject +async def get_deeplink_test_data( + dialog_manager: DialogManager, + test_dao: FromDishka[TestDAO], + test_repo: FromDishka[TestRepository], + **_kwargs +): + test_id = dialog_manager.start_data.get("test_id") if dialog_manager.start_data else None + error = dialog_manager.start_data.get("error") if dialog_manager.start_data else None + + if error: + return {"test_info": error, "can_start": False} + + if not test_id: + return {"test_info": "❌ Тест не найден", "can_start": False} + + test = await test_dao.get_by_id(test_id) + + if not test: + return {"test_info": "❌ Тест не найден", "can_start": False} + + questions_count = await test_repo.count_questions_in_test(test_id) + + password_str = "🔒 Требуется пароль" if test.password else "🔓 Без пароля" + attempts_str = f"🔄 Попыток: {test.attempts}" if test.attempts 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}" + ) + + return {"test_info": test_info, "can_start": True, "has_password": bool(test.password)} + + +@inject +async def on_start_deeplink_test( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + test_dao: FromDishka[TestDAO], + test_repo: FromDishka[TestRepository], + attempt_repo: FromDishka[TestAttemptRepository], +): + start_data = manager.start_data or {} + test_id = start_data.get("test_id") + user_id = _callback.from_user.id + + if not test_id: + await _callback.answer("❌ Тест не найден") + return + + test = await test_dao.get_by_id(test_id) + if not test: + await _callback.answer("❌ Тест не найден") + return + + if test.attempts: + attempts = await attempt_repo.get_user_test_attempts(user_id, test_id) + finished_attempts = [a for a in attempts if a.finished_at] + if len(finished_attempts) >= test.attempts: + await _callback.answer("❌ Вы исчерпали все попытки") + return + + active_attempt = await attempt_repo.get_active_attempt(user_id, test_id) + if active_attempt: + await attempt_repo.attempt_dao.delete(active_attempt.id) + + if test.password: + await manager.switch_to(UserDeeplinkSG.password_input) + else: + await start_test_without_password(manager, test_repo, attempt_repo, test_id, user_id) + + +async def start_test_without_password( + manager: DialogManager, + test_repo: TestRepository, + attempt_repo: TestAttemptRepository, + test_id: int, + user_id: int, +): + _, questions = await test_repo.get_test_with_questions(test_id) + + if not questions: + return + + attempt = await attempt_repo.attempt_dao.create(user_id=user_id, test_id=test_id) + + first_question, _ = await test_repo.get_question_with_options(questions[0].id) + + if first_question: + if first_question.question_type == QuestionType.SINGLE: + first_state = UserTestSG.question_single + elif first_question.question_type == QuestionType.MULTIPLE: + first_state = UserTestSG.question_multiple + else: + first_state = UserTestSG.question_input + else: + first_state = UserTestSG.question_single + + await manager.start( + first_state, + mode=StartMode.RESET_STACK, + data={ + "test_id": test_id, + "attempt_id": attempt.id, + "questions": [q.id for q in questions], + "current_question_index": 0, + "user_answers": {}, + } + ) + + +@inject +async def on_deeplink_password_input( + message: Message, + _widget: MessageInput, + manager: DialogManager, + test_dao: FromDishka[TestDAO], + test_repo: FromDishka[TestRepository], + attempt_repo: FromDishka[TestAttemptRepository], +): + start_data = manager.start_data or {} + test_id = start_data.get("test_id") + + if not test_id: + await message.answer("❌ Тест не найден") + return + + test = await test_dao.get_by_id(test_id) + + if not test or not test.password: + await message.answer("❌ Ошибка проверки пароля") + return + + if message.text and message.text.strip() == test.password: + await message.answer("✅ Пароль верный") + await start_test_without_password( + manager, test_repo, attempt_repo, test_id, message.from_user.id + ) + else: + await message.answer("❌ Неверный пароль") + + +async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + + +deeplink_dialog = Dialog( + Window( + Format("{test_info}"), + Button( + Const("▶️ Пройти тест"), + id="start_test", + on_click=on_start_deeplink_test, + when="can_start" + ), + Button(Const("◀️ В главное меню"), id="back", on_click=on_back_to_menu), + state=UserDeeplinkSG.test_preview, + getter=get_deeplink_test_data, + ), + Window( + Const("🔑 Введите пароль для доступа к тесту:"), + MessageInput(on_deeplink_password_input), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu), + state=UserDeeplinkSG.password_input, + ), +) diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/trudex/application/bot/user_dialogs/registration.py index b7ba040..5e837cd 100644 --- a/src/trudex/application/bot/user_dialogs/registration.py +++ b/src/trudex/application/bot/user_dialogs/registration.py @@ -6,8 +6,11 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.user_dialogs.states import (UserMenuSG, - UserRegistrationSG) +from trudex.application.bot.user_dialogs.states import ( + UserDeeplinkSG, + UserMenuSG, + UserRegistrationSG, +) from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO @@ -27,7 +30,8 @@ async def on_name_input(message: Message, _widget: MessageInput, manager: Dialog await message.answer("❌ Имя и фамилия слишком длинные (максимум 128 символов)") return - user_id = manager.start_data.get("user_id") + start_data = manager.start_data or {} + user_id = start_data.get("user_id") await user_dao.update(user_id=user_id, name=name) manager.dialog_data["name"] = name @@ -35,7 +39,7 @@ async def on_name_input(message: Message, _widget: MessageInput, manager: Dialog @inject -async def get_groups_for_registration(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): +async def get_groups_for_registration(group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() return { @@ -45,9 +49,20 @@ async def get_groups_for_registration(dialog_manager: DialogManager, group_dao: @inject async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, user_dao: FromDishka[UserDAO]): - user_id = manager.start_data.get("user_id") + start_data = manager.start_data or {} + user_id = start_data.get("user_id") + pending_test_id = start_data.get("pending_test_id") + await user_dao.update(user_id=user_id, group=int(item_id)) - await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + + if pending_test_id: + await manager.start( + UserDeeplinkSG.test_preview, + mode=StartMode.RESET_STACK, + data={"test_id": pending_test_id} + ) + else: + await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) registration_dialog = Dialog( diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/trudex/application/bot/user_dialogs/states.py index e485906..9f77f72 100644 --- a/src/trudex/application/bot/user_dialogs/states.py +++ b/src/trudex/application/bot/user_dialogs/states.py @@ -20,6 +20,11 @@ class UserTestSG(StatesGroup): detailed_results = State() +class UserDeeplinkSG(StatesGroup): + test_preview = State() + password_input = State() + + class UserRegistrationSG(StatesGroup): input_name = State() select_group = State() From c09a565f6bad6b5781aaa0583a87821706cd5c99 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 23:48:48 +0300 Subject: [PATCH 37/57] commit --- .../bot/creator_dialogs/create_test.py | 6 +-- .../application/bot/creator_dialogs/tests.py | 1 - src/trudex/application/bot/handlers.py | 12 +++--- .../application/bot/user_dialogs/deeplink.py | 33 ++++++---------- .../application/bot/user_dialogs/main_menu.py | 38 +++++++++++-------- .../bot/user_dialogs/registration.py | 25 ++++++++++-- .../application/bot/user_dialogs/take_test.py | 18 +++++++-- 7 files changed, 78 insertions(+), 55 deletions(-) diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index 95ce762..30de6ef 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -55,7 +55,7 @@ async def on_description_input(message: Message, _widget: MessageInput, manager: @inject -async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager, group_dao: FromDishka[GroupDAO]): +async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager, _group_dao: FromDishka[GroupDAO]): if not message.text: await message.answer("❌ Пароль не может быть пустым") return @@ -74,7 +74,7 @@ async def on_password_input(message: Message, _widget: MessageInput, manager: Di @inject -async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager, group_dao: FromDishka[GroupDAO]): +async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager, _group_dao: FromDishka[GroupDAO]): manager.dialog_data["password"] = None await manager.switch_to(CreateTestSG.input_attempts) @@ -120,7 +120,7 @@ async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: Di @inject -async def get_groups_for_test(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): +async def get_groups_for_test(group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() return { diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index e690362..a522a85 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -1,7 +1,6 @@ import asyncio import functools from datetime import date, datetime -import logging from aiogram import Bot from aiogram.enums import ContentType diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index a9bb513..46b9fc1 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from aiogram import Router from aiogram.filters import Command, CommandStart, CommandObject @@ -93,7 +93,7 @@ async def validate_deeplink_test( if not test.is_active: return False, "❌ Тест деактивирован" - if test.expires_at and test.expires_at < datetime.utcnow(): + if test.expires_at and test.expires_at < datetime.now(timezone.utc): return False, "❌ Срок действия теста истек" user = await user_dao.get_by_id(user_id) @@ -107,11 +107,11 @@ async def validate_deeplink_test( async def start_with_deeplink( message: Message, command: CommandObject, + dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], group_dao: FromDishka[GroupDAO], test_dao: FromDishka[TestDAO], config: FromDishka[Config], - dialog_manager: DialogManager, ) -> None: assert message.from_user is not None @@ -156,9 +156,9 @@ async def start_with_deeplink( @router.message(CommandStart()) async def start_handler( message: Message, + dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], group_dao: FromDishka[GroupDAO], - dialog_manager: DialogManager ) -> None: is_registered = await ensure_user_registered( user_dao, group_dao, message, dialog_manager @@ -169,12 +169,12 @@ async def start_handler( @router.message(Command("admin")) -async def admin_command(message: Message, dialog_manager: DialogManager) -> None: +async def admin_command(_message: Message, dialog_manager: DialogManager) -> None: await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) @router.message(Command("creator")) -async def creator_command(message: Message, dialog_manager: DialogManager) -> None: +async def creator_command(_message: Message, dialog_manager: DialogManager) -> None: await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) diff --git a/src/trudex/application/bot/user_dialogs/deeplink.py b/src/trudex/application/bot/user_dialogs/deeplink.py index 8f634f2..1cd907d 100644 --- a/src/trudex/application/bot/user_dialogs/deeplink.py +++ b/src/trudex/application/bot/user_dialogs/deeplink.py @@ -1,5 +1,3 @@ -from datetime import datetime - from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput @@ -9,38 +7,23 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserTestSG -from trudex.domain.schemas import Test, User from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.models import QuestionType from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -async def validate_test_access(test: Test | None, user: User | None) -> tuple[bool, str]: - if not test: - return False, "❌ Тест не найден" - - if not test.is_active: - return False, "❌ Тест деактивирован" - - if test.expires_at and test.expires_at < datetime.utcnow(): - return False, "❌ Срок действия теста истек" - - if test.for_group and user and user.group != test.for_group: - return False, f"❌ Тест доступен только для группы {test.for_group}" - - return True, "" - - @inject async def get_deeplink_test_data( dialog_manager: DialogManager, test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], - **_kwargs + **_kwargs, ): - test_id = dialog_manager.start_data.get("test_id") if dialog_manager.start_data else None - error = dialog_manager.start_data.get("error") if dialog_manager.start_data else None + start_data = dialog_manager.start_data or {} + assert isinstance(start_data, dict) + test_id = start_data.get("test_id") + error = start_data.get("error") if error: return {"test_info": error, "can_start": False} @@ -78,7 +61,10 @@ async def on_start_deeplink_test( test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], ): + assert _callback.from_user is not None + start_data = manager.start_data or {} + assert isinstance(start_data, dict) test_id = start_data.get("test_id") user_id = _callback.from_user.id @@ -156,7 +142,10 @@ async def on_deeplink_password_input( test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], ): + assert message.from_user is not None + start_data = manager.start_data or {} + assert isinstance(start_data, dict) test_id = start_data.get("test_id") if not test_id: diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index e5530bc..b5b09bc 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window @@ -21,8 +21,9 @@ async def get_user_data( dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], attempt_repo: FromDishka[TestAttemptRepository], - **_kwargs + **_kwargs, ): + assert dialog_manager.event.from_user is not None user_id = dialog_manager.event.from_user.id user = await user_dao.get_by_id(user_id) stats = await attempt_repo.get_user_stats(user_id) @@ -53,11 +54,11 @@ async def get_user_data( def can_edit_field(updated_at: datetime | None) -> bool: if updated_at is None: return True - return datetime.utcnow() - updated_at >= timedelta(hours=24) + return datetime.now(timezone.utc) - updated_at >= timedelta(hours=24) def get_remaining_time(updated_at: datetime) -> str: - remaining = timedelta(hours=24) - (datetime.utcnow() - updated_at) + remaining = timedelta(hours=24) - (datetime.now(timezone.utc) - updated_at) hours = int(remaining.total_seconds() // 3600) minutes = int((remaining.total_seconds() % 3600) // 60) return f"{hours}ч {minutes}м" @@ -68,14 +69,16 @@ async def on_edit_name_clicked( _callback: CallbackQuery, _button: Button, manager: DialogManager, - user_dao: FromDishka[UserDAO] + user_dao: FromDishka[UserDAO], ): + assert _callback.from_user is not None user = await user_dao.get_by_id(_callback.from_user.id) if not user: await _callback.answer("❌ Пользователь не найден") return if not can_edit_field(user.name_updated_at): + assert user.name_updated_at is not None remaining = get_remaining_time(user.name_updated_at) await _callback.answer(f"⏳ Изменить можно через {remaining}") return @@ -88,14 +91,16 @@ async def on_edit_group_clicked( _callback: CallbackQuery, _button: Button, manager: DialogManager, - user_dao: FromDishka[UserDAO] + user_dao: FromDishka[UserDAO], ): + assert _callback.from_user is not None user = await user_dao.get_by_id(_callback.from_user.id) if not user: await _callback.answer("❌ Пользователь не найден") return if not can_edit_field(user.group_updated_at): + assert user.group_updated_at is not None remaining = get_remaining_time(user.group_updated_at) await _callback.answer(f"⏳ Изменить можно через {remaining}") return @@ -120,14 +125,15 @@ async def on_name_input( message: Message, _widget: MessageInput, manager: DialogManager, - user_dao: FromDishka[UserDAO] + user_dao: FromDishka[UserDAO], ): + assert message.from_user is not None if not message.text or len(message.text.strip()) < 2: await message.answer("❌ Имя должно содержать минимум 2 символа") return name = message.text.strip()[:128] - await user_dao.update(message.from_user.id, name=name, name_updated_at=datetime.utcnow()) + await user_dao.update(message.from_user.id, name=name, name_updated_at=datetime.now(timezone.utc)) await message.answer("✅ Имя обновлено") await manager.switch_to(UserMenuSG.main) @@ -144,9 +150,10 @@ async def on_group_selected( _widget, manager: DialogManager, item_id: str, - user_dao: FromDishka[UserDAO] + user_dao: FromDishka[UserDAO], ): - await user_dao.update(_callback.from_user.id, group=int(item_id), group_updated_at=datetime.utcnow()) + assert _callback.from_user is not None + await user_dao.update(_callback.from_user.id, group=int(item_id), group_updated_at=datetime.now(timezone.utc)) await _callback.answer("✅ Группа обновлена") await manager.switch_to(UserMenuSG.main) @@ -156,8 +163,9 @@ async def get_available_tests( dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], test_repo: FromDishka[TestRepository], - **_kwargs + **_kwargs, ): + assert dialog_manager.event.from_user is not None user_id = dialog_manager.event.from_user.id user = await user_dao.get_by_id(user_id) @@ -186,9 +194,9 @@ async def get_test_detail( dialog_manager: DialogManager, test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], - user_dao: FromDishka[UserDAO], - **_kwargs + **_kwargs, ): + assert dialog_manager.event.from_user is not None test_id = dialog_manager.dialog_data.get("selected_test_id") user_id = dialog_manager.event.from_user.id @@ -200,7 +208,6 @@ async def get_test_detail( if not test: return {"test_info": "❌ Тест не найден"} - user = await user_dao.get_by_id(user_id) attempts = await attempt_repo.get_user_test_attempts(user_id, test_id) finished_attempts = [a for a in attempts if a.finished_at] @@ -226,8 +233,9 @@ async def get_test_detail( async def get_my_results( dialog_manager: DialogManager, attempt_repo: FromDishka[TestAttemptRepository], - **_kwargs + **_kwargs, ): + assert dialog_manager.event.from_user is not None user_id = dialog_manager.event.from_user.id attempts_with_tests = await attempt_repo.get_finished_attempts_with_tests(user_id) diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/trudex/application/bot/user_dialogs/registration.py index 5e837cd..916dcca 100644 --- a/src/trudex/application/bot/user_dialogs/registration.py +++ b/src/trudex/application/bot/user_dialogs/registration.py @@ -16,7 +16,13 @@ from trudex.infrastructure.database.dao.user import UserDAO @inject -async def on_name_input(message: Message, _widget: MessageInput, manager: DialogManager, user_dao: FromDishka[UserDAO]): +async def on_name_input( + message: Message, + _widget: MessageInput, + manager: DialogManager, + user_dao: FromDishka[UserDAO], +): + assert message.from_user is not None if not message.text: await message.answer("❌ Имя и фамилия не могут быть пустыми") return @@ -31,8 +37,10 @@ async def on_name_input(message: Message, _widget: MessageInput, manager: Dialog return start_data = manager.start_data or {} + assert isinstance(start_data, dict) user_id = start_data.get("user_id") - await user_dao.update(user_id=user_id, name=name) + if user_id: + await user_dao.update(user_id=user_id, name=name) manager.dialog_data["name"] = name await manager.switch_to(UserRegistrationSG.select_group) @@ -48,12 +56,21 @@ async def get_groups_for_registration(group_dao: FromDishka[GroupDAO], **_kwargs @inject -async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, user_dao: FromDishka[UserDAO]): +async def on_group_selected( + _callback: CallbackQuery, + _widget, + manager: DialogManager, + item_id: str, + user_dao: FromDishka[UserDAO], +): + assert _callback.from_user is not None start_data = manager.start_data or {} + assert isinstance(start_data, dict) user_id = start_data.get("user_id") pending_test_id = start_data.get("pending_test_id") - await user_dao.update(user_id=user_id, group=int(item_id)) + if user_id: + await user_dao.update(user_id=user_id, group=int(item_id)) if pending_test_id: await manager.start( diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index 8b3b349..bcc4d1c 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window @@ -34,6 +34,7 @@ async def on_start_test( test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], ): + assert _callback.from_user is not None test_id = manager.dialog_data.get("selected_test_id") user_id = _callback.from_user.id @@ -50,7 +51,7 @@ async def on_start_test( await _callback.answer("❌ Тест деактивирован") return - if test.expires_at and test.expires_at < datetime.utcnow(): + if test.expires_at and test.expires_at < datetime.now(timezone.utc): await _callback.answer("❌ Срок действия теста истек") return @@ -101,7 +102,9 @@ async def on_password_input( test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], ): + assert message.from_user is not None start_data = manager.start_data or {} + assert isinstance(start_data, dict) test_id = start_data.get("test_id") if not test_id: @@ -146,6 +149,7 @@ async def on_cancel_test( attempt_repo: FromDishka[TestAttemptRepository], ): start_data = manager.start_data or {} + assert isinstance(start_data, dict) attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") if attempt_id: @@ -159,9 +163,10 @@ async def on_cancel_test( async def get_question_data( dialog_manager: DialogManager, test_repo: FromDishka[TestRepository], - **_kwargs + **_kwargs, ): start_data = dialog_manager.start_data or {} + assert isinstance(start_data, dict) current_index = dialog_manager.dialog_data.get("current_question_index") if current_index is None: @@ -188,6 +193,7 @@ async def get_question_data( async def on_single_answer_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): start_data = manager.start_data or {} + assert isinstance(start_data, dict) current_index = manager.dialog_data.get("current_question_index") if current_index is None: current_index = start_data.get("current_question_index", 0) @@ -204,6 +210,7 @@ async def on_single_answer_selected(_callback: CallbackQuery, _widget, manager: async def on_multiple_answer_changed(_event, widget, manager: DialogManager, _data: str): start_data = manager.start_data or {} + assert isinstance(start_data, dict) current_index = manager.dialog_data.get("current_question_index") if current_index is None: current_index = start_data.get("current_question_index", 0) @@ -230,6 +237,7 @@ async def on_text_answer_input( answer_dao: FromDishka[UserAnswerDAO], ): start_data = manager.start_data or {} + assert isinstance(start_data, dict) current_index = manager.dialog_data.get("current_question_index") if current_index is None: current_index = start_data.get("current_question_index", 0) @@ -285,6 +293,7 @@ async def on_next_question( answer_dao: FromDishka[UserAnswerDAO], ): start_data = manager.start_data or {} + assert isinstance(start_data, dict) current_index = manager.dialog_data.get("current_question_index") if current_index is None: current_index = start_data.get("current_question_index", 0) @@ -409,9 +418,10 @@ async def get_detailed_results_data( dialog_manager: DialogManager, attempt_repo: FromDishka[TestAttemptRepository], test_repo: FromDishka[TestRepository], - **_kwargs + **_kwargs, ): start_data = dialog_manager.start_data or {} + assert isinstance(start_data, dict) attempt_id = dialog_manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") if not attempt_id: From c80e8c693586c516e8cfcc9a3c488ad71b5f10b4 Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 3 Jan 2026 23:58:15 +0300 Subject: [PATCH 38/57] commit --- .../application/bot/admin_dialogs/tests.py | 5 +- .../application/bot/user_dialogs/main_menu.py | 47 ++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 0bf85e0..043e3c9 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -14,6 +14,7 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, AdminTestsSG) +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 @@ -393,8 +394,8 @@ async def on_remove_expires(_callback: CallbackQuery, _button: Button, manager: await manager.switch_to(AdminTestsSG.test_detail) -async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager): - await _callback.answer("Добавление теста") +async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(CreateTestSG.input_title, mode=StartMode.RESET_STACK) async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index b5b09bc..f37af0b 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -1,6 +1,9 @@ +import asyncio +import functools from datetime import datetime, timedelta, timezone -from aiogram.types import CallbackQuery, Message +from aiogram import Bot +from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select @@ -14,6 +17,9 @@ from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository +from trudex.infrastructure.utils.config import Config +from trudex.infrastructure.utils.qr_generator import generate_qr_bytes +from trudex.infrastructure.utils.test_id_to_hash import encode_id @inject @@ -189,6 +195,44 @@ async def on_back_to_tests(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(UserMenuSG.available_tests) +@inject +async def on_share_test( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + config: FromDishka[Config], + bot_inst: FromDishka[Bot], +): + test_id = manager.dialog_data.get("selected_test_id") + + if not test_id: + await _callback.answer("Ошибка: тест не найден") + return + + test_hash = encode_id( + test_id, + config.security.encode_key, + config.security.encoded_string_length, + ) + + bot_info = await bot_inst.get_me() + bot_username = bot_info.username or "your_bot" + share_link = f"https://t.me/{bot_username}?start={test_hash}" + + loop = asyncio.get_running_loop() + qr_bytes = await loop.run_in_executor( + None, + functools.partial(generate_qr_bytes, share_link), + ) + + assert _callback.message is not None + + await _callback.message.answer_photo( + photo=BufferedInputFile(qr_bytes, filename="qr.png"), + caption=f"🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста", + ) + + @inject async def get_test_detail( dialog_manager: DialogManager, @@ -349,6 +393,7 @@ user_menu_dialog = Dialog( Format("{test_info}"), Column( Button(Const("▶️ Пройти тест"), id="start_test", on_click=on_start_test), + Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_tests), ), state=UserMenuSG.test_detail, From 53b846009b7c80ceb6d416f5436a00b6dbbb0f74 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 01:01:07 +0300 Subject: [PATCH 39/57] commit --- .../application/bot/admin_dialogs/tests.py | 15 ++-- .../bot/creator_dialogs/create_test.py | 8 ++- .../application/bot/creator_dialogs/tests.py | 15 ++-- src/trudex/application/bot/handlers.py | 5 +- .../application/bot/user_dialogs/main_menu.py | 70 +++++++++++++------ .../application/bot/user_dialogs/take_test.py | 5 +- .../infrastructure/database/dao/user.py | 4 +- .../database/repo/test_attempt.py | 4 +- src/trudex/infrastructure/scheduling/tasks.py | 6 +- src/trudex/infrastructure/utils/timezone.py | 16 +++++ 10 files changed, 97 insertions(+), 51 deletions(-) create mode 100644 src/trudex/infrastructure/utils/timezone.py diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 043e3c9..f490495 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -1,6 +1,6 @@ import asyncio import functools -from datetime import date, datetime +from datetime import date, datetime, time from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message @@ -22,6 +22,7 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id +from trudex.infrastructure.utils.timezone import MSK_TZ, to_msk @inject @@ -66,7 +67,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 = f"📅 {test.expires_at.strftime('%d.%m.%Y %H:%M')}" if test.expires_at 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 "👥 Для всех" test_info = ( @@ -79,7 +80,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T 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 '—'}" + f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" ) button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" @@ -131,7 +132,8 @@ async def get_statistics_data( results = [] for attempt, user_name in attempts_with_users: status = "✅" if attempt.is_passed else "❌" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "" results.append((f"{status} {user_name} — {attempt.score}% ({date_str})", attempt.id)) return { @@ -167,7 +169,8 @@ async def get_attempt_detail( return {"attempt_info": "❌ Результат не найден"} status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "—" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" lines = [ f"📊 Результат прохождения\n", @@ -376,7 +379,7 @@ async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, await _callback.answer("❌ Тест не найден") return - expires_at = datetime.combine(selected_date, datetime.min.time()) + expires_at = datetime.combine(selected_date, time.min, tzinfo=MSK_TZ) await test_dao.update(test_id, expires_at=expires_at) await _callback.answer("✅ Срок действия обновлен") await manager.switch_to(AdminTestsSG.test_detail) diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index 30de6ef..5c89ef8 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, time from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window @@ -16,6 +16,7 @@ from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.utils.timezone import MSK_TZ, to_msk async def on_title_input(message: Message, _widget: MessageInput, manager: DialogManager): @@ -110,7 +111,7 @@ async def on_skip_attempts(_callback: CallbackQuery, _button: Button, manager: D async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): - manager.dialog_data["expires_at"] = datetime.combine(selected_date, datetime.min.time()) + manager.dialog_data["expires_at"] = datetime.combine(selected_date, time.min, tzinfo=MSK_TZ) await manager.switch_to(CreateTestSG.input_for_group) @@ -148,7 +149,8 @@ async def get_test_info(dialog_manager: DialogManager, **_kwargs): 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 "Без срока" + 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 "Для всех" return { diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index a522a85..71465d1 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -1,6 +1,6 @@ import asyncio import functools -from datetime import date, datetime +from datetime import date, datetime, time from aiogram import Bot from aiogram.enums import ContentType @@ -25,6 +25,7 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id +from trudex.infrastructure.utils.timezone import MSK_TZ, to_msk @inject @@ -69,7 +70,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 = f"📅 {test.expires_at.strftime('%d.%m.%Y %H:%M')}" if test.expires_at 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 "👥 Для всех" test_info = ( @@ -82,7 +83,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T 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 '—'}" + f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" ) button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" @@ -134,7 +135,8 @@ async def get_statistics_data( results = [] for attempt, user_name in attempts_with_users: status = "✅" if attempt.is_passed else "❌" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "" results.append((f"{status} {user_name} — {attempt.score}% ({date_str})", attempt.id)) return { @@ -170,7 +172,8 @@ async def get_attempt_detail( return {"attempt_info": "❌ Результат не найден"} status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "—" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" lines = [ f"📊 Результат прохождения\n", @@ -380,7 +383,7 @@ async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, await _callback.answer("❌ Тест не найден") return - expires_at = datetime.combine(selected_date, datetime.min.time()) + expires_at = datetime.combine(selected_date, time.min, tzinfo=MSK_TZ) await test_dao.update(test_id, expires_at=expires_at) await _callback.answer("✅ Срок действия обновлен") await manager.switch_to(CreatorTestsSG.test_detail) diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 46b9fc1..57fad4d 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,5 +1,3 @@ -from datetime import datetime, timezone - from aiogram import Router from aiogram.filters import Command, CommandStart, CommandObject from aiogram.types import ErrorEvent, Message @@ -19,6 +17,7 @@ from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.test_id_to_hash import decode_id +from trudex.infrastructure.utils.timezone import now_msk router = Router() @@ -93,7 +92,7 @@ async def validate_deeplink_test( if not test.is_active: return False, "❌ Тест деактивирован" - if test.expires_at and test.expires_at < datetime.now(timezone.utc): + if test.expires_at and test.expires_at < now_msk(): return False, "❌ Срок действия теста истек" user = await user_dao.get_by_id(user_id) diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index f37af0b..b695639 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -1,6 +1,6 @@ import asyncio import functools -from datetime import datetime, timedelta, timezone +from datetime import timedelta from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message @@ -20,6 +20,25 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id +from trudex.infrastructure.utils.timezone import now_msk, to_msk +from datetime import datetime + + +def can_edit_field(updated_at: datetime | None) -> bool: + if updated_at is None: + return True + updated_at_msk = to_msk(updated_at) + assert updated_at_msk is not None + return now_msk() - updated_at_msk >= timedelta(hours=24) + + +def get_remaining_time(updated_at: datetime) -> str: + updated_at_msk = to_msk(updated_at) + assert updated_at_msk is not None + remaining = timedelta(hours=24) - (now_msk() - updated_at_msk) + hours = int(remaining.total_seconds() // 3600) + minutes = int((remaining.total_seconds() % 3600) // 60) + return f"{hours}ч {minutes}м" @inject @@ -57,19 +76,6 @@ async def get_user_data( return {"user_info": user_info} -def can_edit_field(updated_at: datetime | None) -> bool: - if updated_at is None: - return True - return datetime.now(timezone.utc) - updated_at >= timedelta(hours=24) - - -def get_remaining_time(updated_at: datetime) -> str: - remaining = timedelta(hours=24) - (datetime.now(timezone.utc) - updated_at) - hours = int(remaining.total_seconds() // 3600) - minutes = int((remaining.total_seconds() % 3600) // 60) - return f"{hours}ч {minutes}м" - - @inject async def on_edit_name_clicked( _callback: CallbackQuery, @@ -79,6 +85,7 @@ async def on_edit_name_clicked( ): assert _callback.from_user is not None user = await user_dao.get_by_id(_callback.from_user.id) + if not user: await _callback.answer("❌ Пользователь не найден") return @@ -101,6 +108,7 @@ async def on_edit_group_clicked( ): assert _callback.from_user is not None user = await user_dao.get_by_id(_callback.from_user.id) + if not user: await _callback.answer("❌ Пользователь не найден") return @@ -139,8 +147,15 @@ async def on_name_input( return name = message.text.strip()[:128] - await user_dao.update(message.from_user.id, name=name, name_updated_at=datetime.now(timezone.utc)) - await message.answer("✅ Имя обновлено") + result = await user_dao.update( + user_id=message.from_user.id, + name=name, + name_updated_at=now_msk(), + ) + if result: + await message.answer("✅ Имя обновлено") + else: + await message.answer("❌ Не удалось обновить имя") await manager.switch_to(UserMenuSG.main) @@ -159,8 +174,15 @@ async def on_group_selected( user_dao: FromDishka[UserDAO], ): assert _callback.from_user is not None - await user_dao.update(_callback.from_user.id, group=int(item_id), group_updated_at=datetime.now(timezone.utc)) - await _callback.answer("✅ Группа обновлена") + result = await user_dao.update( + user_id=_callback.from_user.id, + group=int(item_id), + group_updated_at=now_msk(), + ) + if result: + await _callback.answer("✅ Группа обновлена") + else: + await _callback.answer("❌ Не удалось обновить группу") await manager.switch_to(UserMenuSG.main) @@ -255,9 +277,11 @@ async def get_test_detail( attempts = await attempt_repo.get_user_test_attempts(user_id, test_id) finished_attempts = [a for a in attempts if a.finished_at] - password_str = f"🔒 Требуется пароль" if test.password else "🔓 Без пароля" + password_str = "🔒 Требуется пароль" if test.password else "🔓 Без пароля" attempts_str = f"🔄 Попыток: {len(finished_attempts)}/{test.attempts}" if test.attempts else f"🔄 Попыток: {len(finished_attempts)}/♾️" - expires_str = f"📅 До {test.expires_at.strftime('%d.%m.%Y %H:%M')}" if test.expires_at 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 "📅 Без срока" group_str = f"🎓 Для группы {test.for_group}" if test.for_group else "👥 Для всех" test_info = ( @@ -286,7 +310,8 @@ async def get_my_results( results = [] for attempt, test_title in attempts_with_tests: status = "✅" if attempt.is_passed else "❌" - date_str = attempt.finished_at.strftime("%d.%m.%Y") if attempt.finished_at else "" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y") if finished_at_msk else "" results.append((f"{status} {test_title} — {attempt.score}% ({date_str})", attempt.id)) return { @@ -325,7 +350,8 @@ async def get_result_detail( test_title = test.title if test else "Неизвестный тест" status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" - date_str = attempt.finished_at.strftime("%d.%m.%Y %H:%M") if attempt.finished_at else "—" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" lines = [ f"📝 {test_title}\n", diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index bcc4d1c..6c40969 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -1,5 +1,3 @@ -from datetime import datetime, timezone - from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput @@ -14,6 +12,7 @@ from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.models import QuestionType from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository +from trudex.infrastructure.utils.timezone import now_msk async def get_state_for_question_type(question_type: str): @@ -51,7 +50,7 @@ async def on_start_test( await _callback.answer("❌ Тест деактивирован") return - if test.expires_at and test.expires_at < datetime.now(timezone.utc): + if test.expires_at and test.expires_at < now_msk(): await _callback.answer("❌ Срок действия теста истек") return diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index c4144b8..1d08789 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -108,7 +108,7 @@ class UserDAO: last_name: str | None = None, name: str | None = None, group: int | None = None, - is_admin: bool = False, + is_admin: bool | None = None, ) -> DomainUser: result = await self.session.execute( select(User).where(User.id == user_id) @@ -139,5 +139,5 @@ class UserDAO: last_name=last_name, name=name, group=group, - is_admin=is_admin, + is_admin=is_admin or False, ) diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index 9a822c2..b4fcbd4 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import final from sqlalchemy import func, select @@ -13,6 +12,7 @@ from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO from trudex.infrastructure.database.models import \ TestAttempt as TestAttemptModel from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel +from trudex.infrastructure.utils.timezone import now_msk @final @@ -132,7 +132,7 @@ class TestAttemptRepository: async def finish_attempt(self, attempt_id: int, score: int, is_passed: bool) -> TestAttempt | None: return await self.attempt_dao.update( attempt_id=attempt_id, - finished_at=datetime.utcnow(), + finished_at=now_msk(), score=score, is_passed=is_passed ) diff --git a/src/trudex/infrastructure/scheduling/tasks.py b/src/trudex/infrastructure/scheduling/tasks.py index baefebf..0a99ccf 100644 --- a/src/trudex/infrastructure/scheduling/tasks.py +++ b/src/trudex/infrastructure/scheduling/tasks.py @@ -1,9 +1,7 @@ -from datetime import datetime - from dishka import AsyncContainer from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.models import Test +from trudex.infrastructure.utils.timezone import now_msk async def deactivate_expired_tests(container: AsyncContainer): @@ -13,5 +11,5 @@ async def deactivate_expired_tests(container: AsyncContainer): tests = await test_dao.get_all() for test in tests: - if test.expires_at and test.expires_at < datetime.utcnow() and test.is_active: + if test.expires_at and test.expires_at < now_msk() and test.is_active: await test_dao.update(test.id, is_active=False) diff --git a/src/trudex/infrastructure/utils/timezone.py b/src/trudex/infrastructure/utils/timezone.py new file mode 100644 index 0000000..dea7c88 --- /dev/null +++ b/src/trudex/infrastructure/utils/timezone.py @@ -0,0 +1,16 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +MSK_TZ = ZoneInfo("Europe/Moscow") + + +def now_msk() -> datetime: + return datetime.now(MSK_TZ) + + +def to_msk(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=MSK_TZ) + return dt.astimezone(MSK_TZ) From ad8eb5b5ee9cadd607a2d715463c4acac2a748c0 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 01:10:51 +0300 Subject: [PATCH 40/57] commit --- .../application/bot/creator_dialogs/states.py | 1 + .../application/bot/creator_dialogs/users.py | 61 +++++++++++++++++-- .../infrastructure/database/repo/test.py | 1 + 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index bc74887..2b0909f 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -10,6 +10,7 @@ class CreatorUsersSG(StatesGroup): users_input = State() user_detail = State() make_admin_confirm = State() + remove_admin_confirm = State() class CreatorTestsSG(StatesGroup): diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index 9b92fb8..3d5e741 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -1,3 +1,6 @@ +import asyncio + +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 @@ -10,18 +13,22 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import (CreatorMenuSG, CreatorUsersSG) from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.database.repo.user import UserRepository +from trudex.infrastructure.utils.bot_commands import setup_bot_commands +from trudex.infrastructure.utils.config import Config @inject async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): users = await user_dao.get_all() + users_sorted = sorted(users, key=lambda u: u.created_at, reverse=True) return { "users": [ - (f"{u.name or u.first_name} (@{u.username or 'нет'})", u.id) - for u in users + (f"{'👑 ' if u.is_admin else ''}{u.name or u.first_name} (@{u.username or 'нет'})", u.id) + for u in users_sorted ], - "count": len(users), + "count": len(users_sorted), } @@ -54,6 +61,7 @@ async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDish "user_info": user_info, "is_admin": user.is_admin, "show_make_admin": not user.is_admin, + "show_remove_admin": user.is_admin, } @@ -107,18 +115,52 @@ async def on_make_admin_clicked(_callback: CallbackQuery, _button: Button, manag await manager.switch_to(CreatorUsersSG.make_admin_confirm) +async def on_remove_admin_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorUsersSG.remove_admin_confirm) + + @inject -async def on_confirm_yes(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): +async def on_confirm_yes( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + user_dao: FromDishka[UserDAO], + user_repo: FromDishka[UserRepository], + bot: FromDishka[Bot], + config: FromDishka[Config], +): user_id = manager.dialog_data.get("selected_user_id") if not user_id: await _callback.answer("Ошибка: пользователь не выбран") return await user_dao.update(user_id=user_id, is_admin=True) + asyncio.create_task(setup_bot_commands(bot, config, user_repo)) await _callback.answer("✅ Пользователь назначен администратором") await manager.switch_to(CreatorUsersSG.user_detail) +@inject +async def on_remove_admin_confirm_yes( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + user_dao: FromDishka[UserDAO], + user_repo: FromDishka[UserRepository], + bot: FromDishka[Bot], + config: FromDishka[Config], +): + user_id = manager.dialog_data.get("selected_user_id") + if not user_id: + await _callback.answer("Ошибка: пользователь не выбран") + return + + await user_dao.update(user_id=user_id, is_admin=False) + asyncio.create_task(setup_bot_commands(bot, config, user_repo)) + await _callback.answer("✅ Пользователь снят с должности администратора") + await manager.switch_to(CreatorUsersSG.user_detail) + + async def on_confirm_no(_callback: CallbackQuery, _button: Button, manager: DialogManager): await _callback.answer("Отменено") await manager.switch_to(CreatorUsersSG.user_detail) @@ -160,6 +202,7 @@ users_dialog = Dialog( Format("{user_info}"), Column( Button(Const("👑 Сделать администратором"), id="make_admin", on_click=on_make_admin_clicked, when="show_make_admin"), + Button(Const("🚫 Снять администратора"), id="remove_admin", on_click=on_remove_admin_clicked, when="show_remove_admin"), SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorUsersSG.users_list), ), state=CreatorUsersSG.user_detail, @@ -175,4 +218,14 @@ users_dialog = Dialog( state=CreatorUsersSG.make_admin_confirm, getter=get_confirm_data, ), + Window( + Const("⚠️ Подтверждение\n\nВы уверены, что хотите снять этого пользователя с должности администратора?\n\n"), + Format("{user_info}"), + Row( + Button(Const("✅ Да"), id="confirm_yes", on_click=on_remove_admin_confirm_yes), + Button(Const("❌ Нет"), id="confirm_no", on_click=on_confirm_no), + ), + state=CreatorUsersSG.remove_admin_confirm, + getter=get_confirm_data, + ), ) diff --git a/src/trudex/infrastructure/database/repo/test.py b/src/trudex/infrastructure/database/repo/test.py index 4597f64..521b913 100644 --- a/src/trudex/infrastructure/database/repo/test.py +++ b/src/trudex/infrastructure/database/repo/test.py @@ -179,6 +179,7 @@ class TestRepository: (subquery.c.attempts_count.is_(None)) | (subquery.c.attempts_count < TestModel.attempts) ) + .order_by(TestModel.created_at.desc()) ) result = await self.session.execute(query) From 05dd721f60bb0911af49d11b83f710a4b59ed2e6 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 01:20:00 +0300 Subject: [PATCH 41/57] commit --- src/trudex/application/bot/admin_dialogs/tests.py | 4 ++-- src/trudex/application/bot/creator_dialogs/create_test.py | 6 +++--- src/trudex/application/bot/creator_dialogs/tests.py | 4 ++-- src/trudex/application/bot/handlers.py | 4 ++-- src/trudex/application/bot/user_dialogs/main_menu.py | 6 +++--- src/trudex/application/bot/user_dialogs/take_test.py | 4 ++-- src/trudex/infrastructure/api/__init__.py | 1 - src/trudex/infrastructure/database/repo/test_attempt.py | 4 ++-- src/trudex/infrastructure/scheduling/tasks.py | 4 ++-- src/trudex/infrastructure/utils/timezone.py | 5 +++++ 10 files changed, 23 insertions(+), 19 deletions(-) delete mode 100644 src/trudex/infrastructure/api/__init__.py diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index f490495..fb2faf3 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -22,7 +22,7 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id -from trudex.infrastructure.utils.timezone import MSK_TZ, to_msk +from trudex.infrastructure.utils.timezone import to_msk @inject @@ -379,7 +379,7 @@ async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, await _callback.answer("❌ Тест не найден") return - expires_at = datetime.combine(selected_date, time.min, tzinfo=MSK_TZ) + expires_at = datetime.combine(selected_date, time.min) await test_dao.update(test_id, expires_at=expires_at) await _callback.answer("✅ Срок действия обновлен") await manager.switch_to(AdminTestsSG.test_detail) diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index 5c89ef8..513a21c 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -16,7 +16,7 @@ from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.utils.timezone import MSK_TZ, to_msk +from trudex.infrastructure.utils.timezone import to_msk async def on_title_input(message: Message, _widget: MessageInput, manager: DialogManager): @@ -111,7 +111,7 @@ async def on_skip_attempts(_callback: CallbackQuery, _button: Button, manager: D async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): - manager.dialog_data["expires_at"] = datetime.combine(selected_date, time.min, tzinfo=MSK_TZ) + manager.dialog_data["expires_at"] = datetime.combine(selected_date, time.min) await manager.switch_to(CreateTestSG.input_for_group) @@ -537,7 +537,7 @@ create_test_dialog = Dialog( getter=get_question_type_data, ), Window( - Const("✏️ Правильный ответ\n\n💬 Введите правильный ответ (для проверки будет использоваться точное совпадение):\n(максимум 255 символов)"), + Const("✏️ Правильный ответ\n\n💬 Введите правильный ответ (регистр и пробелы игнорируются):\n(максимум 255 символов)"), MessageInput(on_correct_answer_input), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), state=CreateTestSG.input_correct_answer, diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 71465d1..524a95a 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -25,7 +25,7 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id -from trudex.infrastructure.utils.timezone import MSK_TZ, to_msk +from trudex.infrastructure.utils.timezone import to_msk @inject @@ -383,7 +383,7 @@ async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, await _callback.answer("❌ Тест не найден") return - expires_at = datetime.combine(selected_date, time.min, tzinfo=MSK_TZ) + expires_at = datetime.combine(selected_date, time.min) await test_dao.update(test_id, expires_at=expires_at) await _callback.answer("✅ Срок действия обновлен") await manager.switch_to(CreatorTestsSG.test_detail) diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 57fad4d..75b0570 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -17,7 +17,7 @@ from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.test_id_to_hash import decode_id -from trudex.infrastructure.utils.timezone import now_msk +from trudex.infrastructure.utils.timezone import now_msk_naive router = Router() @@ -92,7 +92,7 @@ async def validate_deeplink_test( if not test.is_active: return False, "❌ Тест деактивирован" - if test.expires_at and test.expires_at < now_msk(): + if test.expires_at and test.expires_at < now_msk_naive(): return False, "❌ Срок действия теста истек" user = await user_dao.get_by_id(user_id) diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index b695639..122e8de 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -20,7 +20,7 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id -from trudex.infrastructure.utils.timezone import now_msk, to_msk +from trudex.infrastructure.utils.timezone import now_msk, now_msk_naive, to_msk from datetime import datetime @@ -150,7 +150,7 @@ async def on_name_input( result = await user_dao.update( user_id=message.from_user.id, name=name, - name_updated_at=now_msk(), + name_updated_at=now_msk_naive(), ) if result: await message.answer("✅ Имя обновлено") @@ -177,7 +177,7 @@ async def on_group_selected( result = await user_dao.update( user_id=_callback.from_user.id, group=int(item_id), - group_updated_at=now_msk(), + group_updated_at=now_msk_naive(), ) if result: await _callback.answer("✅ Группа обновлена") diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index 6c40969..8a8a16d 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -12,7 +12,7 @@ from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.models import QuestionType from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.utils.timezone import now_msk +from trudex.infrastructure.utils.timezone import now_msk_naive async def get_state_for_question_type(question_type: str): @@ -50,7 +50,7 @@ async def on_start_test( await _callback.answer("❌ Тест деактивирован") return - if test.expires_at and test.expires_at < now_msk(): + if test.expires_at and test.expires_at < now_msk_naive(): await _callback.answer("❌ Срок действия теста истек") return diff --git a/src/trudex/infrastructure/api/__init__.py b/src/trudex/infrastructure/api/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/src/trudex/infrastructure/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index b4fcbd4..1ec61fa 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -12,7 +12,7 @@ from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO from trudex.infrastructure.database.models import \ TestAttempt as TestAttemptModel from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel -from trudex.infrastructure.utils.timezone import now_msk +from trudex.infrastructure.utils.timezone import now_msk_naive @final @@ -132,7 +132,7 @@ class TestAttemptRepository: async def finish_attempt(self, attempt_id: int, score: int, is_passed: bool) -> TestAttempt | None: return await self.attempt_dao.update( attempt_id=attempt_id, - finished_at=now_msk(), + finished_at=now_msk_naive(), score=score, is_passed=is_passed ) diff --git a/src/trudex/infrastructure/scheduling/tasks.py b/src/trudex/infrastructure/scheduling/tasks.py index 0a99ccf..5667ccb 100644 --- a/src/trudex/infrastructure/scheduling/tasks.py +++ b/src/trudex/infrastructure/scheduling/tasks.py @@ -1,7 +1,7 @@ from dishka import AsyncContainer from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.utils.timezone import now_msk +from trudex.infrastructure.utils.timezone import now_msk_naive async def deactivate_expired_tests(container: AsyncContainer): @@ -11,5 +11,5 @@ async def deactivate_expired_tests(container: AsyncContainer): tests = await test_dao.get_all() for test in tests: - if test.expires_at and test.expires_at < now_msk() and test.is_active: + if test.expires_at and test.expires_at < now_msk_naive() and test.is_active: await test_dao.update(test.id, is_active=False) diff --git a/src/trudex/infrastructure/utils/timezone.py b/src/trudex/infrastructure/utils/timezone.py index dea7c88..6236e2b 100644 --- a/src/trudex/infrastructure/utils/timezone.py +++ b/src/trudex/infrastructure/utils/timezone.py @@ -8,6 +8,11 @@ def now_msk() -> datetime: return datetime.now(MSK_TZ) +def now_msk_naive() -> datetime: + """Возвращает текущее время в МСК без timezone info (для сохранения в БД).""" + return datetime.now(MSK_TZ).replace(tzinfo=None) + + def to_msk(dt: datetime | None) -> datetime | None: if dt is None: return None From 1a8da5c070601f12eb5ee009e65b25cbd5f8227f Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 01:39:35 +0300 Subject: [PATCH 42/57] commit --- ...7720a4_add_are_results_viewable_to_test.py | 29 ++++++++++++ .../application/bot/admin_dialogs/tests.py | 29 +++++++++++- .../application/bot/creator_dialogs/tests.py | 29 +++++++++++- .../application/bot/user_dialogs/main_menu.py | 47 +++++++++++-------- .../application/bot/user_dialogs/take_test.py | 42 +++++++++++------ src/trudex/domain/schemas.py | 1 + .../infrastructure/database/dao/test.py | 7 ++- .../infrastructure/database/dao/user.py | 4 +- .../infrastructure/database/dto/test.py | 1 + src/trudex/infrastructure/database/models.py | 1 + 10 files changed, 153 insertions(+), 37 deletions(-) create mode 100644 alembic/versions/40f5317720a4_add_are_results_viewable_to_test.py diff --git a/alembic/versions/40f5317720a4_add_are_results_viewable_to_test.py b/alembic/versions/40f5317720a4_add_are_results_viewable_to_test.py new file mode 100644 index 0000000..bfffd6c --- /dev/null +++ b/alembic/versions/40f5317720a4_add_are_results_viewable_to_test.py @@ -0,0 +1,29 @@ +"""add_are_results_viewable_to_test + +Revision ID: 40f5317720a4 +Revises: e002f2b802ec +Create Date: 2026-01-04 01:29:18.257105 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = '40f5317720a4' +down_revision: str | None = 'e002f2b802ec' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tests', sa.Column('are_results_viewable', sa.Boolean(), nullable=False, server_default=sa.text('false'))) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tests', 'are_results_viewable') + # ### end Alembic commands ### diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index fb2faf3..8363be1 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -52,6 +52,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", + "results_button_text": "👁 Показать результаты", } test = await test_dao.get_by_id(test_id) @@ -62,6 +63,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", + "results_button_text": "👁 Показать результаты", } status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" @@ -69,6 +71,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T attempts_str = f"🔄 {test.attempts}" if test.attempts 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 "🔒 Результаты скрыты" test_info = ( f"📝 Информация о тесте\n\n" @@ -79,16 +82,19 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T f"Пароль: {password_str}\n" f"Попытки: {attempts_str}\n" f"Срок: {expires_str}\n" - f"Группа: {group_str}\n\n" + f"Группа: {group_str}\n" + f"Видимость: {results_str}\n\n" f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" ) button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" + results_button_text = "🔒 Скрыть результаты" if test.are_results_viewable else "👁 Показать результаты" return { "test_info": test_info, "is_active": test.is_active, "button_text": button_text, + "results_button_text": results_button_text, } @@ -108,6 +114,22 @@ async def on_toggle_active(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(AdminTestsSG.test_detail) +@inject +async def on_toggle_results_viewable(_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 + + test = await test_dao.get_by_id(test_id) + + if test: + await test_dao.update(test_id, are_results_viewable=not test.are_results_viewable) + action = "скрыты" if test.are_results_viewable else "видны" + await _callback.answer(f"✅ Результаты теперь {action}") + await manager.switch_to(AdminTestsSG.test_detail) + + async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(AdminTestsSG.tests_list) @@ -435,6 +457,11 @@ tests_dialog = Dialog( id="toggle_active", on_click=on_toggle_active ), + Button( + Format("{results_button_text}"), + id="toggle_results", + on_click=on_toggle_results_viewable + ), Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics), Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu), diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 524a95a..42969ab 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -55,6 +55,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", + "results_button_text": "👁 Показать результаты", } test = await test_dao.get_by_id(test_id) @@ -65,6 +66,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T "test_info": "Тест не найден", "is_active": False, "button_text": "◀️ Назад", + "results_button_text": "👁 Показать результаты", } status = "🟢 Активен" if test.is_active else "🔴 Деактивирован" @@ -72,6 +74,7 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T attempts_str = f"🔄 {test.attempts}" if test.attempts 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 "🔒 Результаты скрыты" test_info = ( f"📝 Информация о тесте\n\n" @@ -82,16 +85,19 @@ async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[T f"Пароль: {password_str}\n" f"Попытки: {attempts_str}\n" f"Срок: {expires_str}\n" - f"Группа: {group_str}\n\n" + f"Группа: {group_str}\n" + f"Видимость: {results_str}\n\n" f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" ) button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" + results_button_text = "🔒 Скрыть результаты" if test.are_results_viewable else "👁 Показать результаты" return { "test_info": test_info, "is_active": test.is_active, "button_text": button_text, + "results_button_text": results_button_text, } @@ -111,6 +117,22 @@ async def on_toggle_active(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(CreatorTestsSG.test_detail) +@inject +async def on_toggle_results_viewable(_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 + + test = await test_dao.get_by_id(test_id) + + if test: + await test_dao.update(test_id, are_results_viewable=not test.are_results_viewable) + action = "скрыты" if test.are_results_viewable else "видны" + await _callback.answer(f"✅ Результаты теперь {action}") + await manager.switch_to(CreatorTestsSG.test_detail) + + async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager): await manager.switch_to(CreatorTestsSG.tests_list) @@ -439,6 +461,11 @@ tests_dialog = Dialog( id="toggle_active", on_click=on_toggle_active ), + Button( + Format("{results_button_text}"), + id="toggle_results", + on_click=on_toggle_results_viewable + ), Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics), Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu), diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index 122e8de..21da133 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -348,37 +348,46 @@ async def get_result_detail( test, _ = await test_repo.get_test_with_questions(attempt.test_id) test_title = test.title if test else "Неизвестный тест" + are_results_viewable = test.are_results_viewable if test else False status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" finished_at_msk = to_msk(attempt.finished_at) date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" + correct_count = sum(1 for a in answers if a.is_correct) + total_count = len(answers) + lines = [ f"📝 {test_title}\n", f"📊 Результат: {attempt.score}%", + f"✏️ Правильных ответов: {correct_count} из {total_count}", f"📅 Дата: {date_str}", - f"🏆 Статус: {status}\n", - "📋 Ответы:\n", + f"🏆 Статус: {status}", ] - for i, answer in enumerate(answers, 1): - question, options = await test_repo.get_question_with_options(answer.question_id) - if not question: - continue + if are_results_viewable: + lines.append("\n📋 Ответы:\n") - correct_options = [opt for opt in options if opt.is_correct] - correct_texts = [opt.text for opt in correct_options] - - status_icon = "✅" if answer.is_correct else "❌" - - user_answer = answer.text_answer or "" - if "|" in user_answer: - user_answer = ", ".join(user_answer.split("|")) - - lines.append(f"{status_icon} Вопрос {i}") - lines.append(f"
{question.text}
") - lines.append(f"👤 Ваш ответ: {user_answer or '—'}") - lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") + for i, answer in enumerate(answers, 1): + question, options = await test_repo.get_question_with_options(answer.question_id) + if not question: + continue + + correct_options = [opt for opt in options if opt.is_correct] + correct_texts = [opt.text for opt in correct_options] + + status_icon = "✅" if answer.is_correct else "❌" + + user_answer = answer.text_answer or "" + if "|" in user_answer: + user_answer = ", ".join(user_answer.split("|")) + + lines.append(f"{status_icon} Вопрос {i}") + lines.append(f"
{question.text}
") + lines.append(f"👤 Ваш ответ: {user_answer or '—'}") + lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") + else: + lines.append("\n🔒 Подробные результаты скрыты") return {"result_info": "\n".join(lines)} diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index 8a8a16d..6479df6 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -234,6 +234,7 @@ async def on_text_answer_input( test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], answer_dao: FromDishka[UserAnswerDAO], + test_dao: FromDishka[TestDAO], ): start_data = manager.start_data or {} assert isinstance(start_data, dict) @@ -248,6 +249,7 @@ async def on_text_answer_input( question_id = questions[current_index] text_answer = message.text.strip() if message.text else "" attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") + test_id = manager.dialog_data.get("test_id") or start_data.get("test_id") if not attempt_id: await message.answer("❌ Ошибка попытки") @@ -270,7 +272,9 @@ async def on_text_answer_input( ) if current_index + 1 >= len(questions): - await finish_test(manager, attempt_repo, attempt_id, len(questions)) + test = await test_dao.get_by_id(test_id) if test_id else None + are_results_viewable = test.are_results_viewable if test else False + await finish_test(manager, attempt_repo, attempt_id, len(questions), are_results_viewable) else: next_index = current_index + 1 manager.dialog_data["current_question_index"] = next_index @@ -290,6 +294,7 @@ async def on_next_question( test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], answer_dao: FromDishka[UserAnswerDAO], + test_dao: FromDishka[TestDAO], ): start_data = manager.start_data or {} assert isinstance(start_data, dict) @@ -311,6 +316,7 @@ async def on_next_question( answer_data = user_answers[str(question_id)] attempt_id = manager.dialog_data.get("attempt_id") or start_data.get("attempt_id") + test_id = manager.dialog_data.get("test_id") or start_data.get("test_id") if not attempt_id: await _callback.answer("❌ Ошибка попытки") @@ -352,7 +358,9 @@ async def on_next_question( ) if current_index + 1 >= len(questions): - await finish_test(manager, attempt_repo, attempt_id, len(questions)) + test = await test_dao.get_by_id(test_id) if test_id else None + are_results_viewable = test.are_results_viewable if test else False + await finish_test(manager, attempt_repo, attempt_id, len(questions), are_results_viewable) else: next_index = current_index + 1 manager.dialog_data["current_question_index"] = next_index @@ -364,7 +372,13 @@ async def on_next_question( await manager.switch_to(next_state) -async def finish_test(manager: DialogManager, attempt_repo: TestAttemptRepository, attempt_id: int, total_questions: int): +async def finish_test( + manager: DialogManager, + attempt_repo: TestAttemptRepository, + attempt_id: int, + total_questions: int, + are_results_viewable: bool = False, +): correct_count = await attempt_repo.calculate_attempt_score(attempt_id) score = round((correct_count / total_questions * 100)) if total_questions > 0 else 0 @@ -376,6 +390,7 @@ async def finish_test(manager: DialogManager, attempt_repo: TestAttemptRepositor manager.dialog_data["correct_count"] = correct_count manager.dialog_data["total_questions"] = total_questions manager.dialog_data["is_passed"] = is_passed + manager.dialog_data["are_results_viewable"] = are_results_viewable await manager.switch_to(UserTestSG.results) @@ -385,6 +400,7 @@ async def get_results_data(dialog_manager: DialogManager, **_kwargs): correct_count = dialog_manager.dialog_data.get("correct_count", 0) total_questions = dialog_manager.dialog_data.get("total_questions", 0) is_passed = dialog_manager.dialog_data.get("is_passed", False) + are_results_viewable = dialog_manager.dialog_data.get("are_results_viewable", False) if is_passed: status = "✅ Тест пройден!" @@ -397,7 +413,7 @@ async def get_results_data(dialog_manager: DialogManager, **_kwargs): f"✏️ Правильных ответов: {correct_count} из {total_questions}" ) - return {"results_text": results_text} + return {"results_text": results_text, "are_results_viewable": are_results_viewable} async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): @@ -471,10 +487,7 @@ take_test_dialog = Dialog( on_click=on_single_answer_selected, ), ), - Column( - Button(Const("➡️ Далее"), id="next", on_click=on_next_question), - Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), - ), + Button(Const("➡️ Далее"), id="next", on_click=on_next_question), state=UserTestSG.question_single, getter=get_question_data, ), @@ -490,24 +503,25 @@ take_test_dialog = Dialog( on_state_changed=on_multiple_answer_changed, ), ), - Column( - Button(Const("➡️ Далее"), id="next", on_click=on_next_question), - Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), - ), + Button(Const("➡️ Далее"), id="next", on_click=on_next_question), state=UserTestSG.question_multiple, getter=get_question_data, ), Window( Format("{question_text}\n\nВведите ответ:"), MessageInput(on_text_answer_input), - Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_test), state=UserTestSG.question_input, getter=get_question_data, ), Window( Format("{results_text}"), Column( - Button(Const("📋 Подробные результаты"), id="detailed", on_click=on_show_detailed_results), + Button( + Const("📋 Подробные результаты"), + id="detailed", + on_click=on_show_detailed_results, + when="are_results_viewable", + ), Button(Const("◀️ В главное меню"), id="back", on_click=on_back_to_menu), ), state=UserTestSG.results, diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index a4b1515..46878d1 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -35,6 +35,7 @@ class Test: expires_at: datetime | None = None attempts: int | None = None is_active: bool = True + are_results_viewable: bool = False created_at: datetime | None = None updated_at: datetime | None = None diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/trudex/infrastructure/database/dao/test.py index 505a90c..6e3d326 100644 --- a/src/trudex/infrastructure/database/dao/test.py +++ b/src/trudex/infrastructure/database/dao/test.py @@ -21,7 +21,7 @@ class TestDAO: async def get_all(self) -> list[DomainTest]: result = await self.session.execute( - select(Test).order_by(Test.id) + select(Test).order_by(Test.created_at.desc()) ) models = list(result.scalars().all()) return [TestDTO(model).to_domain() for model in models] @@ -35,6 +35,7 @@ class TestDAO: expires_at: datetime | None = None, attempts: int | None = None, is_active: bool = True, + are_results_viewable: bool = False, ) -> DomainTest: test = Test( title=title, @@ -44,6 +45,7 @@ class TestDAO: expires_at=expires_at, attempts=attempts, is_active=is_active, + are_results_viewable=are_results_viewable, ) self.session.add(test) await self.session.flush() @@ -60,6 +62,7 @@ class TestDAO: expires_at: datetime | None = None, attempts: int | None = None, is_active: bool | None = None, + are_results_viewable: bool | None = None, ) -> DomainTest | None: result = await self.session.execute( select(Test).where(Test.id == test_id) @@ -82,6 +85,8 @@ class TestDAO: test.attempts = attempts if is_active is not None: test.is_active = is_active + if are_results_viewable is not None: + test.are_results_viewable = are_results_viewable await self.session.flush() await self.session.refresh(test) diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index 1d08789..712e2f9 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -20,7 +20,9 @@ class UserDAO: return UserDTO(model).to_domain() if model else None async def get_all(self) -> list[DomainUser]: - result = await self.session.execute(select(User)) + result = await self.session.execute( + select(User).order_by(User.created_at.desc()) + ) models = list(result.scalars().all()) return [UserDTO(model).to_domain() for model in models] diff --git a/src/trudex/infrastructure/database/dto/test.py b/src/trudex/infrastructure/database/dto/test.py index 0be7a2c..05d20bc 100644 --- a/src/trudex/infrastructure/database/dto/test.py +++ b/src/trudex/infrastructure/database/dto/test.py @@ -16,6 +16,7 @@ class TestDTO: expires_at=self.model.expires_at, attempts=self.model.attempts, is_active=self.model.is_active, + are_results_viewable=self.model.are_results_viewable, created_at=self.model.created_at, updated_at=self.model.updated_at, ) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 04914ae..890dd7a 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -63,6 +63,7 @@ class Test(Base): expires_at: Mapped[datetime | None] = mapped_column(default=None) attempts: 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()) updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) From 26f5ecd9189896c0b34c19711fed92b8a798dee8 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 01:56:49 +0300 Subject: [PATCH 43/57] commit --- src/trudex/application/__main__.py | 6 + .../bot/admin_dialogs/main_menu.py | 6 + .../application/bot/admin_dialogs/states.py | 5 + .../bot/admin_dialogs/templates.py | 138 ++++++++++++++++++ .../bot/creator_dialogs/main_menu.py | 6 + .../application/bot/creator_dialogs/states.py | 5 + .../bot/creator_dialogs/templates.py | 136 +++++++++++++++++ 7 files changed, 302 insertions(+) create mode 100644 src/trudex/application/bot/admin_dialogs/templates.py create mode 100644 src/trudex/application/bot/creator_dialogs/templates.py diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 727f465..6240db8 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -14,6 +14,8 @@ from trudex.application.bot.admin_dialogs.broadcast import \ from trudex.application.bot.admin_dialogs.groups import \ groups_dialog as admin_groups_dialog from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog +from trudex.application.bot.admin_dialogs.templates import \ + templates_dialog as admin_templates_dialog from trudex.application.bot.admin_dialogs.tests import \ tests_dialog as admin_tests_dialog from trudex.application.bot.admin_dialogs.users import \ @@ -26,6 +28,8 @@ from trudex.application.bot.creator_dialogs.groups import \ groups_dialog as creator_groups_dialog from trudex.application.bot.creator_dialogs.main_menu import \ creator_menu_dialog +from trudex.application.bot.creator_dialogs.templates import \ + templates_dialog as creator_templates_dialog from trudex.application.bot.creator_dialogs.tests import \ tests_dialog as creator_tests_dialog from trudex.application.bot.creator_dialogs.users import \ @@ -72,11 +76,13 @@ async def main() -> None: admin_tests_dialog, admin_groups_dialog, admin_broadcast_dialog, + admin_templates_dialog, creator_menu_dialog, creator_users_dialog, creator_tests_dialog, creator_groups_dialog, creator_broadcast_dialog, + creator_templates_dialog, create_test_dialog, ) diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index 9f0c52f..33d9d36 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -6,6 +6,7 @@ from aiogram_dialog.widgets.text import Const from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, AdminGroupsSG, AdminMenuSG, + AdminTemplatesSG, AdminTestsSG, AdminUsersSG) @@ -26,6 +27,10 @@ async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manage await manager.start(AdminBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) +async def on_templates_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(AdminTemplatesSG.main, mode=StartMode.RESET_STACK) + + admin_menu_dialog = Dialog( Window( Const("🔧 Админ-панель\n\nВыберите раздел:"), @@ -34,6 +39,7 @@ admin_menu_dialog = Dialog( Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked), Button(Const("🎓 Группы"), id="groups", on_click=on_groups_clicked), Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked), + Button(Const("📦 Шаблоны тестов"), id="templates", on_click=on_templates_clicked), ), state=AdminMenuSG.main, ), diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 44f013e..28720a3 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -5,6 +5,11 @@ class AdminMenuSG(StatesGroup): main = State() +class AdminTemplatesSG(StatesGroup): + main = State() + export_list = State() + + class AdminUsersSG(StatesGroup): users_list = State() users_input = State() diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/admin_dialogs/templates.py new file mode 100644 index 0000000..976488c --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/templates.py @@ -0,0 +1,138 @@ +import json + +from aiogram.types import BufferedInputFile, CallbackQuery +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.kbd import Button, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminTemplatesSG +from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.repo.test import TestRepository + + +TEMPLATES_INFO = ( + "📦 Шаблоны тестов\n\n" + "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" + "🔹 Экспорт — сохраните тест как файл для резервной копии или передачи\n" + "🔹 Импорт — загрузите тест из файла\n" + "🔹 Спецификация — описание формата JSON для создания тестов вручную" +) + + +async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(AdminTemplatesSG.export_list) + + +async def on_import_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("🚧 В разработке", show_alert=True) + + +async def on_spec_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("🚧 В разработке", show_alert=True) + + +async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + + +async def on_back_to_templates(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(AdminTemplatesSG.main) + + +@inject +async def get_tests_for_export(test_dao: FromDishka[TestDAO], **_kwargs): + tests = await test_dao.get_all() + return { + "tests": [(f"📝 {t.title}", t.id) for t in tests], + "count": len(tests), + } + + +@inject +async def on_test_selected_for_export( + _callback: CallbackQuery, + _widget: Select, + _manager: DialogManager, + item_id: str, + test_repo: FromDishka[TestRepository], +): + test_id = int(item_id) + test, questions_with_options = await test_repo.get_full_test(test_id) + + if not test: + await _callback.answer("❌ Тест не найден") + return + + export_data: dict = { + "title": test.title, + "description": test.description, + "password": test.password, + "attempts": test.attempts, + "for_group": test.for_group, + "questions": [], + } + + questions_list: list = export_data["questions"] + + for question, options in questions_with_options: + question_data: dict = { + "text": question.text, + "question_type": question.question_type, + } + + if question.question_type == "input": + correct_options = [o for o in options if o.is_correct] + if correct_options: + question_data["correct_answer"] = correct_options[0].text + else: + question_data["options"] = [ + {"text": o.text, "is_correct": o.is_correct} + for o in options + ] + + questions_list.append(question_data) + + json_str = json.dumps(export_data, ensure_ascii=False, indent=2) + + safe_title = "".join(c if c.isalnum() or c in " -_" else "_" for c in test.title)[:50] + filename = f"{safe_title}.json" + + assert _callback.message is not None + await _callback.message.answer_document( + document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), + caption=f"📤 Экспорт теста\n\n📝 {test.title}", + ) + + +templates_dialog = Dialog( + Window( + Const(TEMPLATES_INFO), + Row( + Button(Const("📤 Экспорт"), id="export", on_click=on_export_clicked), + Button(Const("📥 Импорт"), id="import", on_click=on_import_clicked), + ), + Button(Const("📋 Спецификация"), id="spec", on_click=on_spec_clicked), + Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked), + state=AdminTemplatesSG.main, + ), + Window( + Format("📤 Экспорт теста\n\nВыберите тест для экспорта:\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="test_select", + item_id_getter=lambda x: x[1], + items="tests", + on_click=on_test_selected_for_export, + ), + id="tests_scroll", + width=1, + height=7, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), + state=AdminTemplatesSG.export_list, + getter=get_tests_for_export, + ), +) diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index 3d63ee0..a37ebbf 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -6,6 +6,7 @@ from aiogram_dialog.widgets.text import Const from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, CreatorGroupsSG, CreatorMenuSG, + CreatorTemplatesSG, CreatorTestsSG, CreatorUsersSG) @@ -26,6 +27,10 @@ async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manage await manager.start(CreatorBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) +async def on_templates_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(CreatorTemplatesSG.main, mode=StartMode.RESET_STACK) + + creator_menu_dialog = Dialog( Window( Const("👑 Панель создателя\n\nВыберите раздел:"), @@ -34,6 +39,7 @@ creator_menu_dialog = Dialog( Button(Const("👥 Пользователи"), id="users", on_click=on_users_clicked), Button(Const("🎓 Группы"), id="groups", on_click=on_groups_clicked), Button(Const("📢 Рассылка"), id="broadcast", on_click=on_broadcast_clicked), + Button(Const("📦 Шаблоны тестов"), id="templates", on_click=on_templates_clicked), ), state=CreatorMenuSG.main, ), diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 2b0909f..02f22d4 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -5,6 +5,11 @@ class CreatorMenuSG(StatesGroup): main = State() +class CreatorTemplatesSG(StatesGroup): + main = State() + export_list = State() + + class CreatorUsersSG(StatesGroup): users_list = State() users_input = State() diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py new file mode 100644 index 0000000..28b88a4 --- /dev/null +++ b/src/trudex/application/bot/creator_dialogs/templates.py @@ -0,0 +1,136 @@ +import json + +from aiogram.types import BufferedInputFile, CallbackQuery +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.kbd import Button, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorTemplatesSG +from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.repo.test import TestRepository + + +TEMPLATES_INFO = ( + "📦 Шаблоны тестов\n\n" + "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" + "🔹 Экспорт — сохраните тест как файл для резервной копии или передачи\n" + "🔹 Импорт — загрузите тест из файла\n" + "🔹 Спецификация — описание формата JSON для создания тестов вручную" +) + + +async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(CreatorTemplatesSG.export_list) + + +async def on_import_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("🚧 В разработке", show_alert=True) + + +async def on_spec_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await _callback.answer("🚧 В разработке", show_alert=True) + + +async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + + +async def on_back_to_templates(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(CreatorTemplatesSG.main) + + +@inject +async def get_tests_for_export(test_dao: FromDishka[TestDAO], **_kwargs): + tests = await test_dao.get_all() + return { + "tests": [(f"📝 {t.title}", t.id) for t in tests], + "count": len(tests), + } + + +@inject +async def on_test_selected_for_export( + _callback: CallbackQuery, + _widget: Select, + _manager: DialogManager, + item_id: str, + test_repo: FromDishka[TestRepository], +): + test_id = int(item_id) + test, questions_with_options = await test_repo.get_full_test(test_id) + + if not test: + await _callback.answer("❌ Тест не найден") + return + + export_data = { + "title": test.title, + "description": test.description, + "password": test.password, + "attempts": test.attempts, + "for_group": test.for_group, + "questions": [], + } + + for question, options in questions_with_options: + question_data = { + "text": question.text, + "question_type": question.question_type, + } + + if question.question_type == "input": + correct_options = [o for o in options if o.is_correct] + if correct_options: + question_data["correct_answer"] = correct_options[0].text + else: + question_data["options"] = [ + {"text": o.text, "is_correct": o.is_correct} + for o in options + ] + + export_data["questions"].append(question_data) + + json_str = json.dumps(export_data, ensure_ascii=False, indent=2) + + safe_title = "".join(c if c.isalnum() or c in " -_" else "_" for c in test.title)[:50] + filename = f"{safe_title}.json" + + assert _callback.message is not None + await _callback.message.answer_document( + document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), + caption=f"📤 Экспорт теста\n\n📝 {test.title}", + ) + + +templates_dialog = Dialog( + Window( + Const(TEMPLATES_INFO), + Row( + Button(Const("📤 Экспорт"), id="export", on_click=on_export_clicked), + Button(Const("📥 Импорт"), id="import", on_click=on_import_clicked), + ), + Button(Const("📋 Спецификация"), id="spec", on_click=on_spec_clicked), + Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked), + state=CreatorTemplatesSG.main, + ), + Window( + Format("📤 Экспорт теста\n\nВыберите тест для экспорта:\n\nВсего: {count}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="test_select", + item_id_getter=lambda x: x[1], + items="tests", + on_click=on_test_selected_for_export, + ), + id="tests_scroll", + width=1, + height=7, + ), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), + state=CreatorTemplatesSG.export_list, + getter=get_tests_for_export, + ), +) From 5d0e99f875ed134dd8f4a875d4ad8cf6c52ae4d0 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 02:00:26 +0300 Subject: [PATCH 44/57] commit --- .../application/bot/admin_dialogs/templates.py | 9 +++++---- .../bot/creator_dialogs/templates.py | 17 ++++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/admin_dialogs/templates.py index 976488c..ea9f0df 100644 --- a/src/trudex/application/bot/admin_dialogs/templates.py +++ b/src/trudex/application/bot/admin_dialogs/templates.py @@ -53,11 +53,11 @@ async def get_tests_for_export(test_dao: FromDishka[TestDAO], **_kwargs): @inject async def on_test_selected_for_export( _callback: CallbackQuery, - _widget: Select, + _widget: Select, # type: ignore[type-arg] _manager: DialogManager, item_id: str, test_repo: FromDishka[TestRepository], -): +) -> None: test_id = int(item_id) test, questions_with_options = await test_repo.get_full_test(test_id) @@ -70,6 +70,7 @@ async def on_test_selected_for_export( "description": test.description, "password": test.password, "attempts": test.attempts, + "expires_at": test.expires_at.isoformat() if test.expires_at else None, "for_group": test.for_group, "questions": [], } @@ -102,7 +103,7 @@ async def on_test_selected_for_export( assert _callback.message is not None await _callback.message.answer_document( document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), - caption=f"📤 Экспорт теста\n\n📝 {test.title}", + caption=f"📤 Экспорт теста: {test.title}", ) @@ -125,7 +126,7 @@ templates_dialog = Dialog( id="test_select", item_id_getter=lambda x: x[1], items="tests", - on_click=on_test_selected_for_export, + on_click=on_test_selected_for_export, # type: ignore[arg-type] ), id="tests_scroll", width=1, diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py index 28b88a4..ba21d49 100644 --- a/src/trudex/application/bot/creator_dialogs/templates.py +++ b/src/trudex/application/bot/creator_dialogs/templates.py @@ -53,11 +53,11 @@ async def get_tests_for_export(test_dao: FromDishka[TestDAO], **_kwargs): @inject async def on_test_selected_for_export( _callback: CallbackQuery, - _widget: Select, + _widget: Select, # type: ignore[type-arg] _manager: DialogManager, item_id: str, test_repo: FromDishka[TestRepository], -): +) -> None: test_id = int(item_id) test, questions_with_options = await test_repo.get_full_test(test_id) @@ -65,17 +65,20 @@ async def on_test_selected_for_export( await _callback.answer("❌ Тест не найден") return - export_data = { + export_data: dict = { "title": test.title, "description": test.description, "password": test.password, "attempts": test.attempts, + "expires_at": test.expires_at.isoformat() if test.expires_at else None, "for_group": test.for_group, "questions": [], } + questions_list: list = export_data["questions"] + for question, options in questions_with_options: - question_data = { + question_data: dict = { "text": question.text, "question_type": question.question_type, } @@ -90,7 +93,7 @@ async def on_test_selected_for_export( for o in options ] - export_data["questions"].append(question_data) + questions_list.append(question_data) json_str = json.dumps(export_data, ensure_ascii=False, indent=2) @@ -100,7 +103,7 @@ async def on_test_selected_for_export( assert _callback.message is not None await _callback.message.answer_document( document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), - caption=f"📤 Экспорт теста\n\n📝 {test.title}", + caption=f"📤 Экспорт теста: {test.title}", ) @@ -123,7 +126,7 @@ templates_dialog = Dialog( id="test_select", item_id_getter=lambda x: x[1], items="tests", - on_click=on_test_selected_for_export, + on_click=on_test_selected_for_export, # type: ignore[arg-type] ), id="tests_scroll", width=1, From 0ebf915ec4d4217ae1ed2f0f91752ff69a2739d0 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 02:07:07 +0300 Subject: [PATCH 45/57] commit --- .../application/bot/admin_dialogs/states.py | 1 + .../bot/admin_dialogs/templates.py | 185 +++++++++++++++++- .../application/bot/creator_dialogs/states.py | 1 + .../bot/creator_dialogs/templates.py | 185 +++++++++++++++++- 4 files changed, 366 insertions(+), 6 deletions(-) diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 28720a3..d9b1e88 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -8,6 +8,7 @@ class AdminMenuSG(StatesGroup): class AdminTemplatesSG(StatesGroup): main = State() export_list = State() + spec = State() class AdminUsersSG(StatesGroup): diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/admin_dialogs/templates.py index ea9f0df..e861db4 100644 --- a/src/trudex/application/bot/admin_dialogs/templates.py +++ b/src/trudex/application/bot/admin_dialogs/templates.py @@ -20,6 +20,145 @@ TEMPLATES_INFO = ( "🔹 Спецификация — описание формата JSON для создания тестов вручную" ) +SPEC_INFO = """📋 Спецификация формата JSON + +Структура файла: +{ + "title": "Название теста", + "description": "Описание теста", + "password": null, + "attempts": null, + "expires_at": null, + "for_group": null, + "questions": [...] +} + +Поля теста: +• title — название (обязательно, до 255 символов) +• description — описание (до 2000 символов) +• password — пароль для доступа или null +• attempts — лимит попыток (1-100) или null +• expires_at — срок действия в ISO формате или null +• for_group — номер группы или null для всех + +Типы вопросов: +• single — один правильный ответ +• multiple — несколько правильных ответов +• input — ввод текста (регистр и пробелы игнорируются) + +Формат вопроса (single/multiple): +{ + "text": "Текст вопроса", + "question_type": "single", + "options": [ + {"text": "Вариант 1", "is_correct": true}, + {"text": "Вариант 2", "is_correct": false} + ] +} + +Формат вопроса (input): +{ + "text": "Текст вопроса", + "question_type": "input", + "correct_answer": "правильный ответ" +} + +⚠️ Важно: +• Для single — ровно один is_correct: true +• Для multiple — один или более is_correct: true +• Минимум 2 варианта ответа для single/multiple""" + +TEMPLATE_SINGLE = { + "title": "Пример теста с одиночным выбором", + "description": "Демонстрация формата single вопросов", + "password": None, + "attempts": None, + "expires_at": None, + "for_group": None, + "questions": [ + { + "text": "Какой язык программирования используется для разработки Telegram ботов?", + "question_type": "single", + "options": [ + {"text": "Python", "is_correct": True}, + {"text": "HTML", "is_correct": False}, + {"text": "CSS", "is_correct": False}, + ], + }, + ], +} + +TEMPLATE_MULTIPLE = { + "title": "Пример теста с множественным выбором", + "description": "Демонстрация формата multiple вопросов", + "password": None, + "attempts": None, + "expires_at": None, + "for_group": None, + "questions": [ + { + "text": "Выберите языки программирования:", + "question_type": "multiple", + "options": [ + {"text": "Python", "is_correct": True}, + {"text": "JavaScript", "is_correct": True}, + {"text": "HTML", "is_correct": False}, + {"text": "CSS", "is_correct": False}, + ], + }, + ], +} + +TEMPLATE_INPUT = { + "title": "Пример теста с вводом текста", + "description": "Демонстрация формата input вопросов", + "password": None, + "attempts": None, + "expires_at": None, + "for_group": None, + "questions": [ + { + "text": "Как называется библиотека для создания Telegram ботов на Python?", + "question_type": "input", + "correct_answer": "aiogram", + }, + ], +} + +TEMPLATE_FULL = { + "title": "Полный пример теста", + "description": "Тест со всеми типами вопросов и настройками", + "password": "secret123", + "attempts": 3, + "expires_at": "2026-12-31T23:59:59", + "for_group": 1234, + "questions": [ + { + "text": "Выберите правильный ответ:", + "question_type": "single", + "options": [ + {"text": "Вариант A", "is_correct": False}, + {"text": "Вариант B", "is_correct": True}, + {"text": "Вариант C", "is_correct": False}, + ], + }, + { + "text": "Выберите все правильные ответы:", + "question_type": "multiple", + "options": [ + {"text": "Ответ 1", "is_correct": True}, + {"text": "Ответ 2", "is_correct": True}, + {"text": "Ответ 3", "is_correct": False}, + ], + }, + { + "text": "Введите ответ:", + "question_type": "input", + "correct_answer": "ответ", + }, + ], +} + async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: await manager.switch_to(AdminTemplatesSG.export_list) @@ -29,8 +168,8 @@ async def on_import_clicked(_callback: CallbackQuery, _button: Button, _manager: await _callback.answer("🚧 В разработке", show_alert=True) -async def on_spec_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("🚧 В разработке", show_alert=True) +async def on_spec_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(AdminTemplatesSG.spec) async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: @@ -97,7 +236,7 @@ async def on_test_selected_for_export( json_str = json.dumps(export_data, ensure_ascii=False, indent=2) - safe_title = "".join(c if c.isalnum() or c in " -_" else "_" for c in test.title)[:50] + safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in test.title)[:50] filename = f"{safe_title}.json" assert _callback.message is not None @@ -107,6 +246,33 @@ async def on_test_selected_for_export( ) +async def send_template(callback: CallbackQuery, template: dict, name: str) -> None: + json_str = json.dumps(template, ensure_ascii=False, indent=2) + filename = f"template_{name}.json" + + assert callback.message is not None + await callback.message.answer_document( + document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), + caption=f"📄 Шаблон: {template['title']}", + ) + + +async def on_template_single(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await send_template(_callback, TEMPLATE_SINGLE, "single") + + +async def on_template_multiple(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await send_template(_callback, TEMPLATE_MULTIPLE, "multiple") + + +async def on_template_input(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await send_template(_callback, TEMPLATE_INPUT, "input") + + +async def on_template_full(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await send_template(_callback, TEMPLATE_FULL, "full") + + templates_dialog = Dialog( Window( Const(TEMPLATES_INFO), @@ -136,4 +302,17 @@ templates_dialog = Dialog( state=AdminTemplatesSG.export_list, getter=get_tests_for_export, ), + Window( + Const(SPEC_INFO), + Row( + Button(Const("📌 Single"), id="tpl_single", on_click=on_template_single), + Button(Const("📋 Multiple"), id="tpl_multiple", on_click=on_template_multiple), + ), + Row( + Button(Const("✏️ Input"), id="tpl_input", on_click=on_template_input), + Button(Const("📦 Полный"), id="tpl_full", on_click=on_template_full), + ), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), + state=AdminTemplatesSG.spec, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 02f22d4..eff6b23 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -8,6 +8,7 @@ class CreatorMenuSG(StatesGroup): class CreatorTemplatesSG(StatesGroup): main = State() export_list = State() + spec = State() class CreatorUsersSG(StatesGroup): diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py index ba21d49..570b878 100644 --- a/src/trudex/application/bot/creator_dialogs/templates.py +++ b/src/trudex/application/bot/creator_dialogs/templates.py @@ -20,6 +20,145 @@ TEMPLATES_INFO = ( "🔹 Спецификация — описание формата JSON для создания тестов вручную" ) +SPEC_INFO = """📋 Спецификация формата JSON + +Структура файла: +{ + "title": "Название теста", + "description": "Описание теста", + "password": null, + "attempts": null, + "expires_at": null, + "for_group": null, + "questions": [...] +} + +Поля теста: +• title — название (обязательно, до 255 символов) +• description — описание (до 2000 символов) +• password — пароль для доступа или null +• attempts — лимит попыток (1-100) или null +• expires_at — срок действия в ISO формате или null +• for_group — номер группы или null для всех + +Типы вопросов: +• single — один правильный ответ +• multiple — несколько правильных ответов +• input — ввод текста (регистр и пробелы игнорируются) + +Формат вопроса (single/multiple): +{ + "text": "Текст вопроса", + "question_type": "single", + "options": [ + {"text": "Вариант 1", "is_correct": true}, + {"text": "Вариант 2", "is_correct": false} + ] +} + +Формат вопроса (input): +{ + "text": "Текст вопроса", + "question_type": "input", + "correct_answer": "правильный ответ" +} + +⚠️ Важно: +• Для single — ровно один is_correct: true +• Для multiple — один или более is_correct: true +• Минимум 2 варианта ответа для single/multiple""" + +TEMPLATE_SINGLE = { + "title": "Пример теста с одиночным выбором", + "description": "Демонстрация формата single вопросов", + "password": None, + "attempts": None, + "expires_at": None, + "for_group": None, + "questions": [ + { + "text": "Какой язык программирования используется для разработки Telegram ботов?", + "question_type": "single", + "options": [ + {"text": "Python", "is_correct": True}, + {"text": "HTML", "is_correct": False}, + {"text": "CSS", "is_correct": False}, + ], + }, + ], +} + +TEMPLATE_MULTIPLE = { + "title": "Пример теста с множественным выбором", + "description": "Демонстрация формата multiple вопросов", + "password": None, + "attempts": None, + "expires_at": None, + "for_group": None, + "questions": [ + { + "text": "Выберите языки программирования:", + "question_type": "multiple", + "options": [ + {"text": "Python", "is_correct": True}, + {"text": "JavaScript", "is_correct": True}, + {"text": "HTML", "is_correct": False}, + {"text": "CSS", "is_correct": False}, + ], + }, + ], +} + +TEMPLATE_INPUT = { + "title": "Пример теста с вводом текста", + "description": "Демонстрация формата input вопросов", + "password": None, + "attempts": None, + "expires_at": None, + "for_group": None, + "questions": [ + { + "text": "Как называется библиотека для создания Telegram ботов на Python?", + "question_type": "input", + "correct_answer": "aiogram", + }, + ], +} + +TEMPLATE_FULL = { + "title": "Полный пример теста", + "description": "Тест со всеми типами вопросов и настройками", + "password": "secret123", + "attempts": 3, + "expires_at": "2026-12-31T23:59:59", + "for_group": 1234, + "questions": [ + { + "text": "Выберите правильный ответ:", + "question_type": "single", + "options": [ + {"text": "Вариант A", "is_correct": False}, + {"text": "Вариант B", "is_correct": True}, + {"text": "Вариант C", "is_correct": False}, + ], + }, + { + "text": "Выберите все правильные ответы:", + "question_type": "multiple", + "options": [ + {"text": "Ответ 1", "is_correct": True}, + {"text": "Ответ 2", "is_correct": True}, + {"text": "Ответ 3", "is_correct": False}, + ], + }, + { + "text": "Введите ответ:", + "question_type": "input", + "correct_answer": "ответ", + }, + ], +} + async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: await manager.switch_to(CreatorTemplatesSG.export_list) @@ -29,8 +168,8 @@ async def on_import_clicked(_callback: CallbackQuery, _button: Button, _manager: await _callback.answer("🚧 В разработке", show_alert=True) -async def on_spec_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("🚧 В разработке", show_alert=True) +async def on_spec_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(CreatorTemplatesSG.spec) async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: @@ -97,7 +236,7 @@ async def on_test_selected_for_export( json_str = json.dumps(export_data, ensure_ascii=False, indent=2) - safe_title = "".join(c if c.isalnum() or c in " -_" else "_" for c in test.title)[:50] + safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in test.title)[:50] filename = f"{safe_title}.json" assert _callback.message is not None @@ -107,6 +246,33 @@ async def on_test_selected_for_export( ) +async def send_template(callback: CallbackQuery, template: dict, name: str) -> None: + json_str = json.dumps(template, ensure_ascii=False, indent=2) + filename = f"template_{name}.json" + + assert callback.message is not None + await callback.message.answer_document( + document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), + caption=f"📄 Шаблон: {template['title']}", + ) + + +async def on_template_single(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await send_template(_callback, TEMPLATE_SINGLE, "single") + + +async def on_template_multiple(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await send_template(_callback, TEMPLATE_MULTIPLE, "multiple") + + +async def on_template_input(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await send_template(_callback, TEMPLATE_INPUT, "input") + + +async def on_template_full(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await send_template(_callback, TEMPLATE_FULL, "full") + + templates_dialog = Dialog( Window( Const(TEMPLATES_INFO), @@ -136,4 +302,17 @@ templates_dialog = Dialog( state=CreatorTemplatesSG.export_list, getter=get_tests_for_export, ), + Window( + Const(SPEC_INFO), + Row( + Button(Const("📌 Single"), id="tpl_single", on_click=on_template_single), + Button(Const("📋 Multiple"), id="tpl_multiple", on_click=on_template_multiple), + ), + Row( + Button(Const("✏️ Input"), id="tpl_input", on_click=on_template_input), + Button(Const("📦 Полный"), id="tpl_full", on_click=on_template_full), + ), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), + state=CreatorTemplatesSG.spec, + ), ) From cd391254f1c9d80208aeae7f15db18540614377f Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 02:15:17 +0300 Subject: [PATCH 46/57] commit --- .../application/bot/admin_dialogs/states.py | 1 + .../bot/admin_dialogs/templates.py | 109 +++++- .../application/bot/creator_dialogs/states.py | 1 + .../bot/creator_dialogs/templates.py | 109 +++++- src/trudex/domain/test_parser.py | 333 ++++++++++++++++++ 5 files changed, 547 insertions(+), 6 deletions(-) create mode 100644 src/trudex/domain/test_parser.py diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index d9b1e88..e346506 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -9,6 +9,7 @@ class AdminTemplatesSG(StatesGroup): main = State() export_list = State() spec = State() + import_file = State() class AdminUsersSG(StatesGroup): diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/admin_dialogs/templates.py index e861db4..644ab3a 100644 --- a/src/trudex/application/bot/admin_dialogs/templates.py +++ b/src/trudex/application/bot/admin_dialogs/templates.py @@ -1,13 +1,18 @@ import json -from aiogram.types import BufferedInputFile, CallbackQuery +from aiogram import Bot +from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminTemplatesSG +from trudex.domain.test_parser import ParsedTest, TestParser +from trudex.infrastructure.database.dao.option import OptionDAO +from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository @@ -164,8 +169,8 @@ async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: await manager.switch_to(AdminTemplatesSG.export_list) -async def on_import_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("🚧 В разработке", show_alert=True) +async def on_import_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(AdminTemplatesSG.import_file) async def on_spec_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: @@ -273,6 +278,98 @@ async def on_template_full(_callback: CallbackQuery, _button: Button, _manager: await send_template(_callback, TEMPLATE_FULL, "full") +async def create_test_from_parsed( + parsed: ParsedTest, + test_dao: TestDAO, + question_dao: QuestionDAO, + option_dao: OptionDAO, +) -> int: + test = await test_dao.create( + title=parsed.title, + description=parsed.description, + password=parsed.password, + attempts=parsed.attempts, + expires_at=parsed.expires_at, + for_group=parsed.for_group, + ) + + for position, q in enumerate(parsed.questions): + question = await question_dao.create( + test_id=test.id, + text=q.text, + position=position, + question_type=q.question_type, + ) + + for opt in q.options: + await option_dao.create( + question_id=question.id, + text=opt.text, + is_correct=opt.is_correct, + ) + + return test.id + + +@inject +async def on_import_file( + message: Message, + _widget: MessageInput, + manager: DialogManager, + bot_inst: FromDishka[Bot], + test_dao: FromDishka[TestDAO], + question_dao: FromDishka[QuestionDAO], + option_dao: FromDishka[OptionDAO], +) -> None: + if not message.document: + await message.answer("❌ Отправьте JSON файл") + return + + if message.document.file_size and message.document.file_size > 1024 * 1024: + await message.answer("❌ Файл слишком большой (максимум 1 МБ)") + return + + file = await bot_inst.get_file(message.document.file_id) + if not file.file_path: + await message.answer("❌ Не удалось загрузить файл") + return + + file_bytes = await bot_inst.download_file(file.file_path) + if not file_bytes: + await message.answer("❌ Не удалось загрузить файл") + return + + try: + json_str = file_bytes.read().decode("utf-8") + except UnicodeDecodeError: + await message.answer("❌ Файл должен быть в кодировке UTF-8") + return + + parser = TestParser() + result = parser.parse(json_str) + + if isinstance(result, list): + error_lines = ["❌ Ошибки валидации:\n"] + for err in result[:10]: + path_str = f" ({err.path})" if err.path else "" + error_lines.append(f"• {err.message}{path_str}") + if len(result) > 10: + error_lines.append(f"\n... и ещё {len(result) - 10} ошибок") + await message.answer("\n".join(error_lines)) + return + + test_id = await create_test_from_parsed(result, test_dao, question_dao, option_dao) + + await message.answer( + f"✅ Тест импортирован!\n\n" + f"📝 Название: {result.title}\n" + f"❓ Вопросов: {len(result.questions)}\n\n" + f"Тест создан в деактивированном состоянии." + ) + + await manager.switch_to(AdminTemplatesSG.main) + + templates_dialog = Dialog( Window( Const(TEMPLATES_INFO), @@ -315,4 +412,10 @@ templates_dialog = Dialog( Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), state=AdminTemplatesSG.spec, ), + Window( + Const("📥 Импорт теста\n\nОтправьте JSON файл с тестом.\n\nФормат файла описан в разделе «Спецификация»"), + MessageInput(on_import_file, content_types=[ContentType.DOCUMENT]), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), + state=AdminTemplatesSG.import_file, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index eff6b23..6b21c34 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -9,6 +9,7 @@ class CreatorTemplatesSG(StatesGroup): main = State() export_list = State() spec = State() + import_file = State() class CreatorUsersSG(StatesGroup): diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py index 570b878..b9dbc6c 100644 --- a/src/trudex/application/bot/creator_dialogs/templates.py +++ b/src/trudex/application/bot/creator_dialogs/templates.py @@ -1,13 +1,18 @@ import json -from aiogram.types import BufferedInputFile, CallbackQuery +from aiogram import Bot +from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorTemplatesSG +from trudex.domain.test_parser import ParsedTest, TestParser +from trudex.infrastructure.database.dao.option import OptionDAO +from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository @@ -164,8 +169,8 @@ async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: await manager.switch_to(CreatorTemplatesSG.export_list) -async def on_import_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("🚧 В разработке", show_alert=True) +async def on_import_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(CreatorTemplatesSG.import_file) async def on_spec_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: @@ -273,6 +278,98 @@ async def on_template_full(_callback: CallbackQuery, _button: Button, _manager: await send_template(_callback, TEMPLATE_FULL, "full") +async def create_test_from_parsed( + parsed: ParsedTest, + test_dao: TestDAO, + question_dao: QuestionDAO, + option_dao: OptionDAO, +) -> int: + test = await test_dao.create( + title=parsed.title, + description=parsed.description, + password=parsed.password, + attempts=parsed.attempts, + expires_at=parsed.expires_at, + for_group=parsed.for_group, + ) + + for position, q in enumerate(parsed.questions): + question = await question_dao.create( + test_id=test.id, + text=q.text, + position=position, + question_type=q.question_type, + ) + + for opt in q.options: + await option_dao.create( + question_id=question.id, + text=opt.text, + is_correct=opt.is_correct, + ) + + return test.id + + +@inject +async def on_import_file( + message: Message, + _widget: MessageInput, + manager: DialogManager, + bot_inst: FromDishka[Bot], + test_dao: FromDishka[TestDAO], + question_dao: FromDishka[QuestionDAO], + option_dao: FromDishka[OptionDAO], +) -> None: + if not message.document: + await message.answer("❌ Отправьте JSON файл") + return + + if message.document.file_size and message.document.file_size > 1024 * 1024: + await message.answer("❌ Файл слишком большой (максимум 1 МБ)") + return + + file = await bot_inst.get_file(message.document.file_id) + if not file.file_path: + await message.answer("❌ Не удалось загрузить файл") + return + + file_bytes = await bot_inst.download_file(file.file_path) + if not file_bytes: + await message.answer("❌ Не удалось загрузить файл") + return + + try: + json_str = file_bytes.read().decode("utf-8") + except UnicodeDecodeError: + await message.answer("❌ Файл должен быть в кодировке UTF-8") + return + + parser = TestParser() + result = parser.parse(json_str) + + if isinstance(result, list): + error_lines = ["❌ Ошибки валидации:\n"] + for err in result[:10]: + path_str = f" ({err.path})" if err.path else "" + error_lines.append(f"• {err.message}{path_str}") + if len(result) > 10: + error_lines.append(f"\n... и ещё {len(result) - 10} ошибок") + await message.answer("\n".join(error_lines)) + return + + test_id = await create_test_from_parsed(result, test_dao, question_dao, option_dao) + + await message.answer( + f"✅ Тест импортирован!\n\n" + f"📝 Название: {result.title}\n" + f"❓ Вопросов: {len(result.questions)}\n\n" + f"Тест создан в деактивированном состоянии." + ) + + await manager.switch_to(CreatorTemplatesSG.main) + + templates_dialog = Dialog( Window( Const(TEMPLATES_INFO), @@ -315,4 +412,10 @@ templates_dialog = Dialog( Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), state=CreatorTemplatesSG.spec, ), + Window( + Const("📥 Импорт теста\n\nОтправьте JSON файл с тестом.\n\nФормат файла описан в разделе «Спецификация»"), + MessageInput(on_import_file, content_types=[ContentType.DOCUMENT]), + Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), + state=CreatorTemplatesSG.import_file, + ), ) diff --git a/src/trudex/domain/test_parser.py b/src/trudex/domain/test_parser.py new file mode 100644 index 0000000..682c786 --- /dev/null +++ b/src/trudex/domain/test_parser.py @@ -0,0 +1,333 @@ +import json +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class ParsedOption: + text: str + is_correct: bool + + +@dataclass +class ParsedQuestion: + text: str + question_type: str + options: list[ParsedOption] + correct_answer: str | None = None + + +@dataclass +class ParsedTest: + title: str + description: str | None + password: str | None + attempts: int | None + expires_at: datetime | None + for_group: int | None + questions: list[ParsedQuestion] + + +@dataclass +class ParseError: + message: str + path: str | None = None + + +class TestParser: + VALID_QUESTION_TYPES = {"single", "multiple", "input"} + + def parse(self, json_str: str) -> ParsedTest | list[ParseError]: + try: + data = json.loads(json_str) + except json.JSONDecodeError as e: + return [ParseError(f"Невалидный JSON: {e.msg}", path=None)] + + if not isinstance(data, dict): + return [ParseError("JSON должен быть объектом", path=None)] + + errors: list[ParseError] = [] + + title = self._parse_string(data, "title", required=True, max_length=255, errors=errors) + 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) + expires_at = self._parse_datetime(data, "expires_at", required=False, errors=errors) + for_group = self._parse_int(data, "for_group", required=False, errors=errors) + + questions = self._parse_questions(data, errors) + + if errors: + return errors + + assert title is not None + + return ParsedTest( + title=title, + description=description, + password=password, + attempts=attempts, + expires_at=expires_at, + for_group=for_group, + questions=questions, + ) + + def _parse_string( + self, + data: dict, + key: str, + required: bool, + max_length: int | None = None, + errors: list[ParseError] | None = None, + ) -> str | None: + errors = errors or [] + value = data.get(key) + + if value is None: + if required: + errors.append(ParseError(f"Поле '{key}' обязательно", path=key)) + return None + + if not isinstance(value, str): + errors.append(ParseError(f"Поле '{key}' должно быть строкой", path=key)) + return None + + value = value.strip() + if not value and required: + errors.append(ParseError(f"Поле '{key}' не может быть пустым", path=key)) + return None + + if max_length and len(value) > max_length: + errors.append(ParseError(f"Поле '{key}' слишком длинное (максимум {max_length})", path=key)) + return None + + return value if value else None + + def _parse_int( + self, + data: dict, + key: str, + required: bool, + min_val: int | None = None, + max_val: int | None = None, + errors: list[ParseError] | None = None, + ) -> int | None: + errors = errors or [] + value = data.get(key) + + if value is None: + if required: + errors.append(ParseError(f"Поле '{key}' обязательно", path=key)) + return None + + if not isinstance(value, int) or isinstance(value, bool): + errors.append(ParseError(f"Поле '{key}' должно быть числом", path=key)) + return None + + if min_val is not None and value < min_val: + errors.append(ParseError(f"Поле '{key}' должно быть >= {min_val}", path=key)) + return None + + if max_val is not None and value > max_val: + errors.append(ParseError(f"Поле '{key}' должно быть <= {max_val}", path=key)) + return None + + return value + + def _parse_datetime( + self, + data: dict, + key: str, + required: bool, + errors: list[ParseError] | None = None, + ) -> datetime | None: + errors = errors or [] + value = data.get(key) + + if value is None: + if required: + errors.append(ParseError(f"Поле '{key}' обязательно", path=key)) + return None + + if not isinstance(value, str): + errors.append(ParseError(f"Поле '{key}' должно быть строкой в ISO формате", path=key)) + return None + + try: + return datetime.fromisoformat(value) + except ValueError: + errors.append(ParseError(f"Поле '{key}' должно быть в ISO формате (например 2026-12-31T23:59:59)", path=key)) + return None + + def _parse_questions(self, data: dict, errors: list[ParseError]) -> list[ParsedQuestion]: + questions_data = data.get("questions") + + if questions_data is None: + errors.append(ParseError("Поле 'questions' обязательно", path="questions")) + return [] + + if not isinstance(questions_data, list): + errors.append(ParseError("Поле 'questions' должно быть массивом", path="questions")) + return [] + + if len(questions_data) == 0: + errors.append(ParseError("Тест должен содержать хотя бы один вопрос", path="questions")) + return [] + + questions: list[ParsedQuestion] = [] + + for i, q_data in enumerate(questions_data): + path = f"questions[{i}]" + + if not isinstance(q_data, dict): + errors.append(ParseError("Вопрос должен быть объектом", path=path)) + continue + + question = self._parse_question(q_data, path, errors) + if question: + questions.append(question) + + return questions + + def _parse_question(self, data: dict, path: str, errors: list[ParseError]) -> ParsedQuestion | None: + text = data.get("text") + if not text or not isinstance(text, str): + errors.append(ParseError("Поле 'text' обязательно и должно быть строкой", path=f"{path}.text")) + return None + + text = text.strip() + if not text: + errors.append(ParseError("Текст вопроса не может быть пустым", path=f"{path}.text")) + return None + + if len(text) > 2000: + errors.append(ParseError("Текст вопроса слишком длинный (максимум 2000)", path=f"{path}.text")) + return None + + question_type = data.get("question_type") + if not question_type or not isinstance(question_type, str): + errors.append(ParseError("Поле 'question_type' обязательно", path=f"{path}.question_type")) + return None + + if question_type not in self.VALID_QUESTION_TYPES: + errors.append(ParseError( + f"Неизвестный тип вопроса '{question_type}'. Допустимые: single, multiple, input", + path=f"{path}.question_type" + )) + return None + + if question_type == "input": + return self._parse_input_question(data, path, text, errors) + else: + return self._parse_choice_question(data, path, text, question_type, errors) + + def _parse_input_question( + self, + data: dict, + path: str, + text: str, + errors: list[ParseError], + ) -> ParsedQuestion | None: + correct_answer = data.get("correct_answer") + + if not correct_answer or not isinstance(correct_answer, str): + errors.append(ParseError( + "Для типа 'input' поле 'correct_answer' обязательно", + path=f"{path}.correct_answer" + )) + return None + + correct_answer = correct_answer.strip() + if not correct_answer: + errors.append(ParseError("Правильный ответ не может быть пустым", path=f"{path}.correct_answer")) + return None + + if len(correct_answer) > 255: + errors.append(ParseError("Правильный ответ слишком длинный (максимум 255)", path=f"{path}.correct_answer")) + return None + + return ParsedQuestion( + text=text, + question_type="input", + options=[ParsedOption(text=correct_answer, is_correct=True)], + correct_answer=correct_answer, + ) + + def _parse_choice_question( + self, + data: dict, + path: str, + text: str, + question_type: str, + errors: list[ParseError], + ) -> ParsedQuestion | None: + options_data = data.get("options") + + if not options_data or not isinstance(options_data, list): + errors.append(ParseError( + f"Для типа '{question_type}' поле 'options' обязательно и должно быть массивом", + path=f"{path}.options" + )) + return None + + if len(options_data) < 2: + errors.append(ParseError("Минимум 2 варианта ответа", path=f"{path}.options")) + return None + + if len(options_data) > 10: + errors.append(ParseError("Максимум 10 вариантов ответа", path=f"{path}.options")) + return None + + options: list[ParsedOption] = [] + correct_count = 0 + + for j, opt_data in enumerate(options_data): + opt_path = f"{path}.options[{j}]" + + if not isinstance(opt_data, dict): + errors.append(ParseError("Вариант ответа должен быть объектом", path=opt_path)) + continue + + opt_text = opt_data.get("text") + if not opt_text or not isinstance(opt_text, str): + errors.append(ParseError("Поле 'text' обязательно", path=f"{opt_path}.text")) + continue + + opt_text = opt_text.strip() + if not opt_text: + errors.append(ParseError("Текст варианта не может быть пустым", path=f"{opt_path}.text")) + continue + + if len(opt_text) > 255: + errors.append(ParseError("Текст варианта слишком длинный (максимум 255)", path=f"{opt_path}.text")) + continue + + is_correct = opt_data.get("is_correct") + if not isinstance(is_correct, bool): + errors.append(ParseError("Поле 'is_correct' должно быть true или false", path=f"{opt_path}.is_correct")) + continue + + if is_correct: + correct_count += 1 + + options.append(ParsedOption(text=opt_text, is_correct=is_correct)) + + if len(options) < 2: + return None + + if correct_count == 0: + errors.append(ParseError("Должен быть хотя бы один правильный ответ", path=f"{path}.options")) + return None + + if question_type == "single" and correct_count > 1: + errors.append(ParseError( + f"Для типа 'single' должен быть ровно один правильный ответ (найдено {correct_count})", + path=f"{path}.options" + )) + return None + + return ParsedQuestion( + text=text, + question_type=question_type, + options=options, + ) From e618ad0f2b5e264bed3ce629bb175be219df2770 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 02:25:41 +0300 Subject: [PATCH 47/57] commit --- src/trudex/application/bot/admin_dialogs/templates.py | 3 +++ src/trudex/application/bot/creator_dialogs/templates.py | 3 +++ src/trudex/domain/test_parser.py | 9 ++++++--- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/admin_dialogs/templates.py index 644ab3a..f8aa8ab 100644 --- a/src/trudex/application/bot/admin_dialogs/templates.py +++ b/src/trudex/application/bot/admin_dialogs/templates.py @@ -349,6 +349,9 @@ async def on_import_file( result = parser.parse(json_str) if isinstance(result, list): + if not result: + await message.answer("❌ Неизвестная ошибка валидации") + return error_lines = ["❌ Ошибки валидации:\n"] for err in result[:10]: path_str = f" ({err.path})" if err.path else "" diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py index b9dbc6c..599cae9 100644 --- a/src/trudex/application/bot/creator_dialogs/templates.py +++ b/src/trudex/application/bot/creator_dialogs/templates.py @@ -349,6 +349,9 @@ async def on_import_file( result = parser.parse(json_str) if isinstance(result, list): + if not result: + await message.answer("❌ Неизвестная ошибка валидации") + return error_lines = ["❌ Ошибки валидации:\n"] for err in result[:10]: path_str = f" ({err.path})" if err.path else "" diff --git a/src/trudex/domain/test_parser.py b/src/trudex/domain/test_parser.py index 682c786..b7524fb 100644 --- a/src/trudex/domain/test_parser.py +++ b/src/trudex/domain/test_parser.py @@ -80,7 +80,8 @@ class TestParser: max_length: int | None = None, errors: list[ParseError] | None = None, ) -> str | None: - errors = errors or [] + if errors is None: + errors = [] value = data.get(key) if value is None: @@ -112,7 +113,8 @@ class TestParser: max_val: int | None = None, errors: list[ParseError] | None = None, ) -> int | None: - errors = errors or [] + if errors is None: + errors = [] value = data.get(key) if value is None: @@ -141,7 +143,8 @@ class TestParser: required: bool, errors: list[ParseError] | None = None, ) -> datetime | None: - errors = errors or [] + if errors is None: + errors = [] value = data.get(key) if value is None: From b3237e281891aaee04fada8b11eeda2c29903c90 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 02:43:25 +0300 Subject: [PATCH 48/57] commit --- src/trudex/application/__main__.py | 3 + .../bot/admin_dialogs/create_test.py | 576 ++++++++++++++++++ .../application/bot/admin_dialogs/groups.py | 2 + .../application/bot/admin_dialogs/states.py | 18 + .../bot/admin_dialogs/templates.py | 7 +- .../application/bot/admin_dialogs/tests.py | 6 +- .../bot/creator_dialogs/templates.py | 7 +- src/trudex/domain/test_parser.py | 4 +- 8 files changed, 612 insertions(+), 11 deletions(-) create mode 100644 src/trudex/application/bot/admin_dialogs/create_test.py diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 6240db8..0b0b213 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -11,6 +11,8 @@ from dishka.integrations.aiogram import setup_dishka from trudex.application.bot.admin_dialogs.broadcast import \ broadcast_dialog as admin_broadcast_dialog +from trudex.application.bot.admin_dialogs.create_test import \ + admin_create_test_dialog from trudex.application.bot.admin_dialogs.groups import \ groups_dialog as admin_groups_dialog from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog @@ -77,6 +79,7 @@ async def main() -> None: admin_groups_dialog, admin_broadcast_dialog, admin_templates_dialog, + admin_create_test_dialog, creator_menu_dialog, creator_users_dialog, creator_tests_dialog, diff --git a/src/trudex/application/bot/admin_dialogs/create_test.py b/src/trudex/application/bot/admin_dialogs/create_test.py new file mode 100644 index 0000000..cee34c2 --- /dev/null +++ b/src/trudex/application/bot/admin_dialogs/create_test.py @@ -0,0 +1,576 @@ +from datetime import date, datetime, time + +from aiogram.types import CallbackQuery, ContentType, Message +from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import (Button, Calendar, Cancel, Column, Row, + ScrollingGroup, Select) +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from trudex.application.bot.admin_dialogs.states import (AdminCreateTestSG, + AdminTestsSG) +from trudex.infrastructure.database.dao.group import GroupDAO +from trudex.infrastructure.database.dao.option import OptionDAO +from trudex.infrastructure.database.dao.question import QuestionDAO +from trudex.infrastructure.database.dao.test import TestDAO +from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.utils.timezone import to_msk + + +async def on_title_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Название не может быть пустым") + return + + title = message.text.strip() + if not title: + await message.answer("❌ Название не может быть пустым") + return + + if len(title) > 255: + await message.answer("❌ Название слишком длинное (максимум 255 символов)") + return + + manager.dialog_data["title"] = title + await manager.switch_to(AdminCreateTestSG.input_description) + + +async def on_description_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Описание не может быть пустым") + return + + description = message.text.strip() + if not description: + await message.answer("❌ Описание не может быть пустым") + return + + if len(description) > 2000: + await message.answer("❌ Описание слишком длинное (максимум 2000 символов)") + return + + manager.dialog_data["description"] = description + await manager.switch_to(AdminCreateTestSG.input_password) + + +@inject +async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager, _group_dao: FromDishka[GroupDAO]): + if not message.text: + await message.answer("❌ Пароль не может быть пустым") + return + + password = message.text.strip() + if not password: + await message.answer("❌ Пароль не может быть пустым") + return + + if len(password) > 255: + await message.answer("❌ Пароль слишком длинный (максимум 255 символов)") + return + + manager.dialog_data["password"] = password + await manager.switch_to(AdminCreateTestSG.input_attempts) + + +@inject +async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager, _group_dao: FromDishka[GroupDAO]): + manager.dialog_data["password"] = None + await manager.switch_to(AdminCreateTestSG.input_attempts) + + +async def on_attempts_input(message: Message, _widget: MessageInput, manager: DialogManager): + 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 + + manager.dialog_data["attempts"] = attempts + await manager.switch_to(AdminCreateTestSG.input_expires_at) + + +async def on_skip_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["attempts"] = None + await manager.switch_to(AdminCreateTestSG.input_expires_at) + + +async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): + manager.dialog_data["expires_at"] = datetime.combine(selected_date, time.min) + await manager.switch_to(AdminCreateTestSG.input_for_group) + + +async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["expires_at"] = None + await manager.switch_to(AdminCreateTestSG.input_for_group) + + +@inject +async def get_groups_for_test(group_dao: FromDishka[GroupDAO], **_kwargs): + groups = await group_dao.get_all() + + return { + "groups": [(str(g.number), str(g.number)) for g in groups], + } + + +async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + manager.dialog_data["for_group"] = int(item_id) + await manager.switch_to(AdminCreateTestSG.confirm_test_info) + + +async def on_skip_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["for_group"] = None + await manager.switch_to(AdminCreateTestSG.confirm_test_info) + + +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_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 "Для всех" + + return { + "info": ( + f"📝 Информация о тесте\n\n" + f"Название: {title}\n" + f"Описание: {description}\n" + f"Пароль: {password_str}\n" + f"Попыток: {attempts_str}\n" + f"Истекает: {expires_str}\n" + f"Для группы: {group_str}" + ) + } + + +@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") + + test = await test_dao.create( + title=title, + description=description, + password=password, + attempts=attempts, + expires_at=expires_at, + for_group=for_group, + ) + + manager.dialog_data["test_id"] = test.id + manager.dialog_data["questions"] = [] + await manager.switch_to(AdminCreateTestSG.add_question) + + +async def on_add_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data["current_question"] = {} + await manager.switch_to(AdminCreateTestSG.input_question_text) + + +async def on_question_input(message: Message, _widget: MessageInput, manager: DialogManager): + current_question = manager.dialog_data.get("current_question", {}) + + if message.content_type == ContentType.PHOTO: + photo = message.photo[-1] if message.photo else None + if photo: + text = (message.caption or "").strip() + if not text: + await message.answer("❌ Изображение должно содержать подпись с текстом вопроса") + return + if len(text) > 2000: + await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)") + return + current_question["tg_file_id"] = photo.file_id + current_question["text"] = text + elif message.content_type == ContentType.TEXT and message.text: + text = message.text.strip() + if not text: + await message.answer("❌ Текст вопроса не может быть пустым") + return + if len(text) > 2000: + await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)") + return + current_question["text"] = text + current_question["tg_file_id"] = None + else: + await message.answer("❌ Отправьте текст или фото с подписью") + return + + manager.dialog_data["current_question"] = current_question + await manager.switch_to(AdminCreateTestSG.select_question_type) + + +async def get_question_type_data(**_kwargs): + return { + "question_types": [ + ("single", "📌 Один правильный ответ"), + ("multiple", "📋 Несколько правильных ответов"), + ("input", "✏️ Ввод текста"), + ] + } + + +async def on_question_type_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + current_question = manager.dialog_data.get("current_question", {}) + current_question["question_type"] = item_id + manager.dialog_data["current_question"] = current_question + + if item_id == "input": + await manager.switch_to(AdminCreateTestSG.input_correct_answer) + else: + manager.dialog_data["current_options"] = [] + await manager.switch_to(AdminCreateTestSG.input_options) + + +async def on_correct_answer_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Правильный ответ не может быть пустым") + return + + answer = message.text.strip() + if not answer: + await message.answer("❌ Правильный ответ не может быть пустым") + return + + if len(answer) > 255: + await message.answer("❌ Ответ слишком длинный (максимум 255 символов)") + return + + current_question = manager.dialog_data.get("current_question", {}) + current_question["correct_answer"] = answer + manager.dialog_data["current_question"] = current_question + await manager.switch_to(AdminCreateTestSG.confirm_question) + + +async def on_option_input(message: Message, _widget: MessageInput, manager: DialogManager): + if not message.text: + await message.answer("❌ Вариант ответа не может быть пустым") + return + + option_text = message.text.strip() + if not option_text: + await message.answer("❌ Вариант ответа не может быть пустым") + return + + if len(option_text) > 255: + await message.answer("❌ Вариант ответа слишком длинный (максимум 255 символов)") + return + + current_options = manager.dialog_data.get("current_options", []) + + if len(current_options) >= 10: + await message.answer("❌ Максимум 10 вариантов ответа") + return + + current_options.append({"text": option_text, "is_correct": False}) + manager.dialog_data["current_options"] = current_options + + await message.answer(f"✅ Вариант {len(current_options)} добавлен") + + +async def on_finish_options(_callback: CallbackQuery, _button: Button, manager: DialogManager): + current_options = manager.dialog_data.get("current_options", []) + if len(current_options) < 2: + await _callback.answer("❌ Добавьте минимум 2 варианта ответа", show_alert=True) + return + + await manager.switch_to(AdminCreateTestSG.mark_correct_options) + + +async def get_options_data(dialog_manager: DialogManager, **_kwargs): + current_options = dialog_manager.dialog_data.get("current_options", []) + formatted_options = [] + for i, opt in enumerate(current_options): + marker = "✅" if opt["is_correct"] else "❌" + formatted_options.append((str(i), f"{marker} {opt['text']}")) + return { + "options": formatted_options, + "options_count": len(current_options), + } + + +async def on_option_toggle(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): + current_options = manager.dialog_data.get("current_options", []) + current_question = manager.dialog_data.get("current_question", {}) + question_type = current_question.get("question_type", "single") + + option_idx = int(item_id) + + if question_type == "single": + for opt in current_options: + opt["is_correct"] = False + current_options[option_idx]["is_correct"] = True + else: + current_options[option_idx]["is_correct"] = not current_options[option_idx]["is_correct"] + + manager.dialog_data["current_options"] = current_options + await _callback.answer() + + +async def on_confirm_correct(_callback: CallbackQuery, _button: Button, manager: DialogManager): + current_options = manager.dialog_data.get("current_options", []) + + if not any(opt["is_correct"] for opt in current_options): + await _callback.answer("❌ Отметьте хотя бы один правильный ответ", show_alert=True) + return + + await manager.switch_to(AdminCreateTestSG.confirm_question) + + +async def get_question_preview(dialog_manager: DialogManager, **_kwargs): + current_question = dialog_manager.dialog_data.get("current_question", {}) + current_options = dialog_manager.dialog_data.get("current_options", []) + + text = current_question.get("text", "") + question_type = current_question.get("question_type", "single") + has_image = current_question.get("tg_file_id") is not None + + type_names = { + "single": "📌 Один правильный ответ", + "multiple": "📋 Несколько правильных ответов", + "input": "✏️ Ввод текста", + } + + preview = f"📝 Предпросмотр вопроса\n\n" + preview += f"Текст: {text}\n" + preview += f"Тип: {type_names[question_type]}\n" + preview += f"Изображение: {'✅ Да' if has_image else '❌ Нет'}\n\n" + + if question_type == "input": + correct_answer = current_question.get("correct_answer", "") + preview += f"Правильный ответ: {correct_answer}" + else: + preview += "Варианты ответов:\n" + for i, opt in enumerate(current_options, 1): + marker = "✅" if opt["is_correct"] else "❌" + preview += f"{i}. {marker} {opt['text']}\n" + + return {"preview": preview} + + +@inject +async def on_save_question( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + question_dao: FromDishka[QuestionDAO], + option_dao: FromDishka[OptionDAO], + 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", []) + + questions_count = await test_repo.count_questions_in_test(test_id) + + question = await question_dao.create( + test_id=test_id, + text=current_question.get("text", ""), + position=questions_count, + question_type=current_question.get("question_type", "single"), + tg_file_id=current_question.get("tg_file_id"), + ) + + if current_question.get("question_type") == "input": + await option_dao.create( + question_id=question.id, + text=current_question.get("correct_answer", ""), + is_correct=True, + ) + else: + for opt in current_options: + await option_dao.create( + question_id=question.id, + text=opt["text"], + is_correct=opt["is_correct"], + ) + + questions = manager.dialog_data.get("questions", []) + questions.append(question.id) + manager.dialog_data["questions"] = questions + + manager.dialog_data.pop("current_question", None) + manager.dialog_data.pop("current_options", None) + + await _callback.answer("✅ Вопрос добавлен") + await manager.switch_to(AdminCreateTestSG.add_question) + + +async def on_cancel_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): + manager.dialog_data.pop("current_question", None) + manager.dialog_data.pop("current_options", None) + await manager.switch_to(AdminCreateTestSG.add_question) + + +async def get_questions_count(dialog_manager: DialogManager, **_kwargs): + questions = dialog_manager.dialog_data.get("questions", []) + return {"questions_count": len(questions)} + + +async def on_finish_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): + questions = manager.dialog_data.get("questions", []) + + if len(questions) == 0: + await _callback.answer("❌ Добавьте хотя бы один вопрос", show_alert=True) + return + + await _callback.answer("✅ Тест создан") + await manager.start(AdminTestsSG.tests_list, mode=StartMode.RESET_STACK) + + +async def on_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.start(AdminTestsSG.tests_list, mode=StartMode.RESET_STACK) + + +admin_create_test_dialog = Dialog( + Window( + Const("📝 Создание теста\n\n💬 Введите название теста:\n(максимум 255 символов)"), + MessageInput(on_title_input), + Cancel(Const("◀️ Отмена")), + state=AdminCreateTestSG.input_title, + ), + Window( + Const("📝 Создание теста\n\n📄 Введите описание теста:\n(максимум 2000 символов)"), + MessageInput(on_description_input), + state=AdminCreateTestSG.input_description, + ), + Window( + Const("🔒 Пароль\n\n🔑 Введите пароль для доступа к тесту или пропустите этот шаг:\n(максимум 255 символов)"), + MessageInput(on_password_input), + Button(Const("⏭️ Без пароля"), id="skip_password", on_click=on_skip_password), + state=AdminCreateTestSG.input_password, + ), + Window( + Const("🔄 Количество попыток\n\n🔢 Введите количество попыток (1-100) или пропустите для неограниченного количества:"), + MessageInput(on_attempts_input), + Button(Const("⏭️ Без ограничений"), id="skip_attempts", on_click=on_skip_attempts), + state=AdminCreateTestSG.input_attempts, + ), + Window( + Const("📅 Срок действия\n\n🗓 Выберите дату истечения теста или пропустите:"), + Calendar(id="calendar", on_click=on_date_selected), + Button(Const("⏭️ Без срока"), id="skip_expires", on_click=on_skip_expires), + state=AdminCreateTestSG.input_expires_at, + ), + Window( + Const("👥 Группа\n\n🎓 Выберите группу или пропустите для всех:"), + ScrollingGroup( + Select( + Format("{item[1]}"), + id="groups", + item_id_getter=lambda x: x[0], + items="groups", + on_click=on_group_selected, + ), + id="groups_scroll", + width=2, + height=7, + ), + Button(Const("⏭️ Для всех"), id="skip_group", on_click=on_skip_group), + state=AdminCreateTestSG.input_for_group, + getter=get_groups_for_test, + ), + Window( + Format("{info}\n\n✅ Подтвердите создание теста:"), + Row( + Button(Const("✅ Создать"), id="confirm", on_click=on_confirm_test), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel), + ), + state=AdminCreateTestSG.confirm_test_info, + getter=get_test_info, + ), + Window( + Format("➕ Добавление вопросов\n\n📊 Вопросов добавлено: {questions_count}\n\n💡 Добавьте вопросы к тесту:"), + Column( + Button(Const("➕ Добавить вопрос"), id="add_question", on_click=on_add_question), + Button(Const("✅ Завершить создание"), id="finish", on_click=on_finish_test), + ), + state=AdminCreateTestSG.add_question, + getter=get_questions_count, + ), + Window( + Const("❓ Текст вопроса\n\n📝 Отправьте текст вопроса или 📷 фото с подписью:\n(максимум 2000 символов)"), + MessageInput(on_question_input, content_types=[ContentType.TEXT, ContentType.PHOTO]), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=AdminCreateTestSG.input_question_text, + ), + Window( + Const("📋 Тип вопроса\n\n🎯 Выберите тип вопроса:"), + Column(Select( + Format("{item[1]}"), + id="question_type", + item_id_getter=lambda x: x[0], + items="question_types", + on_click=on_question_type_selected, + )), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=AdminCreateTestSG.select_question_type, + getter=get_question_type_data, + ), + Window( + Const("✏️ Правильный ответ\n\n💬 Введите правильный ответ (регистр и пробелы игнорируются):\n(максимум 255 символов)"), + MessageInput(on_correct_answer_input), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=AdminCreateTestSG.input_correct_answer, + ), + Window( + Format("📝 Варианты ответов\n\n📊 Добавлено вариантов: {options_count}/10\n\n💬 Введите вариант ответа:\n(максимум 255 символов)"), + MessageInput(on_option_input), + Button(Const("✅ Завершить добавление вариантов"), id="finish_options", on_click=on_finish_options), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=AdminCreateTestSG.input_options, + getter=get_options_data, + ), + Window( + Const("✅ Правильные ответы\n\nОтметьте правильные варианты ответов:"), + Column(Select( + Format("{item[1]}"), + id="options", + item_id_getter=lambda x: x[0], + items="options", + on_click=on_option_toggle, + )), + Button(Const("✅ Подтвердить выбор"), id="confirm", on_click=on_confirm_correct), + Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), + state=AdminCreateTestSG.mark_correct_options, + getter=get_options_data, + ), + Window( + Format("{preview}\n\n💾 Сохранить вопрос?"), + Row( + Button(Const("✅ Сохранить"), id="save", on_click=on_save_question), + Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_question), + ), + state=AdminCreateTestSG.confirm_question, + getter=get_question_preview, + ), +) diff --git a/src/trudex/application/bot/admin_dialogs/groups.py b/src/trudex/application/bot/admin_dialogs/groups.py index 4d62d10..75bab05 100644 --- a/src/trudex/application/bot/admin_dialogs/groups.py +++ b/src/trudex/application/bot/admin_dialogs/groups.py @@ -116,6 +116,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/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index e346506..c374aa7 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -41,3 +41,21 @@ class AdminGroupsSG(StatesGroup): add_group_input_number = State() delete_groups_list = State() delete_confirm = State() + + +class AdminCreateTestSG(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() + add_question = State() + input_question_text = State() + select_question_type = State() + input_correct_answer = State() + input_options = State() + mark_correct_options = State() + confirm_question = State() + test_created = State() diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/admin_dialogs/templates.py index f8aa8ab..8529035 100644 --- a/src/trudex/application/bot/admin_dialogs/templates.py +++ b/src/trudex/application/bot/admin_dialogs/templates.py @@ -9,7 +9,7 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminTemplatesSG +from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminTemplatesSG, AdminTestsSG from trudex.domain.test_parser import ParsedTest, TestParser from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO @@ -291,6 +291,7 @@ async def create_test_from_parsed( attempts=parsed.attempts, expires_at=parsed.expires_at, for_group=parsed.for_group, + is_active=False, ) for position, q in enumerate(parsed.questions): @@ -361,7 +362,7 @@ async def on_import_file( await message.answer("\n".join(error_lines)) return - test_id = await create_test_from_parsed(result, test_dao, question_dao, option_dao) + await create_test_from_parsed(result, test_dao, question_dao, option_dao) await message.answer( f"✅ Тест импортирован!\n\n" @@ -370,7 +371,7 @@ async def on_import_file( f"Тест создан в деактивированном состоянии." ) - await manager.switch_to(AdminTemplatesSG.main) + await manager.start(AdminTestsSG.tests_list, mode=StartMode.RESET_STACK) templates_dialog = Dialog( diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 8363be1..7bfbe80 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -13,8 +13,8 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, - AdminTestsSG) -from trudex.application.bot.creator_dialogs.states import CreateTestSG + AdminTestsSG, + AdminCreateTestSG) from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository @@ -420,7 +420,7 @@ async def on_remove_expires(_callback: CallbackQuery, _button: Button, manager: async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(CreateTestSG.input_title, mode=StartMode.RESET_STACK) + await manager.start(AdminCreateTestSG.input_title, mode=StartMode.RESET_STACK) async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py index 599cae9..396c774 100644 --- a/src/trudex/application/bot/creator_dialogs/templates.py +++ b/src/trudex/application/bot/creator_dialogs/templates.py @@ -9,7 +9,7 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorTemplatesSG +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorTemplatesSG, CreatorTestsSG from trudex.domain.test_parser import ParsedTest, TestParser from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO @@ -291,6 +291,7 @@ async def create_test_from_parsed( attempts=parsed.attempts, expires_at=parsed.expires_at, for_group=parsed.for_group, + is_active=False, ) for position, q in enumerate(parsed.questions): @@ -361,7 +362,7 @@ async def on_import_file( await message.answer("\n".join(error_lines)) return - test_id = await create_test_from_parsed(result, test_dao, question_dao, option_dao) + await create_test_from_parsed(result, test_dao, question_dao, option_dao) await message.answer( f"✅ Тест импортирован!\n\n" @@ -370,7 +371,7 @@ async def on_import_file( f"Тест создан в деактивированном состоянии." ) - await manager.switch_to(CreatorTemplatesSG.main) + await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK) templates_dialog = Dialog( diff --git a/src/trudex/domain/test_parser.py b/src/trudex/domain/test_parser.py index b7524fb..9fc6009 100644 --- a/src/trudex/domain/test_parser.py +++ b/src/trudex/domain/test_parser.py @@ -127,11 +127,11 @@ class TestParser: return None if min_val is not None and value < min_val: - errors.append(ParseError(f"Поле '{key}' должно быть >= {min_val}", path=key)) + errors.append(ParseError(f"Поле '{key}' должно быть не меньше {min_val}", path=key)) return None if max_val is not None and value > max_val: - errors.append(ParseError(f"Поле '{key}' должно быть <= {max_val}", path=key)) + errors.append(ParseError(f"Поле '{key}' должно быть не больше {max_val}", path=key)) return None return value From d04bce19136256d4f71809f597a8dc20d7218b61 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 03:00:44 +0300 Subject: [PATCH 49/57] commit --- pyproject.toml | 3 + src/trudex/application/__main__.py | 50 +++++--------- .../bot/admin_dialogs/broadcast.py | 3 +- .../bot/admin_dialogs/create_test.py | 6 +- .../application/bot/admin_dialogs/groups.py | 6 +- .../bot/admin_dialogs/main_menu.py | 8 +-- .../bot/admin_dialogs/templates.py | 4 +- .../application/bot/admin_dialogs/tests.py | 7 +- .../application/bot/admin_dialogs/users.py | 6 +- .../bot/creator_dialogs/broadcast.py | 3 +- .../bot/creator_dialogs/create_test.py | 6 +- .../application/bot/creator_dialogs/groups.py | 6 +- .../bot/creator_dialogs/main_menu.py | 8 +-- .../bot/creator_dialogs/templates.py | 4 +- .../application/bot/creator_dialogs/tests.py | 7 +- .../application/bot/creator_dialogs/users.py | 6 +- src/trudex/application/bot/handlers.py | 40 +++++++++-- .../application/bot/user_dialogs/main_menu.py | 3 +- .../bot/user_dialogs/registration.py | 6 +- .../application/bot/user_dialogs/take_test.py | 2 +- src/trudex/domain/schemas.py | 9 ++- src/trudex/infrastructure/database/config.py | 3 +- .../infrastructure/database/dao/question.py | 15 +++-- .../infrastructure/database/dao/test.py | 62 ++++++++++++----- .../infrastructure/database/dao/user.py | 67 ++++++++++--------- .../infrastructure/database/dto/question.py | 2 +- .../database/dto/test_attempt.py | 3 +- src/trudex/infrastructure/database/models.py | 15 +---- .../infrastructure/database/repo/__init__.py | 3 +- .../database/repo/test_attempt.py | 6 +- src/trudex/infrastructure/di.py | 5 +- src/trudex/infrastructure/scheduling/tasks.py | 14 ++-- .../infrastructure/utils/bot_commands.py | 3 +- src/trudex/infrastructure/utils/broadcast.py | 12 +++- .../infrastructure/utils/test_id_to_hash.py | 3 +- 35 files changed, 213 insertions(+), 193 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cd614cd..826eb2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ dev = [ [tool.pyright] typeCheckingMode = "standard" +[tool.isort] +line_length = 110 + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 0b0b213..45d3fa9 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -9,42 +9,26 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from dishka import make_async_container from dishka.integrations.aiogram import setup_dishka -from trudex.application.bot.admin_dialogs.broadcast import \ - broadcast_dialog as admin_broadcast_dialog -from trudex.application.bot.admin_dialogs.create_test import \ - admin_create_test_dialog -from trudex.application.bot.admin_dialogs.groups import \ - groups_dialog as admin_groups_dialog +from trudex.application.bot.admin_dialogs.broadcast import broadcast_dialog as admin_broadcast_dialog +from trudex.application.bot.admin_dialogs.create_test import admin_create_test_dialog +from trudex.application.bot.admin_dialogs.groups import groups_dialog as admin_groups_dialog from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog -from trudex.application.bot.admin_dialogs.templates import \ - templates_dialog as admin_templates_dialog -from trudex.application.bot.admin_dialogs.tests import \ - tests_dialog as admin_tests_dialog -from trudex.application.bot.admin_dialogs.users import \ - users_dialog as admin_users_dialog -from trudex.application.bot.creator_dialogs.broadcast import \ - broadcast_dialog as creator_broadcast_dialog -from trudex.application.bot.creator_dialogs.create_test import \ - create_test_dialog -from trudex.application.bot.creator_dialogs.groups import \ - groups_dialog as creator_groups_dialog -from trudex.application.bot.creator_dialogs.main_menu import \ - creator_menu_dialog -from trudex.application.bot.creator_dialogs.templates import \ - templates_dialog as creator_templates_dialog -from trudex.application.bot.creator_dialogs.tests import \ - tests_dialog as creator_tests_dialog -from trudex.application.bot.creator_dialogs.users import \ - users_dialog as creator_users_dialog +from trudex.application.bot.admin_dialogs.templates import templates_dialog as admin_templates_dialog +from trudex.application.bot.admin_dialogs.tests import tests_dialog as admin_tests_dialog +from trudex.application.bot.admin_dialogs.users import users_dialog as admin_users_dialog +from trudex.application.bot.creator_dialogs.broadcast import broadcast_dialog as creator_broadcast_dialog +from trudex.application.bot.creator_dialogs.create_test import create_test_dialog +from trudex.application.bot.creator_dialogs.groups import groups_dialog as creator_groups_dialog +from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog +from trudex.application.bot.creator_dialogs.templates import templates_dialog as creator_templates_dialog +from trudex.application.bot.creator_dialogs.tests import tests_dialog as creator_tests_dialog +from trudex.application.bot.creator_dialogs.users import users_dialog as creator_users_dialog from trudex.application.bot.handlers import router -from trudex.application.bot.middlewares.reject_not_admin import \ - RejectNotAdminMiddleware -from trudex.application.bot.middlewares.reject_not_creator import \ - RejectNotCreatorMiddleware +from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware +from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware from trudex.application.bot.user_dialogs.deeplink import deeplink_dialog from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog -from trudex.application.bot.user_dialogs.registration import \ - registration_dialog +from trudex.application.bot.user_dialogs.registration import registration_dialog from trudex.application.bot.user_dialogs.take_test import take_test_dialog from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.di import DatabaseProvider, SchedulerProvider @@ -99,8 +83,6 @@ async def main() -> None: ) 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/broadcast.py b/src/trudex/application/bot/admin_dialogs/broadcast.py index 7f49806..44a82ba 100644 --- a/src/trudex/application/bot/admin_dialogs/broadcast.py +++ b/src/trudex/application/bot/admin_dialogs/broadcast.py @@ -6,8 +6,7 @@ from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, - AdminMenuSG) +from trudex.application.bot.admin_dialogs.states import AdminBroadcastSG, AdminMenuSG from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.broadcast import broadcast_message diff --git a/src/trudex/application/bot/admin_dialogs/create_test.py b/src/trudex/application/bot/admin_dialogs/create_test.py index cee34c2..006e48a 100644 --- a/src/trudex/application/bot/admin_dialogs/create_test.py +++ b/src/trudex/application/bot/admin_dialogs/create_test.py @@ -3,14 +3,12 @@ from datetime import date, datetime, time from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Calendar, Cancel, Column, Row, - ScrollingGroup, Select) +from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import (AdminCreateTestSG, - AdminTestsSG) +from trudex.application.bot.admin_dialogs.states import AdminCreateTestSG, AdminTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO diff --git a/src/trudex/application/bot/admin_dialogs/groups.py b/src/trudex/application/bot/admin_dialogs/groups.py index 75bab05..8b80665 100644 --- a/src/trudex/application/bot/admin_dialogs/groups.py +++ b/src/trudex/application/bot/admin_dialogs/groups.py @@ -1,14 +1,12 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, - Select) +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import (AdminGroupsSG, - AdminMenuSG) +from trudex.application.bot.admin_dialogs.states import AdminGroupsSG, AdminMenuSG from trudex.infrastructure.database.dao.group import GroupDAO diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index 33d9d36..c59ad27 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -3,12 +3,8 @@ from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, - AdminGroupsSG, - AdminMenuSG, - AdminTemplatesSG, - AdminTestsSG, - AdminUsersSG) +from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, AdminGroupsSG, AdminMenuSG, + AdminTemplatesSG, AdminTestsSG, AdminUsersSG) async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/admin_dialogs/templates.py index 8529035..80329aa 100644 --- a/src/trudex/application/bot/admin_dialogs/templates.py +++ b/src/trudex/application/bot/admin_dialogs/templates.py @@ -10,13 +10,13 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminTemplatesSG, AdminTestsSG +from trudex.domain.schemas import QuestionType from trudex.domain.test_parser import ParsedTest, TestParser from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository - TEMPLATES_INFO = ( "📦 Шаблоны тестов\n\n" "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" @@ -227,7 +227,7 @@ async def on_test_selected_for_export( "question_type": question.question_type, } - if question.question_type == "input": + if question.question_type == QuestionType.INPUT: correct_options = [o for o in options if o.is_correct] if correct_options: question_data["correct_answer"] = correct_options[0].text diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/admin_dialogs/tests.py index 7bfbe80..6cd66ab 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/admin_dialogs/tests.py @@ -6,15 +6,12 @@ from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, - ScrollingGroup, Select) +from aiogram_dialog.widgets.kbd import Button, Calendar, Column, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, - AdminTestsSG, - AdminCreateTestSG) +from trudex.application.bot.admin_dialogs.states import AdminCreateTestSG, AdminMenuSG, AdminTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index 34a08b6..55cba6b 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -1,14 +1,12 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Column, ScrollingGroup, Select, - SwitchTo) +from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import (AdminMenuSG, - AdminUsersSG) +from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG from trudex.infrastructure.database.dao.user import UserDAO diff --git a/src/trudex/application/bot/creator_dialogs/broadcast.py b/src/trudex/application/bot/creator_dialogs/broadcast.py index 52878fc..ba534fd 100644 --- a/src/trudex/application/bot/creator_dialogs/broadcast.py +++ b/src/trudex/application/bot/creator_dialogs/broadcast.py @@ -6,8 +6,7 @@ from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, - CreatorMenuSG) +from trudex.application.bot.creator_dialogs.states import CreatorBroadcastSG, CreatorMenuSG from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.broadcast import broadcast_message diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py index 513a21c..22ce7cb 100644 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ b/src/trudex/application/bot/creator_dialogs/create_test.py @@ -3,14 +3,12 @@ from datetime import date, datetime, time from aiogram.types import CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Calendar, Cancel, Column, Row, - ScrollingGroup, Select) +from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import (CreateTestSG, - CreatorTestsSG) +from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO diff --git a/src/trudex/application/bot/creator_dialogs/groups.py b/src/trudex/application/bot/creator_dialogs/groups.py index a5565bd..b8ff31c 100644 --- a/src/trudex/application/bot/creator_dialogs/groups.py +++ b/src/trudex/application/bot/creator_dialogs/groups.py @@ -1,14 +1,12 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, - Select) +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import (CreatorGroupsSG, - CreatorMenuSG) +from trudex.application.bot.creator_dialogs.states import CreatorGroupsSG, CreatorMenuSG from trudex.infrastructure.database.dao.group import GroupDAO diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index a37ebbf..b77a873 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -3,12 +3,8 @@ from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, - CreatorGroupsSG, - CreatorMenuSG, - CreatorTemplatesSG, - CreatorTestsSG, - CreatorUsersSG) +from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, CreatorGroupsSG, CreatorMenuSG, + CreatorTemplatesSG, CreatorTestsSG, CreatorUsersSG) async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py index 396c774..9a12de9 100644 --- a/src/trudex/application/bot/creator_dialogs/templates.py +++ b/src/trudex/application/bot/creator_dialogs/templates.py @@ -10,13 +10,13 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorTemplatesSG, CreatorTestsSG +from trudex.domain.schemas import QuestionType from trudex.domain.test_parser import ParsedTest, TestParser from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository - TEMPLATES_INFO = ( "📦 Шаблоны тестов\n\n" "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" @@ -227,7 +227,7 @@ async def on_test_selected_for_export( "question_type": question.question_type, } - if question.question_type == "input": + if question.question_type == QuestionType.INPUT: correct_options = [o for o in options if o.is_correct] if correct_options: question_data["correct_answer"] = correct_options[0].text diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py index 42969ab..6cd9ba4 100644 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ b/src/trudex/application/bot/creator_dialogs/tests.py @@ -8,16 +8,13 @@ from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.api.entities import MediaAttachment from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import (Button, Calendar, Column, Row, - ScrollingGroup, Select) +from aiogram_dialog.widgets.kbd import Button, Calendar, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import (CreateTestSG, - CreatorMenuSG, - CreatorTestsSG) +from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorMenuSG, CreatorTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index 3d5e741..294f551 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -4,14 +4,12 @@ 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 -from aiogram_dialog.widgets.kbd import (Button, Column, Row, ScrollingGroup, - Select, SwitchTo) +from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import (CreatorMenuSG, - CreatorUsersSG) +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.bot_commands import setup_bot_commands diff --git a/src/trudex/application/bot/handlers.py b/src/trudex/application/bot/handlers.py index 75b0570..2b4d54f 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/trudex/application/bot/handlers.py @@ -1,5 +1,7 @@ +import logging + from aiogram import Router -from aiogram.filters import Command, CommandStart, CommandObject +from aiogram.filters import Command, CommandObject, CommandStart from aiogram.types import ErrorEvent, Message from aiogram_dialog import DialogManager, StartMode from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent @@ -7,11 +9,7 @@ from dishka.integrations.aiogram import FromDishka from trudex.application.bot.admin_dialogs.states import AdminMenuSG from trudex.application.bot.creator_dialogs.states import CreatorMenuSG -from trudex.application.bot.user_dialogs.states import ( - UserDeeplinkSG, - UserMenuSG, - UserRegistrationSG, -) +from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.user import UserDAO @@ -20,6 +18,7 @@ from trudex.infrastructure.utils.test_id_to_hash import decode_id from trudex.infrastructure.utils.timezone import now_msk_naive router = Router() +logger = logging.getLogger(__name__) async def ensure_user_registered( @@ -115,6 +114,13 @@ async def start_with_deeplink( assert message.from_user is not None deeplink = command.args + logger.info( + "Deeplink start: user_id=%d, username=%s, deeplink=%s", + message.from_user.id, + message.from_user.username, + deeplink, + ) + if not deeplink: await start_handler(message, user_dao, group_dao, dialog_manager) return @@ -122,6 +128,7 @@ async def start_with_deeplink( try: test_id = decode_id(deeplink, config.security.encode_key) except (ValueError, IndexError): + logger.warning("Invalid deeplink: user_id=%d, deeplink=%s", message.from_user.id, deeplink) await message.answer("❌ Неверная ссылка на тест") await start_handler(message, user_dao, group_dao, dialog_manager) return @@ -138,6 +145,12 @@ async def start_with_deeplink( ) if not is_valid: + logger.info( + "Test validation failed: user_id=%d, test_id=%d, error=%s", + message.from_user.id, + test_id, + error, + ) await dialog_manager.start( UserDeeplinkSG.test_preview, mode=StartMode.RESET_STACK, @@ -145,6 +158,7 @@ async def start_with_deeplink( ) return + logger.info("User starting test via deeplink: user_id=%d, test_id=%d", message.from_user.id, test_id) await dialog_manager.start( UserDeeplinkSG.test_preview, mode=StartMode.RESET_STACK, @@ -159,6 +173,13 @@ async def start_handler( user_dao: FromDishka[UserDAO], group_dao: FromDishka[GroupDAO], ) -> None: + assert message.from_user is not None + logger.info( + "Start command: user_id=%d, username=%s", + message.from_user.id, + message.from_user.username, + ) + is_registered = await ensure_user_registered( user_dao, group_dao, message, dialog_manager ) @@ -169,15 +190,22 @@ async def start_handler( @router.message(Command("admin")) async def admin_command(_message: Message, dialog_manager: DialogManager) -> None: + assert _message.from_user is not None + logger.info("Admin panel access: user_id=%d", _message.from_user.id) await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) @router.message(Command("creator")) async def creator_command(_message: Message, dialog_manager: DialogManager) -> None: + assert _message.from_user is not None + logger.info("Creator panel access: user_id=%d", _message.from_user.id) await dialog_manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) @router.error() async def dialog_error_handler(event: ErrorEvent, dialog_manager: DialogManager) -> None: if isinstance(event.exception, (UnknownIntent, OutdatedIntent)): + logger.debug("Dialog intent error, resetting to main menu: %s", type(event.exception).__name__) await dialog_manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + else: + logger.exception("Unhandled error in dialog: %s", event.exception) diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/trudex/application/bot/user_dialogs/main_menu.py index 21da133..1460415 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/trudex/application/bot/user_dialogs/main_menu.py @@ -1,6 +1,6 @@ import asyncio import functools -from datetime import timedelta +from datetime import datetime, timedelta from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message @@ -21,7 +21,6 @@ from trudex.infrastructure.utils.config import Config from trudex.infrastructure.utils.qr_generator import generate_qr_bytes from trudex.infrastructure.utils.test_id_to_hash import encode_id from trudex.infrastructure.utils.timezone import now_msk, now_msk_naive, to_msk -from datetime import datetime def can_edit_field(updated_at: datetime | None) -> bool: diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/trudex/application/bot/user_dialogs/registration.py index 916dcca..953cf23 100644 --- a/src/trudex/application/bot/user_dialogs/registration.py +++ b/src/trudex/application/bot/user_dialogs/registration.py @@ -6,11 +6,7 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.user_dialogs.states import ( - UserDeeplinkSG, - UserMenuSG, - UserRegistrationSG, -) +from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.user import UserDAO diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index 6479df6..c1b6e6f 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -7,9 +7,9 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.user_dialogs.states import UserMenuSG, UserTestSG +from trudex.domain.schemas import QuestionType from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO -from trudex.infrastructure.database.models import QuestionType from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.utils.timezone import now_msk_naive diff --git a/src/trudex/domain/schemas.py b/src/trudex/domain/schemas.py index 46878d1..74db228 100644 --- a/src/trudex/domain/schemas.py +++ b/src/trudex/domain/schemas.py @@ -1,5 +1,12 @@ from dataclasses import dataclass from datetime import datetime +from enum import Enum + + +class QuestionType(str, Enum): + SINGLE = "single" + MULTIPLE = "multiple" + INPUT = "input" @dataclass @@ -46,7 +53,7 @@ class Question: test_id: int text: str position: int = 0 - question_type: str = "single" + question_type: QuestionType = QuestionType.SINGLE tg_file_id: str | None = None diff --git a/src/trudex/infrastructure/database/config.py b/src/trudex/infrastructure/database/config.py index 8b3bd0f..fba9003 100644 --- a/src/trudex/infrastructure/database/config.py +++ b/src/trudex/infrastructure/database/config.py @@ -1,5 +1,4 @@ -from sqlalchemy.ext.asyncio import (AsyncSession, async_sessionmaker, - create_async_engine) +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]: diff --git a/src/trudex/infrastructure/database/dao/question.py b/src/trudex/infrastructure/database/dao/question.py index c67a60f..ac483c9 100644 --- a/src/trudex/infrastructure/database/dao/question.py +++ b/src/trudex/infrastructure/database/dao/question.py @@ -2,8 +2,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from trudex.domain.schemas import Question as DomainQuestion +from trudex.domain.schemas import QuestionType from trudex.infrastructure.database.dto.question import QuestionDTO -from trudex.infrastructure.database.models import Question, QuestionType +from trudex.infrastructure.database.models import Question class QuestionDAO: @@ -27,14 +28,16 @@ class QuestionDAO: test_id: int, text: str, position: int = 0, - question_type: str = "single", + question_type: str | QuestionType = QuestionType.SINGLE, tg_file_id: str | None = None, ) -> DomainQuestion: + if isinstance(question_type, str): + question_type = QuestionType(question_type) question = Question( test_id=test_id, text=text, position=position, - question_type=QuestionType(question_type), + question_type=question_type, tg_file_id=tg_file_id, ) self.session.add(question) @@ -47,7 +50,7 @@ class QuestionDAO: question_id: int, text: str | None = None, position: int | None = None, - question_type: str | None = None, + question_type: str | QuestionType | None = None, tg_file_id: str | None = None, ) -> DomainQuestion | None: result = await self.session.execute( @@ -62,7 +65,9 @@ class QuestionDAO: if position is not None: question.position = position if question_type is not None: - question.question_type = QuestionType(question_type) + if isinstance(question_type, str): + question_type = QuestionType(question_type) + question.question_type = question_type if tg_file_id is not None: question.tg_file_id = tg_file_id diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/trudex/infrastructure/database/dao/test.py index 6e3d326..2694653 100644 --- a/src/trudex/infrastructure/database/dao/test.py +++ b/src/trudex/infrastructure/database/dao/test.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import NotRequired, TypedDict, Unpack from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -8,6 +9,25 @@ from trudex.infrastructure.database.dto.test import TestDTO from trudex.infrastructure.database.models import Test +class _UNSET: + """Sentinel для различения None и "не передано".""" + pass + + +UNSET = _UNSET() + + +class TestUpdateFields(TypedDict, total=False): + title: str + description: str | None + for_group: int | None + password: str | None + expires_at: datetime | None + attempts: int | None + is_active: bool + are_results_viewable: bool + + class TestDAO: def __init__(self, session: AsyncSession) -> None: self.session: AsyncSession = session @@ -26,6 +46,16 @@ class TestDAO: models = list(result.scalars().all()) return [TestDTO(model).to_domain() for model in models] + async def get_expired_active_tests(self, now: datetime) -> list[DomainTest]: + result = await self.session.execute( + select(Test) + .where(Test.is_active == True) + .where(Test.expires_at.isnot(None)) + .where(Test.expires_at < now) + ) + models = list(result.scalars().all()) + return [TestDTO(model).to_domain() for model in models] + async def create( self, title: str, @@ -55,14 +85,14 @@ class TestDAO: async def update( self, test_id: int, - title: str | None = None, - description: str | None = None, - for_group: int | None = None, - password: str | None = None, - expires_at: datetime | None = None, - attempts: int | None = None, - is_active: bool | None = None, - are_results_viewable: bool | None = None, + title: str | _UNSET = UNSET, + description: str | None | _UNSET = UNSET, + for_group: int | None | _UNSET = UNSET, + password: str | None | _UNSET = UNSET, + expires_at: datetime | None | _UNSET = UNSET, + attempts: int | None | _UNSET = UNSET, + is_active: bool | _UNSET = UNSET, + are_results_viewable: bool | _UNSET = UNSET, ) -> DomainTest | None: result = await self.session.execute( select(Test).where(Test.id == test_id) @@ -71,21 +101,21 @@ class TestDAO: if not test: return None - if title is not None: + if not isinstance(title, _UNSET): test.title = title - if description is not None: + if not isinstance(description, _UNSET): test.description = description - if for_group is not None: + if not isinstance(for_group, _UNSET): test.for_group = for_group - if password is not None: + if not isinstance(password, _UNSET): test.password = password - if expires_at is not None: + if not isinstance(expires_at, _UNSET): test.expires_at = expires_at - if attempts is not None: + if not isinstance(attempts, _UNSET): test.attempts = attempts - if is_active is not None: + if not isinstance(is_active, _UNSET): test.is_active = is_active - if are_results_viewable is not None: + if not isinstance(are_results_viewable, _UNSET): test.are_results_viewable = are_results_viewable await self.session.flush() diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index 712e2f9..8b88842 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -8,6 +8,14 @@ from trudex.infrastructure.database.dto.user import UserDTO from trudex.infrastructure.database.models import User +class _UNSET: + """Sentinel для различения None и "не передано".""" + pass + + +UNSET = _UNSET() + + class UserDAO: def __init__(self, session: AsyncSession) -> None: self.session: AsyncSession = session @@ -53,14 +61,14 @@ class UserDAO: async def update( self, user_id: int, - username: str | None = None, - first_name: str | None = None, - last_name: str | None = None, - name: str | None = None, - group: int | None = None, - is_admin: bool | None = None, - name_updated_at: datetime | None = None, - group_updated_at: datetime | None = None, + username: str | None | _UNSET = UNSET, + first_name: str | _UNSET = UNSET, + last_name: str | None | _UNSET = UNSET, + name: str | None | _UNSET = UNSET, + group: int | None | _UNSET = UNSET, + is_admin: bool | _UNSET = UNSET, + name_updated_at: datetime | None | _UNSET = UNSET, + group_updated_at: datetime | None | _UNSET = UNSET, ) -> DomainUser | None: result = await self.session.execute( select(User).where(User.id == user_id) @@ -69,21 +77,21 @@ class UserDAO: if not user: return None - if username is not None: + if not isinstance(username, _UNSET): user.username = username - if first_name is not None: + if not isinstance(first_name, _UNSET): user.first_name = first_name - if last_name is not None: + if not isinstance(last_name, _UNSET): user.last_name = last_name - if name is not None: + if not isinstance(name, _UNSET): user.name = name - if group is not None: + if not isinstance(group, _UNSET): user.group = group - if is_admin is not None: + if not isinstance(is_admin, _UNSET): user.is_admin = is_admin - if name_updated_at is not None: + if not isinstance(name_updated_at, _UNSET): user.name_updated_at = name_updated_at - if group_updated_at is not None: + if not isinstance(group_updated_at, _UNSET): user.group_updated_at = group_updated_at await self.session.flush() @@ -108,9 +116,9 @@ class UserDAO: first_name: str, username: str | None = None, last_name: str | None = None, - name: str | None = None, - group: int | None = None, - is_admin: bool | None = None, + name: str | None | _UNSET = UNSET, + group: int | None | _UNSET = UNSET, + is_admin: bool | _UNSET = UNSET, ) -> DomainUser: result = await self.session.execute( select(User).where(User.id == user_id) @@ -118,17 +126,14 @@ class UserDAO: user = result.scalar_one_or_none() if user: - if username is not None: - user.username = username - if first_name is not None: - user.first_name = first_name - if last_name is not None: - user.last_name = last_name - if name is not None: + user.username = username + user.first_name = first_name + user.last_name = last_name + if not isinstance(name, _UNSET): user.name = name - if group is not None: + if not isinstance(group, _UNSET): user.group = group - if is_admin is not None: + if not isinstance(is_admin, _UNSET): user.is_admin = is_admin await self.session.flush() await self.session.refresh(user) @@ -139,7 +144,7 @@ class UserDAO: username=username, first_name=first_name, last_name=last_name, - name=name, - group=group, - is_admin=is_admin or False, + name=name if not isinstance(name, _UNSET) else None, + group=group if not isinstance(group, _UNSET) else None, + is_admin=is_admin if not isinstance(is_admin, _UNSET) else False, ) diff --git a/src/trudex/infrastructure/database/dto/question.py b/src/trudex/infrastructure/database/dto/question.py index 6490f4b..a63bbbf 100644 --- a/src/trudex/infrastructure/database/dto/question.py +++ b/src/trudex/infrastructure/database/dto/question.py @@ -12,6 +12,6 @@ class QuestionDTO: test_id=self.model.test_id, text=self.model.text, position=self.model.position, - question_type=self.model.question_type.value, + question_type=self.model.question_type, tg_file_id=self.model.tg_file_id, ) diff --git a/src/trudex/infrastructure/database/dto/test_attempt.py b/src/trudex/infrastructure/database/dto/test_attempt.py index 9fb3255..786eb38 100644 --- a/src/trudex/infrastructure/database/dto/test_attempt.py +++ b/src/trudex/infrastructure/database/dto/test_attempt.py @@ -1,6 +1,5 @@ from trudex.domain.schemas import TestAttempt as DomainTestAttempt -from trudex.infrastructure.database.models import \ - TestAttempt as TestAttemptModel +from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel class TestAttemptDTO: diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 890dd7a..2d18dca 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -1,11 +1,11 @@ from datetime import datetime -from enum import Enum from typing import final -from sqlalchemy import (BigInteger, CheckConstraint, ForeignKey, Integer, - String, Text, func) +from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from trudex.domain.schemas import QuestionType + class Base(DeclarativeBase): pass @@ -40,15 +40,6 @@ class Group(Base): __table_args__ = ( CheckConstraint("number >= 1000 AND number <= 9999", name="check_group_number"), ) - __table_args__ = ( - CheckConstraint("number >= 1000 AND number <= 9999", name="check_group_number"), - ) - - -class QuestionType(str, Enum): - SINGLE = "single" - MULTIPLE = "multiple" - INPUT = "input" @final diff --git a/src/trudex/infrastructure/database/repo/__init__.py b/src/trudex/infrastructure/database/repo/__init__.py index 1f6a9b8..3d25dad 100644 --- a/src/trudex/infrastructure/database/repo/__init__.py +++ b/src/trudex/infrastructure/database/repo/__init__.py @@ -1,6 +1,5 @@ from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import \ - TestAttemptRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository __all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"] diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index 1ec61fa..4842e4e 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -9,8 +9,7 @@ from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.dto.test_attempt import TestAttemptDTO from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO -from trudex.infrastructure.database.models import \ - TestAttempt as TestAttemptModel +from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel from trudex.infrastructure.utils.timezone import now_msk_naive @@ -176,8 +175,7 @@ class TestAttemptRepository: } async def get_most_difficult_questions(self, test_id: int, limit: int = 10) -> list[tuple[int, float]]: - from trudex.infrastructure.database.models import \ - Question as QuestionModel + from trudex.infrastructure.database.models import Question as QuestionModel result = await self.session.execute( select( diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index fa4b406..e4706d3 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -1,5 +1,5 @@ -from collections.abc import AsyncIterable import logging +from collections.abc import AsyncIterable from apscheduler.schedulers.asyncio import AsyncIOScheduler from dishka import AsyncContainer, Provider, Scope, provide @@ -14,8 +14,7 @@ from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import \ - TestAttemptRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.scheduling.tasks import deactivate_expired_tests from trudex.infrastructure.utils.config import Config diff --git a/src/trudex/infrastructure/scheduling/tasks.py b/src/trudex/infrastructure/scheduling/tasks.py index 5667ccb..85f91f7 100644 --- a/src/trudex/infrastructure/scheduling/tasks.py +++ b/src/trudex/infrastructure/scheduling/tasks.py @@ -1,15 +1,19 @@ +import logging + from dishka import AsyncContainer from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.utils.timezone import now_msk_naive +logger = logging.getLogger(__name__) -async def deactivate_expired_tests(container: AsyncContainer): + +async def deactivate_expired_tests(container: AsyncContainer) -> None: async with container() as request_container: test_dao = await request_container.get(TestDAO) - tests = await test_dao.get_all() + expired_tests = await test_dao.get_expired_active_tests(now_msk_naive()) - for test in tests: - if test.expires_at and test.expires_at < now_msk_naive() and test.is_active: - await test_dao.update(test.id, is_active=False) + for test in expired_tests: + await test_dao.update(test.id, is_active=False) + logger.info("Деактивирован истёкший тест: id=%d, title=%s", test.id, test.title) diff --git a/src/trudex/infrastructure/utils/bot_commands.py b/src/trudex/infrastructure/utils/bot_commands.py index 15b73ab..d017f64 100644 --- a/src/trudex/infrastructure/utils/bot_commands.py +++ b/src/trudex/infrastructure/utils/bot_commands.py @@ -1,6 +1,5 @@ from aiogram import Bot -from aiogram.types import (BotCommand, BotCommandScopeAllPrivateChats, - BotCommandScopeChat) +from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeChat from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.config import Config diff --git a/src/trudex/infrastructure/utils/broadcast.py b/src/trudex/infrastructure/utils/broadcast.py index 06612aa..a30c35f 100644 --- a/src/trudex/infrastructure/utils/broadcast.py +++ b/src/trudex/infrastructure/utils/broadcast.py @@ -1,4 +1,5 @@ import asyncio +import logging from dataclasses import dataclass from aiogram import Bot @@ -6,6 +7,8 @@ from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from trudex.infrastructure.database.dao.user import UserDAO +logger = logging.getLogger(__name__) + @dataclass class BroadcastStats: @@ -19,17 +22,20 @@ async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: U success = 0 failed = 0 + logger.info("Starting broadcast: message_id=%d, total_users=%d", message_id, len(users)) + for user in users: try: await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) success += 1 except TelegramForbiddenError: + logger.debug("Broadcast failed (forbidden): user_id=%d", user.id) failed += 1 - except TelegramBadRequest: - failed += 1 - except Exception: + except TelegramBadRequest as e: + logger.debug("Broadcast failed (bad request): user_id=%d, error=%s", user.id, e) failed += 1 await asyncio.sleep(0.1) + logger.info("Broadcast completed: success=%d, failed=%d, total=%d", success, failed, len(users)) return BroadcastStats(success=success, failed=failed, total=len(users)) diff --git a/src/trudex/infrastructure/utils/test_id_to_hash.py b/src/trudex/infrastructure/utils/test_id_to_hash.py index 2d9b035..d5322a0 100644 --- a/src/trudex/infrastructure/utils/test_id_to_hash.py +++ b/src/trudex/infrastructure/utils/test_id_to_hash.py @@ -1,8 +1,7 @@ -import hmac import hashlib +import hmac import string - ALPHABET = string.ascii_letters def _feistel_round(val: int, key: bytes, rounds: int) -> int: From 8d708f2cce6f08d445753a4a055c35dadea15f71 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 03:04:17 +0300 Subject: [PATCH 50/57] commit --- .../application/bot/creator_dialogs/users.py | 75 ++++++++++++++----- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index 294f551..6f19b84 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -64,20 +64,61 @@ async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDish @inject -async def get_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): +async def get_make_admin_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): user_id = dialog_manager.dialog_data.get("selected_user_id") if not user_id: - return {"user_info": "Пользователь не выбран"} + return {"confirm_text": "❌ Пользователь не выбран"} user = await user_dao.get_by_id(user_id) if not user: - return {"user_info": "Пользователь не найден"} + return {"confirm_text": "❌ Пользователь не найден"} - username_str = f"@{user.username}" if user.username else "—" - name_str = user.name or f"{user.first_name} {user.last_name or ''}".strip() - return { - "user_info": f"{name_str}\n{username_str}\nID: {user.id}" - } + username_str = f"@{user.username}" if user.username else "нет username" + name_str = user.name or user.first_name + group_str = f"группа {user.group}" if user.group else "без группы" + + confirm_text = ( + f"👑 Назначение администратора\n\n" + f"Вы собираетесь назначить администратором:\n\n" + f"
" + f"👤 {name_str}\n" + f"📱 {username_str}\n" + f"🎓 {group_str}\n" + f"🆔 {user.id}" + f"
\n\n" + f"⚠️ Администратор получит доступ к управлению тестами, пользователями и рассылкам." + ) + + return {"confirm_text": confirm_text} + + +@inject +async def get_remove_admin_confirm_data(dialog_manager: DialogManager, user_dao: FromDishka[UserDAO], **_kwargs): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"confirm_text": "❌ Пользователь не выбран"} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"confirm_text": "❌ Пользователь не найден"} + + username_str = f"@{user.username}" if user.username else "нет username" + name_str = user.name or user.first_name + group_str = f"группа {user.group}" if user.group else "без группы" + + confirm_text = ( + f"🚫 Снятие администратора\n\n" + f"Вы собираетесь снять с должности администратора:\n\n" + f"
" + f"👤 {name_str}\n" + f"📱 {username_str}\n" + f"🎓 {group_str}\n" + f"🆔 {user.id}" + f"
\n\n" + f"⚠️ Пользователь потеряет доступ к админ-панели." + ) + + return {"confirm_text": confirm_text} async def on_user_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): @@ -207,23 +248,21 @@ users_dialog = Dialog( getter=get_user_detail_data, ), Window( - Const("⚠️ Подтверждение\n\nВы уверены, что хотите назначить этого пользователя администратором?\n\n"), - Format("{user_info}"), + Format("{confirm_text}"), Row( - Button(Const("✅ Да"), id="confirm_yes", on_click=on_confirm_yes), - Button(Const("❌ Нет"), id="confirm_no", on_click=on_confirm_no), + Button(Const("✅ Подтвердить"), id="confirm_yes", on_click=on_confirm_yes), + Button(Const("◀️ Отмена"), id="confirm_no", on_click=on_confirm_no), ), state=CreatorUsersSG.make_admin_confirm, - getter=get_confirm_data, + getter=get_make_admin_confirm_data, ), Window( - Const("⚠️ Подтверждение\n\nВы уверены, что хотите снять этого пользователя с должности администратора?\n\n"), - Format("{user_info}"), + Format("{confirm_text}"), Row( - Button(Const("✅ Да"), id="confirm_yes", on_click=on_remove_admin_confirm_yes), - Button(Const("❌ Нет"), id="confirm_no", on_click=on_confirm_no), + Button(Const("✅ Подтвердить"), id="confirm_yes", on_click=on_remove_admin_confirm_yes), + Button(Const("◀️ Отмена"), id="confirm_no", on_click=on_confirm_no), ), state=CreatorUsersSG.remove_admin_confirm, - getter=get_confirm_data, + getter=get_remove_admin_confirm_data, ), ) From 12beb5a986eb8c047bec1f42733526dcd8af3a60 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 03:36:13 +0300 Subject: [PATCH 51/57] commit --- src/trudex/application/__main__.py | 37 +- .../application/bot/admin_dialogs/groups.py | 186 ------ .../bot/admin_dialogs/main_menu.py | 21 +- .../application/bot/admin_dialogs/states.py | 50 -- .../application/bot/admin_dialogs/users.py | 19 +- .../bot/creator_dialogs/broadcast.py | 73 --- .../bot/creator_dialogs/create_test.py | 574 ------------------ .../bot/creator_dialogs/main_menu.py | 21 +- .../application/bot/creator_dialogs/states.py | 50 -- .../bot/creator_dialogs/templates.py | 425 ------------- .../application/bot/creator_dialogs/tests.py | 561 ----------------- .../application/bot/creator_dialogs/users.py | 10 +- .../bot/shared_dialogs/__init__.py | 0 .../broadcast.py | 16 +- .../create_test.py | 83 ++- .../groups.py | 47 +- .../application/bot/shared_dialogs/states.py | 51 ++ .../templates.py | 28 +- .../tests.py | 77 +-- 19 files changed, 226 insertions(+), 2103 deletions(-) delete mode 100644 src/trudex/application/bot/admin_dialogs/groups.py delete mode 100644 src/trudex/application/bot/creator_dialogs/broadcast.py delete mode 100644 src/trudex/application/bot/creator_dialogs/create_test.py delete mode 100644 src/trudex/application/bot/creator_dialogs/templates.py delete mode 100644 src/trudex/application/bot/creator_dialogs/tests.py create mode 100644 src/trudex/application/bot/shared_dialogs/__init__.py rename src/trudex/application/bot/{admin_dialogs => shared_dialogs}/broadcast.py (85%) rename src/trudex/application/bot/{admin_dialogs => shared_dialogs}/create_test.py (90%) rename src/trudex/application/bot/{creator_dialogs => shared_dialogs}/groups.py (82%) create mode 100644 src/trudex/application/bot/shared_dialogs/states.py rename src/trudex/application/bot/{admin_dialogs => shared_dialogs}/templates.py (95%) rename src/trudex/application/bot/{admin_dialogs => shared_dialogs}/tests.py (91%) diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index 45d3fa9..ff8d583 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -9,23 +9,18 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from dishka import make_async_container from dishka.integrations.aiogram import setup_dishka -from trudex.application.bot.admin_dialogs.broadcast import broadcast_dialog as admin_broadcast_dialog -from trudex.application.bot.admin_dialogs.create_test import admin_create_test_dialog -from trudex.application.bot.admin_dialogs.groups import groups_dialog as admin_groups_dialog from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog -from trudex.application.bot.admin_dialogs.templates import templates_dialog as admin_templates_dialog -from trudex.application.bot.admin_dialogs.tests import tests_dialog as admin_tests_dialog -from trudex.application.bot.admin_dialogs.users import users_dialog as admin_users_dialog -from trudex.application.bot.creator_dialogs.broadcast import broadcast_dialog as creator_broadcast_dialog -from trudex.application.bot.creator_dialogs.create_test import create_test_dialog -from trudex.application.bot.creator_dialogs.groups import groups_dialog as creator_groups_dialog +from trudex.application.bot.admin_dialogs.users import admin_users_dialog from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog -from trudex.application.bot.creator_dialogs.templates import templates_dialog as creator_templates_dialog -from trudex.application.bot.creator_dialogs.tests import tests_dialog as creator_tests_dialog -from trudex.application.bot.creator_dialogs.users import users_dialog as creator_users_dialog +from trudex.application.bot.creator_dialogs.users import creator_users_dialog from trudex.application.bot.handlers import router from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware +from trudex.application.bot.shared_dialogs.broadcast import shared_broadcast_dialog +from trudex.application.bot.shared_dialogs.create_test import shared_create_test_dialog +from trudex.application.bot.shared_dialogs.groups import shared_groups_dialog +from trudex.application.bot.shared_dialogs.templates import shared_templates_dialog +from trudex.application.bot.shared_dialogs.tests import shared_tests_dialog from trudex.application.bot.user_dialogs.deeplink import deeplink_dialog from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog from trudex.application.bot.user_dialogs.registration import registration_dialog @@ -57,20 +52,18 @@ async def main() -> None: take_test_dialog, registration_dialog, deeplink_dialog, + # Shared dialogs + shared_tests_dialog, + shared_groups_dialog, + shared_broadcast_dialog, + shared_templates_dialog, + shared_create_test_dialog, + # Admin dialogs admin_menu_dialog, admin_users_dialog, - admin_tests_dialog, - admin_groups_dialog, - admin_broadcast_dialog, - admin_templates_dialog, - admin_create_test_dialog, + # Creator dialogs creator_menu_dialog, creator_users_dialog, - creator_tests_dialog, - creator_groups_dialog, - creator_broadcast_dialog, - creator_templates_dialog, - create_test_dialog, ) router.message.middleware(RejectNotAdminMiddleware()) diff --git a/src/trudex/application/bot/admin_dialogs/groups.py b/src/trudex/application/bot/admin_dialogs/groups.py deleted file mode 100644 index 8b80665..0000000 --- a/src/trudex/application/bot/admin_dialogs/groups.py +++ /dev/null @@ -1,186 +0,0 @@ -from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, StartMode, Window -from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select -from aiogram_dialog.widgets.text import Const, Format -from dishka import FromDishka -from dishka.integrations.aiogram_dialog import inject - -from trudex.application.bot.admin_dialogs.states import AdminGroupsSG, AdminMenuSG -from trudex.infrastructure.database.dao.group import GroupDAO - - -async def on_group_click(_callback: CallbackQuery, _widget, _manager: DialogManager, _item_id: str): - await _callback.answer("ℹ️ Для удаления используйте кнопку 'Удалить группу'") - - -@inject -async def get_groups_data(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): - groups = await group_dao.get_all() - - success_message = dialog_manager.dialog_data.pop("success_message", None) - - message_text = "👥 Управление группами\n\n" - if success_message: - message_text += f"{success_message}\n\n" - message_text += f"📊 Всего групп: {len(groups)}\n\nСписок групп:" - - return { - "groups": [(str(g.id), str(g.number)) for g in groups], - "groups_count": len(groups), - "message_text": message_text, - } - - -async def on_add_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminGroupsSG.add_group_input_number) - - -async def on_delete_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminGroupsSG.delete_groups_list) - - -async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) - - -@inject -async def on_group_number_input(message: Message, _widget: MessageInput, manager: DialogManager, group_dao: FromDishka[GroupDAO]): - if not message.text: - await message.answer("❌ Номер группы не может быть пустым") - return - - number_str = message.text.strip() - - if not number_str.isdigit(): - await message.answer("❌ Номер группы должен содержать только цифры") - return - - number = int(number_str) - - if number < 1000 or number > 9999: - await message.answer("❌ Номер группы должен быть четырехзначным (1000-9999)") - return - - existing = await group_dao.get_by_number(number) - if existing: - await message.answer(f"❌ Группа с номером {number} уже существует") - return - - try: - await group_dao.create(number=number) - manager.dialog_data["success_message"] = f"✅ Группа {number} создана" - except Exception as e: - await message.answer(f"❌ Ошибка создания группы: {e}") - return - - await manager.switch_to(AdminGroupsSG.groups_list) - - -async def on_cancel_add(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminGroupsSG.groups_list) - - -@inject -async def get_delete_groups_data(dialog_manager: DialogManager, group_dao: FromDishka[GroupDAO], **_kwargs): - groups = await group_dao.get_all() - - return { - "groups": [(str(g.id), f"{g.number}") for g in groups], - "groups_count": len(groups), - } - - -@inject -async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, group_dao: FromDishka[GroupDAO]): - group = await group_dao.get_by_id(int(item_id)) - if not group: - await _callback.answer("❌ Группа не найдена", show_alert=True) - return - - manager.dialog_data["delete_group_id"] = group.id - manager.dialog_data["delete_group_number"] = group.number - await manager.switch_to(AdminGroupsSG.delete_confirm) - - -async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs): - number = dialog_manager.dialog_data.get("delete_group_number", "") - - return { - "group_info": str(number) - } - - -@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) - - manager.dialog_data["success_message"] = "✅ Группа удалена" - await manager.switch_to(AdminGroupsSG.groups_list) - - -async def on_cancel_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminGroupsSG.delete_groups_list) - - -groups_dialog = Dialog( - Window( - Format("{message_text}"), - ScrollingGroup( - Select( - Format("{item[1]}"), - id="groups", - item_id_getter=lambda x: x[0], - items="groups", - on_click=on_group_click, - ), - id="groups_scroll", - width=2, - height=7, - ), - Column( - Button(Const("➕ Добавить группу"), id="add", on_click=on_add_group), - Button(Const("🗑 Удалить группу"), id="delete", on_click=on_delete_group), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu), - ), - state=AdminGroupsSG.groups_list, - getter=get_groups_data, - ), - Window( - Const("➕ Добавление группы\n\n🔢 Введите номер группы (четырехзначное число 1000-9999):"), - MessageInput(on_group_number_input), - Button(Const("◀️ Отмена"), id="cancel", on_click=on_cancel_add), - state=AdminGroupsSG.add_group_input_number, - ), - Window( - Format("🗑 Удаление группы\n\nВыберите группу для удаления:\n\n📊 Всего групп: {groups_count}"), - ScrollingGroup( - Select( - Format("{item[1]}"), - id="delete_groups", - item_id_getter=lambda x: x[0], - items="groups", - on_click=on_select_group_to_delete, - ), - id="delete_groups_scroll", - width=2, - height=7, - ), - Button(Const("◀️ Назад"), id="back", on_click=on_cancel_add), - state=AdminGroupsSG.delete_groups_list, - getter=get_delete_groups_data, - ), - Window( - Format("⚠️ Подтверждение удаления\n\nТочно хотите удалить группу?\n\n👥 {group_info}"), - Row( - Button(Const("✅ Да, удалить"), id="confirm", on_click=on_confirm_delete), - Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_delete), - ), - state=AdminGroupsSG.delete_confirm, - getter=get_delete_confirm_data, - ), -) diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index c59ad27..52f5ee5 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -1,30 +1,35 @@ from aiogram.types import CallbackQuery -from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.admin_dialogs.states import (AdminBroadcastSG, AdminGroupsSG, AdminMenuSG, - AdminTemplatesSG, AdminTestsSG, AdminUsersSG) +from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG +from trudex.application.bot.shared_dialogs.states import ( + SharedBroadcastSG, + SharedGroupsSG, + SharedTemplatesSG, + SharedTestsSG, +) async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(AdminTestsSG.tests_list, mode=StartMode.RESET_STACK) + await manager.start(SharedTestsSG.tests_list) async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(AdminUsersSG.users_list, mode=StartMode.RESET_STACK) + await manager.start(AdminUsersSG.users_list) async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(AdminGroupsSG.groups_list, mode=StartMode.RESET_STACK) + await manager.start(SharedGroupsSG.groups_list) async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(AdminBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) + await manager.start(SharedBroadcastSG.broadcast_input) async def on_templates_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(AdminTemplatesSG.main, mode=StartMode.RESET_STACK) + await manager.start(SharedTemplatesSG.main) admin_menu_dialog = Dialog( diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index c374aa7..fcc4680 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -5,57 +5,7 @@ class AdminMenuSG(StatesGroup): main = State() -class AdminTemplatesSG(StatesGroup): - main = State() - export_list = State() - spec = State() - import_file = State() - - class AdminUsersSG(StatesGroup): users_list = State() users_input = State() user_detail = State() - - -class AdminTestsSG(StatesGroup): - tests_list = State() - test_detail = State() - share_test = State() - edit_menu = State() - edit_password = State() - edit_attempts = State() - edit_group = State() - edit_expires = State() - statistics = State() - attempt_detail = State() - - -class AdminBroadcastSG(StatesGroup): - broadcast_input = State() - broadcast_confirm = State() - - -class AdminGroupsSG(StatesGroup): - groups_list = State() - add_group_input_number = State() - delete_groups_list = State() - delete_confirm = State() - - -class AdminCreateTestSG(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() - add_question = State() - input_question_text = State() - select_question_type = State() - input_correct_answer = State() - input_options = State() - mark_correct_options = State() - confirm_question = State() - test_created = State() diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index 55cba6b..c1b67ba 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -1,25 +1,26 @@ from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Column, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG +from trudex.application.bot.admin_dialogs.states import AdminUsersSG from trudex.infrastructure.database.dao.user import UserDAO @inject async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): users = await user_dao.get_all() + users_sorted = sorted(users, key=lambda u: u.created_at or u.id, reverse=True) return { "users": [ - (f"{u.name or u.first_name} (@{u.username or 'нет'})", u.id) - for u in users + (f"{'👑 ' if u.is_admin else ''}{u.name or u.first_name} (@{u.username or 'нет'})", u.id) + for u in users_sorted ], - "count": len(users), + "count": len(users_sorted), } @@ -34,7 +35,6 @@ async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDish return {"user_info": "Пользователь не найден"} username_str = f"@{user.username}" if user.username else "—" - last_name_str = user.last_name or "—" name_str = user.name or "—" group_str = str(user.group) if user.group else "—" admin_status = "✅ Да" if user.is_admin else "❌ Нет" @@ -42,8 +42,7 @@ async def get_user_detail_data(dialog_manager: DialogManager, user_dao: FromDish user_info = ( f"👤 Информация о пользователе\n\n" f"ID: {user.id}\n" - f"Имя: {user.first_name}\n" - f"Фамилия: {last_name_str}\n" + f"Ник: {user.first_name}\n" f"Имя и фамилия: {name_str}\n" f"Username: {username_str}\n" f"Группа: {group_str}\n" @@ -83,10 +82,10 @@ async def on_user_input(message: Message, _widget: MessageInput, manager: Dialog async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + await manager.done() -users_dialog = Dialog( +admin_users_dialog = Dialog( Window( Format("👥 Пользователи\n\nВсего: {count}"), ScrollingGroup( diff --git a/src/trudex/application/bot/creator_dialogs/broadcast.py b/src/trudex/application/bot/creator_dialogs/broadcast.py deleted file mode 100644 index ba534fd..0000000 --- a/src/trudex/application/bot/creator_dialogs/broadcast.py +++ /dev/null @@ -1,73 +0,0 @@ -from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, StartMode, Window -from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Row -from aiogram_dialog.widgets.text import Const -from dishka import FromDishka -from dishka.integrations.aiogram_dialog import inject - -from trudex.application.bot.creator_dialogs.states import CreatorBroadcastSG, CreatorMenuSG -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.utils.broadcast import broadcast_message - - -async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): - manager.dialog_data["broadcast_message_id"] = message.message_id - manager.dialog_data["broadcast_chat_id"] = message.chat.id - await manager.switch_to(CreatorBroadcastSG.broadcast_confirm) - - -@inject -async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): - message_id = manager.dialog_data.get("broadcast_message_id") - chat_id = manager.dialog_data.get("broadcast_chat_id") - - if not message_id or not chat_id or not _callback.message: - await _callback.answer("Ошибка: сообщение не найдено") - return - - await _callback.message.answer("⏳ Рассылка началась...") - - bot = _callback.bot - if not bot: - await _callback.answer("Ошибка: бот не найден") - return - - stats = await broadcast_message(bot, message_id, chat_id, user_dao) - - stats_text = ( - f"✅ Рассылка завершена\n\n" - f"Всего пользователей: {stats.total}\n" - f"Успешно отправлено: {stats.success}\n" - f"Не удалось отправить: {stats.failed}" - ) - - await _callback.message.answer(stats_text) - await manager.done() - - -async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await _callback.answer("Рассылка отменена") - await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) - - -async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) - - -broadcast_dialog = Dialog( - Window( - Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), - MessageInput(on_broadcast_input), - Button(Const("◀️ Отмена"), id="back", on_click=on_back_to_main), - state=CreatorBroadcastSG.broadcast_input, - ), - Window( - Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), - Row( - Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), - Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), - ), - state=CreatorBroadcastSG.broadcast_confirm, - ), -) diff --git a/src/trudex/application/bot/creator_dialogs/create_test.py b/src/trudex/application/bot/creator_dialogs/create_test.py deleted file mode 100644 index 22ce7cb..0000000 --- a/src/trudex/application/bot/creator_dialogs/create_test.py +++ /dev/null @@ -1,574 +0,0 @@ -from datetime import date, datetime, time - -from aiogram.types import CallbackQuery, ContentType, Message -from aiogram_dialog import Dialog, DialogManager, StartMode, Window -from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Calendar, Cancel, Column, Row, ScrollingGroup, Select -from aiogram_dialog.widgets.text import Const, Format -from dishka import FromDishka -from dishka.integrations.aiogram_dialog import inject - -from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorTestsSG -from trudex.infrastructure.database.dao.group import GroupDAO -from trudex.infrastructure.database.dao.option import OptionDAO -from trudex.infrastructure.database.dao.question import QuestionDAO -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.utils.timezone import to_msk - - -async def on_title_input(message: Message, _widget: MessageInput, manager: DialogManager): - if not message.text: - await message.answer("❌ Название не может быть пустым") - return - - title = message.text.strip() - if not title: - await message.answer("❌ Название не может быть пустым") - return - - if len(title) > 255: - await message.answer("❌ Название слишком длинное (максимум 255 символов)") - return - - manager.dialog_data["title"] = title - await manager.switch_to(CreateTestSG.input_description) - - -async def on_description_input(message: Message, _widget: MessageInput, manager: DialogManager): - if not message.text: - await message.answer("❌ Описание не может быть пустым") - return - - description = message.text.strip() - if not description: - await message.answer("❌ Описание не может быть пустым") - return - - if len(description) > 2000: - await message.answer("❌ Описание слишком длинное (максимум 2000 символов)") - return - - manager.dialog_data["description"] = description - await manager.switch_to(CreateTestSG.input_password) - - -@inject -async def on_password_input(message: Message, _widget: MessageInput, manager: DialogManager, _group_dao: FromDishka[GroupDAO]): - if not message.text: - await message.answer("❌ Пароль не может быть пустым") - return - - password = message.text.strip() - if not password: - await message.answer("❌ Пароль не может быть пустым") - return - - if len(password) > 255: - await message.answer("❌ Пароль слишком длинный (максимум 255 символов)") - return - - manager.dialog_data["password"] = password - 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 - 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 - - 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): - manager.dialog_data["expires_at"] = datetime.combine(selected_date, time.min) - await manager.switch_to(CreateTestSG.input_for_group) - - -async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager): - manager.dialog_data["expires_at"] = None - await manager.switch_to(CreateTestSG.input_for_group) - - -@inject -async def get_groups_for_test(group_dao: FromDishka[GroupDAO], **_kwargs): - groups = await group_dao.get_all() - - return { - "groups": [(str(g.number), str(g.number)) for g in groups], - } - - -async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): - manager.dialog_data["for_group"] = int(item_id) - await manager.switch_to(CreateTestSG.confirm_test_info) - - -async def on_skip_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): - manager.dialog_data["for_group"] = None - await manager.switch_to(CreateTestSG.confirm_test_info) - - -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_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 "Для всех" - - return { - "info": ( - f"📝 Информация о тесте\n\n" - f"Название: {title}\n" - f"Описание: {description}\n" - f"Пароль: {password_str}\n" - f"Попыток: {attempts_str}\n" - f"Истекает: {expires_str}\n" - f"Для группы: {group_str}" - ) - } - - -@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") - - test = await test_dao.create( - title=title, - description=description, - password=password, - attempts=attempts, - expires_at=expires_at, - for_group=for_group, - ) - - manager.dialog_data["test_id"] = test.id - manager.dialog_data["questions"] = [] - await manager.switch_to(CreateTestSG.add_question) - - -async def on_add_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): - manager.dialog_data["current_question"] = {} - await manager.switch_to(CreateTestSG.input_question_text) - - -async def on_question_input(message: Message, _widget: MessageInput, manager: DialogManager): - current_question = manager.dialog_data.get("current_question", {}) - - if message.content_type == ContentType.PHOTO: - photo = message.photo[-1] if message.photo else None - if photo: - text = (message.caption or "").strip() - if not text: - await message.answer("❌ Изображение должно содержать подпись с текстом вопроса") - return - if len(text) > 2000: - await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)") - return - current_question["tg_file_id"] = photo.file_id - current_question["text"] = text - elif message.content_type == ContentType.TEXT and message.text: - text = message.text.strip() - if not text: - await message.answer("❌ Текст вопроса не может быть пустым") - return - if len(text) > 2000: - await message.answer("❌ Текст вопроса слишком длинный (максимум 2000 символов)") - return - current_question["text"] = text - current_question["tg_file_id"] = None - else: - await message.answer("❌ Отправьте текст или фото с подписью") - return - - manager.dialog_data["current_question"] = current_question - await manager.switch_to(CreateTestSG.select_question_type) - - -async def get_question_type_data(**_kwargs): - return { - "question_types": [ - ("single", "📌 Один правильный ответ"), - ("multiple", "� Несколько правильных ответов"), - ("input", "✏️ Ввод текста"), - ] - } - - -async def on_question_type_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): - current_question = manager.dialog_data.get("current_question", {}) - current_question["question_type"] = item_id - manager.dialog_data["current_question"] = current_question - - if item_id == "input": - await manager.switch_to(CreateTestSG.input_correct_answer) - else: - manager.dialog_data["current_options"] = [] - await manager.switch_to(CreateTestSG.input_options) - - -async def on_correct_answer_input(message: Message, _widget: MessageInput, manager: DialogManager): - if not message.text: - await message.answer("❌ Правильный ответ не может быть пустым") - return - - answer = message.text.strip() - if not answer: - await message.answer("❌ Правильный ответ не может быть пустым") - return - - if len(answer) > 255: - await message.answer("❌ Ответ слишком длинный (максимум 255 символов)") - return - - current_question = manager.dialog_data.get("current_question", {}) - current_question["correct_answer"] = answer - manager.dialog_data["current_question"] = current_question - await manager.switch_to(CreateTestSG.confirm_question) - - -async def on_option_input(message: Message, _widget: MessageInput, manager: DialogManager): - if not message.text: - await message.answer("❌ Вариант ответа не может быть пустым") - return - - option_text = message.text.strip() - if not option_text: - await message.answer("❌ Вариант ответа не может быть пустым") - return - - if len(option_text) > 255: - await message.answer("❌ Вариант ответа слишком длинный (максимум 255 символов)") - return - - current_options = manager.dialog_data.get("current_options", []) - - if len(current_options) >= 10: - await message.answer("❌ Максимум 10 вариантов ответа") - return - - current_options.append({"text": option_text, "is_correct": False}) - manager.dialog_data["current_options"] = current_options - - await message.answer(f"✅ Вариант {len(current_options)} добавлен") - - -async def on_finish_options(_callback: CallbackQuery, _button: Button, manager: DialogManager): - current_options = manager.dialog_data.get("current_options", []) - if len(current_options) < 2: - await _callback.answer("❌ Добавьте минимум 2 варианта ответа", show_alert=True) - return - - await manager.switch_to(CreateTestSG.mark_correct_options) - - -async def get_options_data(dialog_manager: DialogManager, **_kwargs): - current_options = dialog_manager.dialog_data.get("current_options", []) - formatted_options = [] - for i, opt in enumerate(current_options): - marker = "✅" if opt["is_correct"] else "❌" - formatted_options.append((str(i), f"{marker} {opt['text']}")) - return { - "options": formatted_options, - "options_count": len(current_options), - } - - -async def on_option_toggle(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): - current_options = manager.dialog_data.get("current_options", []) - current_question = manager.dialog_data.get("current_question", {}) - question_type = current_question.get("question_type", "single") - - option_idx = int(item_id) - - if question_type == "single": - for opt in current_options: - opt["is_correct"] = False - current_options[option_idx]["is_correct"] = True - else: - current_options[option_idx]["is_correct"] = not current_options[option_idx]["is_correct"] - - manager.dialog_data["current_options"] = current_options - await _callback.answer() - - -async def on_confirm_correct(_callback: CallbackQuery, _button: Button, manager: DialogManager): - current_options = manager.dialog_data.get("current_options", []) - - if not any(opt["is_correct"] for opt in current_options): - await _callback.answer("❌ Отметьте хотя бы один правильный ответ", show_alert=True) - return - - await manager.switch_to(CreateTestSG.confirm_question) - - -async def get_question_preview(dialog_manager: DialogManager, **_kwargs): - current_question = dialog_manager.dialog_data.get("current_question", {}) - current_options = dialog_manager.dialog_data.get("current_options", []) - - text = current_question.get("text", "") - question_type = current_question.get("question_type", "single") - has_image = current_question.get("tg_file_id") is not None - - type_names = { - "single": "📌 Один правильный ответ", - "multiple": "📋 Несколько правильных ответов", - "input": "✏️ Ввод текста", - } - - preview = f"📝 Предпросмотр вопроса\n\n" - preview += f"Текст: {text}\n" - preview += f"Тип: {type_names[question_type]}\n" - preview += f"Изображение: {'✅ Да' if has_image else '❌ Нет'}\n\n" - - if question_type == "input": - correct_answer = current_question.get("correct_answer", "") - preview += f"Правильный ответ: {correct_answer}" - else: - preview += "Варианты ответов:\n" - for i, opt in enumerate(current_options, 1): - marker = "✅" if opt["is_correct"] else "❌" - preview += f"{i}. {marker} {opt['text']}\n" - - return {"preview": preview} - - -@inject -async def on_save_question( - _callback: CallbackQuery, - _button: Button, - manager: DialogManager, - question_dao: FromDishka[QuestionDAO], - option_dao: FromDishka[OptionDAO], - 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", []) - - questions_count = await test_repo.count_questions_in_test(test_id) - - question = await question_dao.create( - test_id=test_id, - text=current_question.get("text", ""), - position=questions_count, - question_type=current_question.get("question_type", "single"), - tg_file_id=current_question.get("tg_file_id"), - ) - - if current_question.get("question_type") == "input": - await option_dao.create( - question_id=question.id, - text=current_question.get("correct_answer", ""), - is_correct=True, - ) - else: - for opt in current_options: - await option_dao.create( - question_id=question.id, - text=opt["text"], - is_correct=opt["is_correct"], - ) - - questions = manager.dialog_data.get("questions", []) - questions.append(question.id) - manager.dialog_data["questions"] = questions - - manager.dialog_data.pop("current_question", None) - manager.dialog_data.pop("current_options", None) - - await _callback.answer("✅ Вопрос добавлен") - await manager.switch_to(CreateTestSG.add_question) - - -async def on_cancel_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): - manager.dialog_data.pop("current_question", None) - manager.dialog_data.pop("current_options", None) - await manager.switch_to(CreateTestSG.add_question) - - -async def get_questions_count(dialog_manager: DialogManager, **_kwargs): - questions = dialog_manager.dialog_data.get("questions", []) - return {"questions_count": len(questions)} - - -async def on_finish_test(_callback: CallbackQuery, _button: Button, manager: DialogManager): - questions = manager.dialog_data.get("questions", []) - - if len(questions) == 0: - await _callback.answer("❌ Добавьте хотя бы один вопрос", show_alert=True) - return - - await _callback.answer("✅ Тест создан") - await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK) - - -async def on_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK) - - -create_test_dialog = Dialog( - Window( - Const("📝 Создание теста\n\n💬 Введите название теста:\n(максимум 255 символов)"), - MessageInput(on_title_input), - Cancel(Const("◀️ Отмена")), - state=CreateTestSG.input_title, - ), - Window( - Const("📝 Создание теста\n\n📄 Введите описание теста:\n(максимум 2000 символов)"), - MessageInput(on_description_input), - state=CreateTestSG.input_description, - ), - Window( - Const("🔒 Пароль\n\n🔑 Введите пароль для доступа к тесту или пропустите этот шаг:\n(максимум 255 символов)"), - MessageInput(on_password_input), - 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), - Button(Const("⏭️ Без срока"), id="skip_expires", on_click=on_skip_expires), - state=CreateTestSG.input_expires_at, - ), - Window( - Const("👥 Группа\n\n🎓 Выберите группу или пропустите для всех:"), - ScrollingGroup( - Select( - Format("{item[1]}"), - id="groups", - item_id_getter=lambda x: x[0], - items="groups", - on_click=on_group_selected, - ), - id="groups_scroll", - width=2, - height=7, - ), - Button(Const("⏭️ Для всех"), id="skip_group", on_click=on_skip_group), - state=CreateTestSG.input_for_group, - getter=get_groups_for_test, - ), - Window( - Format("{info}\n\n✅ Подтвердите создание теста:"), - Row( - Button(Const("✅ Создать"), id="confirm", on_click=on_confirm_test), - Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel), - ), - state=CreateTestSG.confirm_test_info, - getter=get_test_info, - ), - Window( - Format("➕ Добавление вопросов\n\n📊 Вопросов добавлено: {questions_count}\n\n💡 Добавьте вопросы к тесту:"), - Column( - Button(Const("➕ Добавить вопрос"), id="add_question", on_click=on_add_question), - Button(Const("✅ Завершить создание"), id="finish", on_click=on_finish_test), - ), - state=CreateTestSG.add_question, - getter=get_questions_count, - ), - Window( - Const("❓ Текст вопроса\n\n📝 Отправьте текст вопроса или 📷 фото с подписью:\n(максимум 2000 символов)"), - MessageInput(on_question_input, content_types=[ContentType.TEXT, ContentType.PHOTO]), - Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=CreateTestSG.input_question_text, - ), - Window( - Const("📋 Тип вопроса\n\n🎯 Выберите тип вопроса:"), - Column(Select( - Format("{item[1]}"), - id="question_type", - item_id_getter=lambda x: x[0], - items="question_types", - on_click=on_question_type_selected, - )), - Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=CreateTestSG.select_question_type, - getter=get_question_type_data, - ), - Window( - Const("✏️ Правильный ответ\n\n💬 Введите правильный ответ (регистр и пробелы игнорируются):\n(максимум 255 символов)"), - MessageInput(on_correct_answer_input), - Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=CreateTestSG.input_correct_answer, - ), - Window( - Format("📝 Варианты ответов\n\n📊 Добавлено вариантов: {options_count}/10\n\n💬 Введите вариант ответа:\n(максимум 255 символов)"), - MessageInput(on_option_input), - Button(Const("✅ Завершить добавление вариантов"), id="finish_options", on_click=on_finish_options), - Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=CreateTestSG.input_options, - getter=get_options_data, - ), - Window( - Const("✅ Правильные ответы\n\nОтметьте правильные варианты ответов:"), - Column(Select( - Format("{item[1]}"), - id="options", - item_id_getter=lambda x: x[0], - items="options", - on_click=on_option_toggle, - )), - Button(Const("✅ Подтвердить выбор"), id="confirm", on_click=on_confirm_correct), - Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=CreateTestSG.mark_correct_options, - getter=get_options_data, - ), - Window( - Format("{preview}\n\n💾 Сохранить вопрос?"), - Row( - Button(Const("✅ Сохранить"), id="save", on_click=on_save_question), - Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_question), - ), - state=CreateTestSG.confirm_question, - getter=get_question_preview, - ), -) diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index b77a873..d11cdae 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -1,30 +1,35 @@ from aiogram.types import CallbackQuery -from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.creator_dialogs.states import (CreatorBroadcastSG, CreatorGroupsSG, CreatorMenuSG, - CreatorTemplatesSG, CreatorTestsSG, CreatorUsersSG) +from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG +from trudex.application.bot.shared_dialogs.states import ( + SharedBroadcastSG, + SharedGroupsSG, + SharedTemplatesSG, + SharedTestsSG, +) async def on_tests_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK) + await manager.start(SharedTestsSG.tests_list) async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(CreatorUsersSG.users_list, mode=StartMode.RESET_STACK) + await manager.start(CreatorUsersSG.users_list) async def on_groups_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(CreatorGroupsSG.groups_list, mode=StartMode.RESET_STACK) + await manager.start(SharedGroupsSG.groups_list) async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(CreatorBroadcastSG.broadcast_input, mode=StartMode.RESET_STACK) + await manager.start(SharedBroadcastSG.broadcast_input) async def on_templates_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(CreatorTemplatesSG.main, mode=StartMode.RESET_STACK) + await manager.start(SharedTemplatesSG.main) creator_menu_dialog = Dialog( diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 6b21c34..749c2f1 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -5,59 +5,9 @@ class CreatorMenuSG(StatesGroup): main = State() -class CreatorTemplatesSG(StatesGroup): - main = State() - export_list = State() - spec = State() - import_file = State() - - class CreatorUsersSG(StatesGroup): users_list = State() users_input = State() user_detail = State() make_admin_confirm = State() remove_admin_confirm = State() - - -class CreatorTestsSG(StatesGroup): - tests_list = State() - test_detail = State() - share_test = State() - edit_menu = State() - edit_password = State() - edit_attempts = State() - edit_group = State() - edit_expires = State() - statistics = State() - attempt_detail = State() - - -class CreatorBroadcastSG(StatesGroup): - broadcast_input = State() - broadcast_confirm = State() - - -class CreatorGroupsSG(StatesGroup): - groups_list = State() - add_group_input_number = State() - delete_groups_list = State() - delete_confirm = State() - - -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() - add_question = State() - input_question_text = State() - select_question_type = State() - input_correct_answer = State() - input_options = State() - mark_correct_options = State() - confirm_question = State() - test_created = State() diff --git a/src/trudex/application/bot/creator_dialogs/templates.py b/src/trudex/application/bot/creator_dialogs/templates.py deleted file mode 100644 index 9a12de9..0000000 --- a/src/trudex/application/bot/creator_dialogs/templates.py +++ /dev/null @@ -1,425 +0,0 @@ -import json - -from aiogram import Bot -from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message -from aiogram_dialog import Dialog, DialogManager, StartMode, Window -from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Row, ScrollingGroup, Select -from aiogram_dialog.widgets.text import Const, Format -from dishka import FromDishka -from dishka.integrations.aiogram_dialog import inject - -from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorTemplatesSG, CreatorTestsSG -from trudex.domain.schemas import QuestionType -from trudex.domain.test_parser import ParsedTest, TestParser -from trudex.infrastructure.database.dao.option import OptionDAO -from trudex.infrastructure.database.dao.question import QuestionDAO -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.repo.test import TestRepository - -TEMPLATES_INFO = ( - "📦 Шаблоны тестов\n\n" - "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" - "🔹 Экспорт — сохраните тест как файл для резервной копии или передачи\n" - "🔹 Импорт — загрузите тест из файла\n" - "🔹 Спецификация — описание формата JSON для создания тестов вручную" -) - -SPEC_INFO = """📋 Спецификация формата JSON - -Структура файла: -{ - "title": "Название теста", - "description": "Описание теста", - "password": null, - "attempts": null, - "expires_at": null, - "for_group": null, - "questions": [...] -} - -Поля теста: -• title — название (обязательно, до 255 символов) -• description — описание (до 2000 символов) -• password — пароль для доступа или null -• attempts — лимит попыток (1-100) или null -• expires_at — срок действия в ISO формате или null -• for_group — номер группы или null для всех - -Типы вопросов: -• single — один правильный ответ -• multiple — несколько правильных ответов -• input — ввод текста (регистр и пробелы игнорируются) - -Формат вопроса (single/multiple): -{ - "text": "Текст вопроса", - "question_type": "single", - "options": [ - {"text": "Вариант 1", "is_correct": true}, - {"text": "Вариант 2", "is_correct": false} - ] -} - -Формат вопроса (input): -{ - "text": "Текст вопроса", - "question_type": "input", - "correct_answer": "правильный ответ" -} - -⚠️ Важно: -• Для single — ровно один is_correct: true -• Для multiple — один или более is_correct: true -• Минимум 2 варианта ответа для single/multiple""" - -TEMPLATE_SINGLE = { - "title": "Пример теста с одиночным выбором", - "description": "Демонстрация формата single вопросов", - "password": None, - "attempts": None, - "expires_at": None, - "for_group": None, - "questions": [ - { - "text": "Какой язык программирования используется для разработки Telegram ботов?", - "question_type": "single", - "options": [ - {"text": "Python", "is_correct": True}, - {"text": "HTML", "is_correct": False}, - {"text": "CSS", "is_correct": False}, - ], - }, - ], -} - -TEMPLATE_MULTIPLE = { - "title": "Пример теста с множественным выбором", - "description": "Демонстрация формата multiple вопросов", - "password": None, - "attempts": None, - "expires_at": None, - "for_group": None, - "questions": [ - { - "text": "Выберите языки программирования:", - "question_type": "multiple", - "options": [ - {"text": "Python", "is_correct": True}, - {"text": "JavaScript", "is_correct": True}, - {"text": "HTML", "is_correct": False}, - {"text": "CSS", "is_correct": False}, - ], - }, - ], -} - -TEMPLATE_INPUT = { - "title": "Пример теста с вводом текста", - "description": "Демонстрация формата input вопросов", - "password": None, - "attempts": None, - "expires_at": None, - "for_group": None, - "questions": [ - { - "text": "Как называется библиотека для создания Telegram ботов на Python?", - "question_type": "input", - "correct_answer": "aiogram", - }, - ], -} - -TEMPLATE_FULL = { - "title": "Полный пример теста", - "description": "Тест со всеми типами вопросов и настройками", - "password": "secret123", - "attempts": 3, - "expires_at": "2026-12-31T23:59:59", - "for_group": 1234, - "questions": [ - { - "text": "Выберите правильный ответ:", - "question_type": "single", - "options": [ - {"text": "Вариант A", "is_correct": False}, - {"text": "Вариант B", "is_correct": True}, - {"text": "Вариант C", "is_correct": False}, - ], - }, - { - "text": "Выберите все правильные ответы:", - "question_type": "multiple", - "options": [ - {"text": "Ответ 1", "is_correct": True}, - {"text": "Ответ 2", "is_correct": True}, - {"text": "Ответ 3", "is_correct": False}, - ], - }, - { - "text": "Введите ответ:", - "question_type": "input", - "correct_answer": "ответ", - }, - ], -} - - -async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(CreatorTemplatesSG.export_list) - - -async def on_import_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(CreatorTemplatesSG.import_file) - - -async def on_spec_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(CreatorTemplatesSG.spec) - - -async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) - - -async def on_back_to_templates(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(CreatorTemplatesSG.main) - - -@inject -async def get_tests_for_export(test_dao: FromDishka[TestDAO], **_kwargs): - tests = await test_dao.get_all() - return { - "tests": [(f"📝 {t.title}", t.id) for t in tests], - "count": len(tests), - } - - -@inject -async def on_test_selected_for_export( - _callback: CallbackQuery, - _widget: Select, # type: ignore[type-arg] - _manager: DialogManager, - item_id: str, - test_repo: FromDishka[TestRepository], -) -> None: - test_id = int(item_id) - test, questions_with_options = await test_repo.get_full_test(test_id) - - if not test: - await _callback.answer("❌ Тест не найден") - return - - export_data: dict = { - "title": test.title, - "description": test.description, - "password": test.password, - "attempts": test.attempts, - "expires_at": test.expires_at.isoformat() if test.expires_at else None, - "for_group": test.for_group, - "questions": [], - } - - questions_list: list = export_data["questions"] - - for question, options in questions_with_options: - question_data: dict = { - "text": question.text, - "question_type": question.question_type, - } - - if question.question_type == QuestionType.INPUT: - correct_options = [o for o in options if o.is_correct] - if correct_options: - question_data["correct_answer"] = correct_options[0].text - else: - question_data["options"] = [ - {"text": o.text, "is_correct": o.is_correct} - for o in options - ] - - questions_list.append(question_data) - - json_str = json.dumps(export_data, ensure_ascii=False, indent=2) - - safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in test.title)[:50] - filename = f"{safe_title}.json" - - assert _callback.message is not None - await _callback.message.answer_document( - document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), - caption=f"📤 Экспорт теста: {test.title}", - ) - - -async def send_template(callback: CallbackQuery, template: dict, name: str) -> None: - json_str = json.dumps(template, ensure_ascii=False, indent=2) - filename = f"template_{name}.json" - - assert callback.message is not None - await callback.message.answer_document( - document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), - caption=f"📄 Шаблон: {template['title']}", - ) - - -async def on_template_single(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await send_template(_callback, TEMPLATE_SINGLE, "single") - - -async def on_template_multiple(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await send_template(_callback, TEMPLATE_MULTIPLE, "multiple") - - -async def on_template_input(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await send_template(_callback, TEMPLATE_INPUT, "input") - - -async def on_template_full(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await send_template(_callback, TEMPLATE_FULL, "full") - - -async def create_test_from_parsed( - parsed: ParsedTest, - test_dao: TestDAO, - question_dao: QuestionDAO, - option_dao: OptionDAO, -) -> int: - test = await test_dao.create( - title=parsed.title, - description=parsed.description, - password=parsed.password, - attempts=parsed.attempts, - expires_at=parsed.expires_at, - for_group=parsed.for_group, - is_active=False, - ) - - for position, q in enumerate(parsed.questions): - question = await question_dao.create( - test_id=test.id, - text=q.text, - position=position, - question_type=q.question_type, - ) - - for opt in q.options: - await option_dao.create( - question_id=question.id, - text=opt.text, - is_correct=opt.is_correct, - ) - - return test.id - - -@inject -async def on_import_file( - message: Message, - _widget: MessageInput, - manager: DialogManager, - bot_inst: FromDishka[Bot], - test_dao: FromDishka[TestDAO], - question_dao: FromDishka[QuestionDAO], - option_dao: FromDishka[OptionDAO], -) -> None: - if not message.document: - await message.answer("❌ Отправьте JSON файл") - return - - if message.document.file_size and message.document.file_size > 1024 * 1024: - await message.answer("❌ Файл слишком большой (максимум 1 МБ)") - return - - file = await bot_inst.get_file(message.document.file_id) - if not file.file_path: - await message.answer("❌ Не удалось загрузить файл") - return - - file_bytes = await bot_inst.download_file(file.file_path) - if not file_bytes: - await message.answer("❌ Не удалось загрузить файл") - return - - try: - json_str = file_bytes.read().decode("utf-8") - except UnicodeDecodeError: - await message.answer("❌ Файл должен быть в кодировке UTF-8") - return - - parser = TestParser() - result = parser.parse(json_str) - - if isinstance(result, list): - if not result: - await message.answer("❌ Неизвестная ошибка валидации") - return - error_lines = ["❌ Ошибки валидации:\n"] - for err in result[:10]: - path_str = f" ({err.path})" if err.path else "" - error_lines.append(f"• {err.message}{path_str}") - if len(result) > 10: - error_lines.append(f"\n... и ещё {len(result) - 10} ошибок") - await message.answer("\n".join(error_lines)) - return - - await create_test_from_parsed(result, test_dao, question_dao, option_dao) - - await message.answer( - f"✅ Тест импортирован!\n\n" - f"📝 Название: {result.title}\n" - f"❓ Вопросов: {len(result.questions)}\n\n" - f"Тест создан в деактивированном состоянии." - ) - - await manager.start(CreatorTestsSG.tests_list, mode=StartMode.RESET_STACK) - - -templates_dialog = Dialog( - Window( - Const(TEMPLATES_INFO), - Row( - Button(Const("📤 Экспорт"), id="export", on_click=on_export_clicked), - Button(Const("📥 Импорт"), id="import", on_click=on_import_clicked), - ), - Button(Const("📋 Спецификация"), id="spec", on_click=on_spec_clicked), - Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked), - state=CreatorTemplatesSG.main, - ), - Window( - Format("📤 Экспорт теста\n\nВыберите тест для экспорта:\n\nВсего: {count}"), - ScrollingGroup( - Select( - Format("{item[0]}"), - id="test_select", - item_id_getter=lambda x: x[1], - items="tests", - on_click=on_test_selected_for_export, # type: ignore[arg-type] - ), - id="tests_scroll", - width=1, - height=7, - ), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), - state=CreatorTemplatesSG.export_list, - getter=get_tests_for_export, - ), - Window( - Const(SPEC_INFO), - Row( - Button(Const("📌 Single"), id="tpl_single", on_click=on_template_single), - Button(Const("📋 Multiple"), id="tpl_multiple", on_click=on_template_multiple), - ), - Row( - Button(Const("✏️ Input"), id="tpl_input", on_click=on_template_input), - Button(Const("📦 Полный"), id="tpl_full", on_click=on_template_full), - ), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), - state=CreatorTemplatesSG.spec, - ), - Window( - Const("📥 Импорт теста\n\nОтправьте JSON файл с тестом.\n\nФормат файла описан в разделе «Спецификация»"), - MessageInput(on_import_file, content_types=[ContentType.DOCUMENT]), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), - state=CreatorTemplatesSG.import_file, - ), -) diff --git a/src/trudex/application/bot/creator_dialogs/tests.py b/src/trudex/application/bot/creator_dialogs/tests.py deleted file mode 100644 index 6cd9ba4..0000000 --- a/src/trudex/application/bot/creator_dialogs/tests.py +++ /dev/null @@ -1,561 +0,0 @@ -import asyncio -import functools -from datetime import date, datetime, time - -from aiogram import Bot -from aiogram.enums import ContentType -from aiogram.types import BufferedInputFile, CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, StartMode, Window -from aiogram_dialog.api.entities import MediaAttachment -from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Calendar, Column, Row, ScrollingGroup, Select -from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format -from dishka import FromDishka -from dishka.integrations.aiogram_dialog import inject - -from trudex.application.bot.creator_dialogs.states import CreateTestSG, CreatorMenuSG, CreatorTestsSG -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.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.utils.config import Config -from trudex.infrastructure.utils.qr_generator import generate_qr_bytes -from trudex.infrastructure.utils.test_id_to_hash import encode_id -from trudex.infrastructure.utils.timezone import to_msk - - -@inject -async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs): - tests = await test_dao.get_all() - - return { - "tests": [ - (f"{'🟢' if t.is_active else '🔴'} {t.title}", t.id) - for t in tests - ], - "count": len(tests), - } - - -async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): - manager.dialog_data["selected_test_id"] = int(item_id) - await manager.switch_to(CreatorTestsSG.test_detail) - - -@inject -async def get_test_detail(test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], dialog_manager: DialogManager, **_kwargs): - test_id = dialog_manager.dialog_data.get("selected_test_id") - - if not test_id: - return { - "test_info": "Тест не найден", - "is_active": False, - "button_text": "◀️ Назад", - "results_button_text": "👁 Показать результаты", - } - - test = await test_dao.get_by_id(test_id) - questions_count = await test_repo.count_questions_in_test(test_id) - - if not test: - return { - "test_info": "Тест не найден", - "is_active": False, - "button_text": "◀️ Назад", - "results_button_text": "👁 Показать результаты", - } - - 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 = 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 "🔒 Результаты скрыты" - - test_info = ( - f"📝 Информация о тесте\n\n" - f"Название:\n
{test.title}
\n" - f"Описание:\n
{test.description or '—'}
\n\n" - f"Статус: {status}\n" - f"Вопросов: {questions_count}\n" - f"Пароль: {password_str}\n" - f"Попытки: {attempts_str}\n" - f"Срок: {expires_str}\n" - f"Группа: {group_str}\n" - f"Видимость: {results_str}\n\n" - f"Создан: {to_msk(test.created_at).strftime('%d.%m.%Y %H:%M') if test.created_at else '—'}" - ) - - button_text = "🔴 Деактивировать" if test.is_active else "🟢 Активировать" - results_button_text = "🔒 Скрыть результаты" if test.are_results_viewable else "👁 Показать результаты" - - return { - "test_info": test_info, - "is_active": test.is_active, - "button_text": button_text, - "results_button_text": results_button_text, - } - - -@inject -async def on_toggle_active(_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 - - test = await test_dao.get_by_id(test_id) - - if test: - await test_dao.update(test_id, is_active=not test.is_active) - action = "деактивирован" if test.is_active else "активирован" - await _callback.answer(f"✅ Тест {action}") - await manager.switch_to(CreatorTestsSG.test_detail) - - -@inject -async def on_toggle_results_viewable(_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 - - test = await test_dao.get_by_id(test_id) - - if test: - await test_dao.update(test_id, are_results_viewable=not test.are_results_viewable) - action = "скрыты" if test.are_results_viewable else "видны" - await _callback.answer(f"✅ Результаты теперь {action}") - await manager.switch_to(CreatorTestsSG.test_detail) - - -async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorTestsSG.tests_list) - - -async def on_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorTestsSG.statistics) - - -@inject -async def get_statistics_data( - dialog_manager: DialogManager, - attempt_repo: FromDishka[TestAttemptRepository], - **_kwargs -): - test_id = dialog_manager.dialog_data.get("selected_test_id") - - if not test_id: - return {"attempts": [], "count": 0} - - attempts_with_users = await attempt_repo.get_test_attempts_with_users(test_id) - - results = [] - for attempt, user_name in attempts_with_users: - status = "✅" if attempt.is_passed else "❌" - finished_at_msk = to_msk(attempt.finished_at) - date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "" - results.append((f"{status} {user_name} — {attempt.score}% ({date_str})", attempt.id)) - - return { - "attempts": results, - "count": len(results), - } - - -async def on_attempt_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): - manager.dialog_data["selected_attempt_id"] = int(item_id) - await manager.switch_to(CreatorTestsSG.attempt_detail) - - -async def on_back_to_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorTestsSG.statistics) - - -@inject -async def get_attempt_detail( - dialog_manager: DialogManager, - attempt_repo: FromDishka[TestAttemptRepository], - test_repo: FromDishka[TestRepository], - **_kwargs -): - attempt_id = dialog_manager.dialog_data.get("selected_attempt_id") - - if not attempt_id: - return {"attempt_info": "❌ Результат не найден"} - - attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id) - - if not attempt: - return {"attempt_info": "❌ Результат не найден"} - - status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" - finished_at_msk = to_msk(attempt.finished_at) - date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" - - lines = [ - f"📊 Результат прохождения\n", - f"📈 Результат: {attempt.score}%", - f"📅 Дата: {date_str}", - f"🏆 Статус: {status}\n", - "📋 Ответы:\n", - ] - - for i, answer in enumerate(answers, 1): - question, options = await test_repo.get_question_with_options(answer.question_id) - if not question: - continue - - correct_options = [opt for opt in options if opt.is_correct] - correct_texts = [opt.text for opt in correct_options] - - status_icon = "✅" if answer.is_correct else "❌" - - user_answer = answer.text_answer or "" - if "|" in user_answer: - user_answer = ", ".join(user_answer.split("|")) - - lines.append(f"{status_icon} Вопрос {i}") - lines.append(f"
{question.text}
") - lines.append(f"👤 Ответ: {user_answer or '—'}") - lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") - - return {"attempt_info": "\n".join(lines)} - - -@inject -async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, config: FromDishka[Config], bot_inst: FromDishka[Bot]): - test_id = manager.dialog_data.get("selected_test_id") - - if not test_id: - return { - "share_link": "Ошибка: тест не найден" - } - - test_hash = encode_id( - test_id, - config.security.encode_key, - config.security.encoded_string_length - ) - - bot_info = await bot_inst.get_me() - bot_username = bot_info.username or "your_bot" - share_link = f"https://t.me/{bot_username}?start={test_hash}" - - loop = asyncio.get_running_loop() - qr_bytes = await loop.run_in_executor( - None, - functools.partial(generate_qr_bytes, share_link) - ) - - assert _callback.message is not None - - await _callback.message.answer_photo( - photo=BufferedInputFile(qr_bytes, filename="qr.png"), - caption=f"🔗 Поделиться тестом\n\n📎 Ссылка на тест:\n{share_link}\n\n💡 Отправьте эту ссылку или QR-код пользователям для прохождения теста" - ) - - -async def on_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorTestsSG.edit_menu) - - -async def on_back_to_detail(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorTestsSG.test_detail) - - -async def on_back_to_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorTestsSG.edit_menu) - - -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) - - -async def on_edit_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorTestsSG.edit_expires) - - -@inject -async def on_password_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 - - password = message.text.strip() - if len(password) > 255: - await message.answer("❌ Пароль слишком длинный (максимум 255 символов)") - return - - await test_dao.update(test_id, password=password) - await message.answer("✅ Пароль обновлен") - await manager.switch_to(CreatorTestsSG.test_detail) - - -@inject -async def on_remove_password(_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, password=None) - await _callback.answer("✅ Пароль удален") - 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() - - return { - "groups": [(str(g.number), str(g.number)) for g in groups], - } - - -@inject -async def on_group_selected_for_test(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str, 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, for_group=int(item_id)) - await _callback.answer("✅ Группа обновлена") - await manager.switch_to(CreatorTestsSG.test_detail) - - -@inject -async def on_remove_group(_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, for_group=None) - await _callback.answer("✅ Тест теперь доступен для всех групп") - await manager.switch_to(CreatorTestsSG.test_detail) - - -@inject -async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, selected_date: date, test_dao: FromDishka[TestDAO]): - test_id = manager.dialog_data.get("selected_test_id") - if not test_id: - await _callback.answer("❌ Тест не найден") - return - - expires_at = datetime.combine(selected_date, time.min) - await test_dao.update(test_id, expires_at=expires_at) - await _callback.answer("✅ Срок действия обновлен") - await manager.switch_to(CreatorTestsSG.test_detail) - - -@inject -async def on_remove_expires(_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, expires_at=None) - await _callback.answer("✅ Срок действия удален") - await manager.switch_to(CreatorTestsSG.test_detail) - - -async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(CreateTestSG.input_title, mode=StartMode.RESET_STACK) - - -async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) - - -tests_dialog = Dialog( - Window( - Format("📝 Тесты\n\nВсего: {count}"), - ScrollingGroup( - Select( - Format("{item[0]}"), - id="test_select", - item_id_getter=lambda x: x[1], - items="tests", - on_click=on_test_selected, - ), - id="tests_scroll", - width=1, - height=7, - ), - Column( - Button(Const("➕ Добавить тест"), id="add_test", on_click=on_add_test_clicked), - Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked), - ), - state=CreatorTestsSG.tests_list, - getter=get_tests_data, - ), - Window( - Format("{test_info}"), - Column( - Button( - Format("{button_text}"), - id="toggle_active", - on_click=on_toggle_active - ), - Button( - Format("{results_button_text}"), - id="toggle_results", - on_click=on_toggle_results_viewable - ), - Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics), - Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), - Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), - ), - state=CreatorTestsSG.test_detail, - getter=get_test_detail, - ), - Window( - Const("✏️ Изменить тест\n\nВыберите, что хотите изменить:"), - 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_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), - ), - state=CreatorTestsSG.edit_menu, - ), - Window( - Const("🔑 Изменение пароля\n\n💬 Введите новый пароль или удалите текущий:\n(максимум 255 символов)"), - MessageInput(on_password_input), - Column( - Button(Const("🗑 Удалить пароль"), id="remove_password", on_click=on_remove_password), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), - ), - 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_edit_menu), - ), - state=CreatorTestsSG.edit_attempts, - ), - Window( - Const("👥 Изменение группы\n\n🎓 Выберите группу или удалите привязку:"), - ScrollingGroup( - Select( - Format("{item[1]}"), - id="groups", - item_id_getter=lambda x: x[0], - items="groups", - on_click=on_group_selected_for_test, - ), - id="groups_scroll", - width=2, - height=7, - ), - Column( - Button(Const("🗑 Для всех групп"), id="remove_group", on_click=on_remove_group), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), - ), - state=CreatorTestsSG.edit_group, - getter=get_groups_for_edit, - ), - Window( - Const("📅 Изменение срока действия\n\n🗓 Выберите новую дату или удалите срок:"), - Calendar(id="calendar", on_click=on_date_selected_for_test), - Column( - Button(Const("🗑 Удалить срок"), id="remove_expires", on_click=on_remove_expires), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), - ), - state=CreatorTestsSG.edit_expires, - ), - Window( - Format("📊 Статистика теста\n\nПрошли тест: {count}"), - ScrollingGroup( - Select( - Format("{item[0]}"), - id="attempt_select", - item_id_getter=lambda x: x[1], - items="attempts", - on_click=on_attempt_selected, - ), - id="attempts_scroll", - width=1, - height=7, - ), - Column( - Button(Const("🔄 Обновить"), id="refresh", on_click=on_statistics), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_detail), - ), - state=CreatorTestsSG.statistics, - getter=get_statistics_data, - ), - Window( - Format("{attempt_info}"), - Button(Const("◀️ Назад"), id="back", on_click=on_back_to_statistics), - state=CreatorTestsSG.attempt_detail, - getter=get_attempt_detail, - ), -) - diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index 6f19b84..6158593 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -2,14 +2,14 @@ import asyncio from aiogram import Bot from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG +from trudex.application.bot.creator_dialogs.states import CreatorUsersSG from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.bot_commands import setup_bot_commands @@ -19,7 +19,7 @@ from trudex.infrastructure.utils.config import Config @inject async def get_users_data(user_dao: FromDishka[UserDAO], **_kwargs): users = await user_dao.get_all() - users_sorted = sorted(users, key=lambda u: u.created_at, reverse=True) + users_sorted = sorted(users, key=lambda u: u.created_at or u.id, reverse=True) return { "users": [ @@ -206,10 +206,10 @@ async def on_confirm_no(_callback: CallbackQuery, _button: Button, manager: Dial async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + await manager.done() -users_dialog = Dialog( +creator_users_dialog = Dialog( Window( Format("👥 Пользователи\n\nВсего: {count}"), ScrollingGroup( diff --git a/src/trudex/application/bot/shared_dialogs/__init__.py b/src/trudex/application/bot/shared_dialogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/trudex/application/bot/admin_dialogs/broadcast.py b/src/trudex/application/bot/shared_dialogs/broadcast.py similarity index 85% rename from src/trudex/application/bot/admin_dialogs/broadcast.py rename to src/trudex/application/bot/shared_dialogs/broadcast.py index 44a82ba..2940f82 100644 --- a/src/trudex/application/bot/admin_dialogs/broadcast.py +++ b/src/trudex/application/bot/shared_dialogs/broadcast.py @@ -1,12 +1,12 @@ from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminBroadcastSG, AdminMenuSG +from trudex.application.bot.shared_dialogs.states import SharedBroadcastSG from trudex.infrastructure.database.dao.user import UserDAO from trudex.infrastructure.utils.broadcast import broadcast_message @@ -14,7 +14,7 @@ from trudex.infrastructure.utils.broadcast import broadcast_message async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): manager.dialog_data["broadcast_message_id"] = message.message_id manager.dialog_data["broadcast_chat_id"] = message.chat.id - await manager.switch_to(AdminBroadcastSG.broadcast_confirm) + await manager.switch_to(SharedBroadcastSG.broadcast_confirm) @inject @@ -48,19 +48,19 @@ async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manage async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): await _callback.answer("Рассылка отменена") - await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + await manager.done() async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + await manager.done() -broadcast_dialog = Dialog( +shared_broadcast_dialog = Dialog( Window( Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), MessageInput(on_broadcast_input), Button(Const("◀️ Отмена"), id="back", on_click=on_back_to_main), - state=AdminBroadcastSG.broadcast_input, + state=SharedBroadcastSG.broadcast_input, ), Window( Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), @@ -68,6 +68,6 @@ broadcast_dialog = Dialog( Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), ), - state=AdminBroadcastSG.broadcast_confirm, + state=SharedBroadcastSG.broadcast_confirm, ), ) diff --git a/src/trudex/application/bot/admin_dialogs/create_test.py b/src/trudex/application/bot/shared_dialogs/create_test.py similarity index 90% rename from src/trudex/application/bot/admin_dialogs/create_test.py rename to src/trudex/application/bot/shared_dialogs/create_test.py index 006e48a..c91a134 100644 --- a/src/trudex/application/bot/admin_dialogs/create_test.py +++ b/src/trudex/application/bot/shared_dialogs/create_test.py @@ -8,7 +8,7 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminCreateTestSG, AdminTestsSG +from trudex.application.bot.shared_dialogs.states import SharedCreateTestSG, SharedTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO @@ -32,7 +32,7 @@ async def on_title_input(message: Message, _widget: MessageInput, manager: Dialo return manager.dialog_data["title"] = title - await manager.switch_to(AdminCreateTestSG.input_description) + await manager.switch_to(SharedCreateTestSG.input_description) async def on_description_input(message: Message, _widget: MessageInput, manager: DialogManager): @@ -50,7 +50,7 @@ async def on_description_input(message: Message, _widget: MessageInput, manager: return manager.dialog_data["description"] = description - await manager.switch_to(AdminCreateTestSG.input_password) + await manager.switch_to(SharedCreateTestSG.input_password) @inject @@ -69,13 +69,13 @@ async def on_password_input(message: Message, _widget: MessageInput, manager: Di return manager.dialog_data["password"] = password - await manager.switch_to(AdminCreateTestSG.input_attempts) + await manager.switch_to(SharedCreateTestSG.input_attempts) @inject async def on_skip_password(_callback: CallbackQuery, _button: Button, manager: DialogManager, _group_dao: FromDishka[GroupDAO]): manager.dialog_data["password"] = None - await manager.switch_to(AdminCreateTestSG.input_attempts) + await manager.switch_to(SharedCreateTestSG.input_attempts) async def on_attempts_input(message: Message, _widget: MessageInput, manager: DialogManager): @@ -100,41 +100,38 @@ async def on_attempts_input(message: Message, _widget: MessageInput, manager: Di return manager.dialog_data["attempts"] = attempts - await manager.switch_to(AdminCreateTestSG.input_expires_at) + await manager.switch_to(SharedCreateTestSG.input_expires_at) async def on_skip_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager): manager.dialog_data["attempts"] = None - await manager.switch_to(AdminCreateTestSG.input_expires_at) + await manager.switch_to(SharedCreateTestSG.input_expires_at) async def on_date_selected(_callback, _widget, manager: DialogManager, selected_date: date): manager.dialog_data["expires_at"] = datetime.combine(selected_date, time.min) - await manager.switch_to(AdminCreateTestSG.input_for_group) + await manager.switch_to(SharedCreateTestSG.input_for_group) async def on_skip_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager): manager.dialog_data["expires_at"] = None - await manager.switch_to(AdminCreateTestSG.input_for_group) + await manager.switch_to(SharedCreateTestSG.input_for_group) @inject async def get_groups_for_test(group_dao: FromDishka[GroupDAO], **_kwargs): groups = await group_dao.get_all() - - return { - "groups": [(str(g.number), str(g.number)) for g in groups], - } + return {"groups": [(str(g.number), str(g.number)) for g in groups]} async def on_group_selected(_callback: CallbackQuery, _widget, manager: DialogManager, item_id: str): manager.dialog_data["for_group"] = int(item_id) - await manager.switch_to(AdminCreateTestSG.confirm_test_info) + await manager.switch_to(SharedCreateTestSG.confirm_test_info) async def on_skip_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): manager.dialog_data["for_group"] = None - await manager.switch_to(AdminCreateTestSG.confirm_test_info) + await manager.switch_to(SharedCreateTestSG.confirm_test_info) async def get_test_info(dialog_manager: DialogManager, **_kwargs): @@ -185,12 +182,12 @@ async def on_confirm_test(_callback: CallbackQuery, _button: Button, manager: Di manager.dialog_data["test_id"] = test.id manager.dialog_data["questions"] = [] - await manager.switch_to(AdminCreateTestSG.add_question) + await manager.switch_to(SharedCreateTestSG.add_question) async def on_add_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): manager.dialog_data["current_question"] = {} - await manager.switch_to(AdminCreateTestSG.input_question_text) + await manager.switch_to(SharedCreateTestSG.input_question_text) async def on_question_input(message: Message, _widget: MessageInput, manager: DialogManager): @@ -223,7 +220,7 @@ async def on_question_input(message: Message, _widget: MessageInput, manager: Di return manager.dialog_data["current_question"] = current_question - await manager.switch_to(AdminCreateTestSG.select_question_type) + await manager.switch_to(SharedCreateTestSG.select_question_type) async def get_question_type_data(**_kwargs): @@ -242,10 +239,10 @@ async def on_question_type_selected(_callback: CallbackQuery, _widget, manager: manager.dialog_data["current_question"] = current_question if item_id == "input": - await manager.switch_to(AdminCreateTestSG.input_correct_answer) + await manager.switch_to(SharedCreateTestSG.input_correct_answer) else: manager.dialog_data["current_options"] = [] - await manager.switch_to(AdminCreateTestSG.input_options) + await manager.switch_to(SharedCreateTestSG.input_options) async def on_correct_answer_input(message: Message, _widget: MessageInput, manager: DialogManager): @@ -265,7 +262,7 @@ async def on_correct_answer_input(message: Message, _widget: MessageInput, manag current_question = manager.dialog_data.get("current_question", {}) current_question["correct_answer"] = answer manager.dialog_data["current_question"] = current_question - await manager.switch_to(AdminCreateTestSG.confirm_question) + await manager.switch_to(SharedCreateTestSG.confirm_question) async def on_option_input(message: Message, _widget: MessageInput, manager: DialogManager): @@ -300,7 +297,7 @@ async def on_finish_options(_callback: CallbackQuery, _button: Button, manager: await _callback.answer("❌ Добавьте минимум 2 варианта ответа", show_alert=True) return - await manager.switch_to(AdminCreateTestSG.mark_correct_options) + await manager.switch_to(SharedCreateTestSG.mark_correct_options) async def get_options_data(dialog_manager: DialogManager, **_kwargs): @@ -340,7 +337,7 @@ async def on_confirm_correct(_callback: CallbackQuery, _button: Button, manager: await _callback.answer("❌ Отметьте хотя бы один правильный ответ", show_alert=True) return - await manager.switch_to(AdminCreateTestSG.confirm_question) + await manager.switch_to(SharedCreateTestSG.confirm_question) async def get_question_preview(dialog_manager: DialogManager, **_kwargs): @@ -357,7 +354,7 @@ async def get_question_preview(dialog_manager: DialogManager, **_kwargs): "input": "✏️ Ввод текста", } - preview = f"📝 Предпросмотр вопроса\n\n" + preview = "📝 Предпросмотр вопроса\n\n" preview += f"Текст: {text}\n" preview += f"Тип: {type_names[question_type]}\n" preview += f"Изображение: {'✅ Да' if has_image else '❌ Нет'}\n\n" @@ -420,13 +417,13 @@ async def on_save_question( manager.dialog_data.pop("current_options", None) await _callback.answer("✅ Вопрос добавлен") - await manager.switch_to(AdminCreateTestSG.add_question) + await manager.switch_to(SharedCreateTestSG.add_question) async def on_cancel_question(_callback: CallbackQuery, _button: Button, manager: DialogManager): manager.dialog_data.pop("current_question", None) manager.dialog_data.pop("current_options", None) - await manager.switch_to(AdminCreateTestSG.add_question) + await manager.switch_to(SharedCreateTestSG.add_question) async def get_questions_count(dialog_manager: DialogManager, **_kwargs): @@ -442,42 +439,42 @@ async def on_finish_test(_callback: CallbackQuery, _button: Button, manager: Dia return await _callback.answer("✅ Тест создан") - await manager.start(AdminTestsSG.tests_list, mode=StartMode.RESET_STACK) + await manager.start(SharedTestsSG.tests_list, mode=StartMode.RESET_STACK) async def on_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(AdminTestsSG.tests_list, mode=StartMode.RESET_STACK) + await manager.start(SharedTestsSG.tests_list, mode=StartMode.RESET_STACK) -admin_create_test_dialog = Dialog( +shared_create_test_dialog = Dialog( Window( Const("📝 Создание теста\n\n💬 Введите название теста:\n(максимум 255 символов)"), MessageInput(on_title_input), Cancel(Const("◀️ Отмена")), - state=AdminCreateTestSG.input_title, + state=SharedCreateTestSG.input_title, ), Window( Const("📝 Создание теста\n\n📄 Введите описание теста:\n(максимум 2000 символов)"), MessageInput(on_description_input), - state=AdminCreateTestSG.input_description, + state=SharedCreateTestSG.input_description, ), Window( Const("🔒 Пароль\n\n🔑 Введите пароль для доступа к тесту или пропустите этот шаг:\n(максимум 255 символов)"), MessageInput(on_password_input), Button(Const("⏭️ Без пароля"), id="skip_password", on_click=on_skip_password), - state=AdminCreateTestSG.input_password, + state=SharedCreateTestSG.input_password, ), Window( Const("🔄 Количество попыток\n\n🔢 Введите количество попыток (1-100) или пропустите для неограниченного количества:"), MessageInput(on_attempts_input), Button(Const("⏭️ Без ограничений"), id="skip_attempts", on_click=on_skip_attempts), - state=AdminCreateTestSG.input_attempts, + state=SharedCreateTestSG.input_attempts, ), Window( Const("📅 Срок действия\n\n🗓 Выберите дату истечения теста или пропустите:"), Calendar(id="calendar", on_click=on_date_selected), Button(Const("⏭️ Без срока"), id="skip_expires", on_click=on_skip_expires), - state=AdminCreateTestSG.input_expires_at, + state=SharedCreateTestSG.input_expires_at, ), Window( Const("👥 Группа\n\n🎓 Выберите группу или пропустите для всех:"), @@ -494,7 +491,7 @@ admin_create_test_dialog = Dialog( height=7, ), Button(Const("⏭️ Для всех"), id="skip_group", on_click=on_skip_group), - state=AdminCreateTestSG.input_for_group, + state=SharedCreateTestSG.input_for_group, getter=get_groups_for_test, ), Window( @@ -503,7 +500,7 @@ admin_create_test_dialog = Dialog( Button(Const("✅ Создать"), id="confirm", on_click=on_confirm_test), Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel), ), - state=AdminCreateTestSG.confirm_test_info, + state=SharedCreateTestSG.confirm_test_info, getter=get_test_info, ), Window( @@ -512,14 +509,14 @@ admin_create_test_dialog = Dialog( Button(Const("➕ Добавить вопрос"), id="add_question", on_click=on_add_question), Button(Const("✅ Завершить создание"), id="finish", on_click=on_finish_test), ), - state=AdminCreateTestSG.add_question, + state=SharedCreateTestSG.add_question, getter=get_questions_count, ), Window( Const("❓ Текст вопроса\n\n📝 Отправьте текст вопроса или 📷 фото с подписью:\n(максимум 2000 символов)"), MessageInput(on_question_input, content_types=[ContentType.TEXT, ContentType.PHOTO]), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=AdminCreateTestSG.input_question_text, + state=SharedCreateTestSG.input_question_text, ), Window( Const("📋 Тип вопроса\n\n🎯 Выберите тип вопроса:"), @@ -531,21 +528,21 @@ admin_create_test_dialog = Dialog( on_click=on_question_type_selected, )), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=AdminCreateTestSG.select_question_type, + state=SharedCreateTestSG.select_question_type, getter=get_question_type_data, ), Window( Const("✏️ Правильный ответ\n\n💬 Введите правильный ответ (регистр и пробелы игнорируются):\n(максимум 255 символов)"), MessageInput(on_correct_answer_input), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=AdminCreateTestSG.input_correct_answer, + state=SharedCreateTestSG.input_correct_answer, ), Window( Format("📝 Варианты ответов\n\n📊 Добавлено вариантов: {options_count}/10\n\n💬 Введите вариант ответа:\n(максимум 255 символов)"), MessageInput(on_option_input), Button(Const("✅ Завершить добавление вариантов"), id="finish_options", on_click=on_finish_options), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=AdminCreateTestSG.input_options, + state=SharedCreateTestSG.input_options, getter=get_options_data, ), Window( @@ -559,7 +556,7 @@ admin_create_test_dialog = Dialog( )), Button(Const("✅ Подтвердить выбор"), id="confirm", on_click=on_confirm_correct), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_question), - state=AdminCreateTestSG.mark_correct_options, + state=SharedCreateTestSG.mark_correct_options, getter=get_options_data, ), Window( @@ -568,7 +565,7 @@ admin_create_test_dialog = Dialog( Button(Const("✅ Сохранить"), id="save", on_click=on_save_question), Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_question), ), - state=AdminCreateTestSG.confirm_question, + state=SharedCreateTestSG.confirm_question, getter=get_question_preview, ), ) diff --git a/src/trudex/application/bot/creator_dialogs/groups.py b/src/trudex/application/bot/shared_dialogs/groups.py similarity index 82% rename from src/trudex/application/bot/creator_dialogs/groups.py rename to src/trudex/application/bot/shared_dialogs/groups.py index b8ff31c..dac8871 100644 --- a/src/trudex/application/bot/creator_dialogs/groups.py +++ b/src/trudex/application/bot/shared_dialogs/groups.py @@ -1,12 +1,12 @@ from aiogram.types import CallbackQuery, Message -from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreatorGroupsSG, CreatorMenuSG +from trudex.application.bot.shared_dialogs.states import SharedGroupsSG from trudex.infrastructure.database.dao.group import GroupDAO @@ -33,16 +33,15 @@ async def get_groups_data(group_dao: FromDishka[GroupDAO], dialog_manager: Dialo async def on_add_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorGroupsSG.add_group_input_number) + await manager.switch_to(SharedGroupsSG.add_group_input_number) async def on_delete_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorGroupsSG.delete_groups_list) + await manager.switch_to(SharedGroupsSG.delete_groups_list) async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): - from trudex.application.bot.creator_dialogs.states import CreatorMenuSG - await manager.start(CreatorMenuSG.main, mode=StartMode.RESET_STACK) + await manager.done() @inject @@ -68,18 +67,13 @@ async def on_group_number_input(message: Message, _widget: MessageInput, manager await message.answer(f"❌ Группа с номером {number} уже существует") return - try: - await group_dao.create(number=number) - manager.dialog_data["success_message"] = f"✅ Группа {number} создана" - except Exception as e: - await message.answer(f"❌ Ошибка создания группы: {e}") - return - - await manager.switch_to(CreatorGroupsSG.groups_list) + await group_dao.create(number=number) + manager.dialog_data["success_message"] = f"✅ Группа {number} создана" + await manager.switch_to(SharedGroupsSG.groups_list) async def on_cancel_add(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorGroupsSG.groups_list) + await manager.switch_to(SharedGroupsSG.groups_list) @inject @@ -101,34 +95,29 @@ async def on_select_group_to_delete(_callback: CallbackQuery, _widget, manager: manager.dialog_data["delete_group_id"] = group.id manager.dialog_data["delete_group_number"] = group.number - await manager.switch_to(CreatorGroupsSG.delete_confirm) + await manager.switch_to(SharedGroupsSG.delete_confirm) async def get_delete_confirm_data(dialog_manager: DialogManager, **_kwargs): number = dialog_manager.dialog_data.get("delete_group_number", "") - - return { - "group_info": str(number) - } + return {"group_info": str(number)} @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) - manager.dialog_data["success_message"] = "✅ Группа удалена" - await manager.switch_to(CreatorGroupsSG.groups_list) + await manager.switch_to(SharedGroupsSG.groups_list) async def on_cancel_delete(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(CreatorGroupsSG.delete_groups_list) + await manager.switch_to(SharedGroupsSG.delete_groups_list) -groups_dialog = Dialog( +shared_groups_dialog = Dialog( Window( Format("{message_text}"), ScrollingGroup( @@ -148,14 +137,14 @@ groups_dialog = Dialog( Button(Const("🗑 Удалить группу"), id="delete", on_click=on_delete_group), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_menu), ), - state=CreatorGroupsSG.groups_list, + state=SharedGroupsSG.groups_list, getter=get_groups_data, ), Window( Const("➕ Добавление группы\n\n🔢 Введите номер группы (четырехзначное число 1000-9999):"), MessageInput(on_group_number_input), Button(Const("◀️ Отмена"), id="cancel", on_click=on_cancel_add), - state=CreatorGroupsSG.add_group_input_number, + state=SharedGroupsSG.add_group_input_number, ), Window( Format("🗑 Удаление группы\n\nВыберите группу для удаления:\n\n📊 Всего групп: {groups_count}"), @@ -172,7 +161,7 @@ groups_dialog = Dialog( height=7, ), Button(Const("◀️ Назад"), id="back", on_click=on_cancel_add), - state=CreatorGroupsSG.delete_groups_list, + state=SharedGroupsSG.delete_groups_list, getter=get_delete_groups_data, ), Window( @@ -181,7 +170,7 @@ groups_dialog = Dialog( Button(Const("✅ Да, удалить"), id="confirm", on_click=on_confirm_delete), Button(Const("❌ Отмена"), id="cancel", on_click=on_cancel_delete), ), - state=CreatorGroupsSG.delete_confirm, + state=SharedGroupsSG.delete_confirm, getter=get_delete_confirm_data, ), ) diff --git a/src/trudex/application/bot/shared_dialogs/states.py b/src/trudex/application/bot/shared_dialogs/states.py new file mode 100644 index 0000000..3f6255c --- /dev/null +++ b/src/trudex/application/bot/shared_dialogs/states.py @@ -0,0 +1,51 @@ +from aiogram.fsm.state import State, StatesGroup + + +class SharedTemplatesSG(StatesGroup): + main = State() + export_list = State() + spec = State() + import_file = State() + + +class SharedTestsSG(StatesGroup): + tests_list = State() + test_detail = State() + share_test = State() + edit_menu = State() + edit_password = State() + edit_attempts = State() + edit_group = State() + edit_expires = State() + statistics = State() + attempt_detail = State() + + +class SharedBroadcastSG(StatesGroup): + broadcast_input = State() + broadcast_confirm = State() + + +class SharedGroupsSG(StatesGroup): + groups_list = State() + add_group_input_number = State() + delete_groups_list = State() + delete_confirm = State() + + +class SharedCreateTestSG(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() + add_question = State() + input_question_text = State() + select_question_type = State() + input_correct_answer = State() + input_options = State() + mark_correct_options = State() + confirm_question = State() + test_created = State() diff --git a/src/trudex/application/bot/admin_dialogs/templates.py b/src/trudex/application/bot/shared_dialogs/templates.py similarity index 95% rename from src/trudex/application/bot/admin_dialogs/templates.py rename to src/trudex/application/bot/shared_dialogs/templates.py index 80329aa..dcc9599 100644 --- a/src/trudex/application/bot/admin_dialogs/templates.py +++ b/src/trudex/application/bot/shared_dialogs/templates.py @@ -9,7 +9,7 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminTemplatesSG, AdminTestsSG +from trudex.application.bot.shared_dialogs.states import SharedTemplatesSG, SharedTestsSG from trudex.domain.schemas import QuestionType from trudex.domain.test_parser import ParsedTest, TestParser from trudex.infrastructure.database.dao.option import OptionDAO @@ -17,6 +17,7 @@ from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository + TEMPLATES_INFO = ( "📦 Шаблоны тестов\n\n" "Шаблоны позволяют экспортировать и импортировать тесты в формате JSON.\n\n" @@ -73,6 +74,7 @@ SPEC_INFO = """📋 Спецификация формата JSON • Для multiple — один или более is_correct: true • Минимум 2 варианта ответа для single/multiple""" + TEMPLATE_SINGLE = { "title": "Пример теста с одиночным выбором", "description": "Демонстрация формата single вопросов", @@ -166,23 +168,23 @@ TEMPLATE_FULL = { async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(AdminTemplatesSG.export_list) + await manager.switch_to(SharedTemplatesSG.export_list) async def on_import_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(AdminTemplatesSG.import_file) + await manager.switch_to(SharedTemplatesSG.import_file) async def on_spec_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(AdminTemplatesSG.spec) + await manager.switch_to(SharedTemplatesSG.spec) async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + await manager.done() async def on_back_to_templates(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: - await manager.switch_to(AdminTemplatesSG.main) + await manager.switch_to(SharedTemplatesSG.main) @inject @@ -224,7 +226,7 @@ async def on_test_selected_for_export( for question, options in questions_with_options: question_data: dict = { "text": question.text, - "question_type": question.question_type, + "question_type": question.question_type.value, } if question.question_type == QuestionType.INPUT: @@ -371,10 +373,10 @@ async def on_import_file( f"Тест создан в деактивированном состоянии." ) - await manager.start(AdminTestsSG.tests_list, mode=StartMode.RESET_STACK) + await manager.start(SharedTestsSG.tests_list, mode=StartMode.RESET_STACK) -templates_dialog = Dialog( +shared_templates_dialog = Dialog( Window( Const(TEMPLATES_INFO), Row( @@ -383,7 +385,7 @@ templates_dialog = Dialog( ), Button(Const("📋 Спецификация"), id="spec", on_click=on_spec_clicked), Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked), - state=AdminTemplatesSG.main, + state=SharedTemplatesSG.main, ), Window( Format("📤 Экспорт теста\n\nВыберите тест для экспорта:\n\nВсего: {count}"), @@ -400,7 +402,7 @@ templates_dialog = Dialog( height=7, ), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), - state=AdminTemplatesSG.export_list, + state=SharedTemplatesSG.export_list, getter=get_tests_for_export, ), Window( @@ -414,12 +416,12 @@ templates_dialog = Dialog( Button(Const("📦 Полный"), id="tpl_full", on_click=on_template_full), ), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), - state=AdminTemplatesSG.spec, + state=SharedTemplatesSG.spec, ), Window( Const("📥 Импорт теста\n\nОтправьте JSON файл с тестом.\n\nФормат файла описан в разделе «Спецификация»"), MessageInput(on_import_file, content_types=[ContentType.DOCUMENT]), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), - state=AdminTemplatesSG.import_file, + state=SharedTemplatesSG.import_file, ), ) diff --git a/src/trudex/application/bot/admin_dialogs/tests.py b/src/trudex/application/bot/shared_dialogs/tests.py similarity index 91% rename from src/trudex/application/bot/admin_dialogs/tests.py rename to src/trudex/application/bot/shared_dialogs/tests.py index 6cd66ab..e97f320 100644 --- a/src/trudex/application/bot/admin_dialogs/tests.py +++ b/src/trudex/application/bot/shared_dialogs/tests.py @@ -11,7 +11,7 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminCreateTestSG, AdminMenuSG, AdminTestsSG +from trudex.application.bot.shared_dialogs.states import SharedCreateTestSG, SharedTestsSG from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository @@ -37,7 +37,7 @@ async def get_tests_data(test_dao: FromDishka[TestDAO], **_kwargs): async def on_test_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): manager.dialog_data["selected_test_id"] = int(item_id) - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) @inject @@ -108,7 +108,7 @@ async def on_toggle_active(_callback: CallbackQuery, _button: Button, manager: D await test_dao.update(test_id, is_active=not test.is_active) action = "деактивирован" if test.is_active else "активирован" await _callback.answer(f"✅ Тест {action}") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) @inject @@ -124,15 +124,15 @@ async def on_toggle_results_viewable(_callback: CallbackQuery, _button: Button, await test_dao.update(test_id, are_results_viewable=not test.are_results_viewable) action = "скрыты" if test.are_results_viewable else "видны" await _callback.answer(f"✅ Результаты теперь {action}") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) async def on_back_to_list(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.tests_list) + await manager.switch_to(SharedTestsSG.tests_list) async def on_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.statistics) + await manager.switch_to(SharedTestsSG.statistics) @inject @@ -163,11 +163,11 @@ async def get_statistics_data( async def on_attempt_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): manager.dialog_data["selected_attempt_id"] = int(item_id) - await manager.switch_to(AdminTestsSG.attempt_detail) + await manager.switch_to(SharedTestsSG.attempt_detail) async def on_back_to_statistics(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.statistics) + await manager.switch_to(SharedTestsSG.statistics) @inject @@ -192,7 +192,7 @@ async def get_attempt_detail( date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" lines = [ - f"📊 Результат прохождения\n", + "📊 Результат прохождения\n", f"📈 Результат: {attempt.score}%", f"📅 Дата: {date_str}", f"🏆 Статус: {status}\n", @@ -226,8 +226,9 @@ async def on_share_test(_callback: CallbackQuery, _button: Button, manager: Dial test_id = manager.dialog_data.get("selected_test_id") if not test_id: - await _callback.answer("Ошибка: тест не найден") - return + return { + "share_link": "Ошибка: тест не найден" + } test_hash = encode_id( test_id, @@ -254,31 +255,31 @@ async def on_share_test(_callback: CallbackQuery, _button: Button, manager: Dial async def on_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.edit_menu) + await manager.switch_to(SharedTestsSG.edit_menu) async def on_back_to_detail(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) async def on_back_to_edit_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.edit_menu) + await manager.switch_to(SharedTestsSG.edit_menu) async def on_edit_password(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.edit_password) + await manager.switch_to(SharedTestsSG.edit_password) async def on_edit_attempts(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.edit_attempts) + await manager.switch_to(SharedTestsSG.edit_attempts) async def on_edit_group(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.edit_group) + await manager.switch_to(SharedTestsSG.edit_group) async def on_edit_expires(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.switch_to(AdminTestsSG.edit_expires) + await manager.switch_to(SharedTestsSG.edit_expires) @inject @@ -299,7 +300,7 @@ async def on_password_input(message: Message, _widget: MessageInput, manager: Di await test_dao.update(test_id, password=password) await message.answer("✅ Пароль обновлен") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) @inject @@ -311,7 +312,7 @@ async def on_remove_password(_callback: CallbackQuery, _button: Button, manager: await test_dao.update(test_id, password=None) await _callback.answer("✅ Пароль удален") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) @inject @@ -343,7 +344,7 @@ async def on_attempts_input_edit(message: Message, _widget: MessageInput, manage await test_dao.update(test_id, attempts=attempts) await message.answer("✅ Количество попыток обновлено") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) @inject @@ -355,7 +356,7 @@ async def on_remove_attempts(_callback: CallbackQuery, _button: Button, manager: await test_dao.update(test_id, attempts=None) await _callback.answer("✅ Ограничение попыток удалено") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) @inject @@ -376,7 +377,7 @@ async def on_group_selected_for_test(_callback: CallbackQuery, _widget, manager: await test_dao.update(test_id, for_group=int(item_id)) await _callback.answer("✅ Группа обновлена") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) @inject @@ -388,7 +389,7 @@ async def on_remove_group(_callback: CallbackQuery, _button: Button, manager: Di await test_dao.update(test_id, for_group=None) await _callback.answer("✅ Тест теперь доступен для всех групп") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) @inject @@ -401,7 +402,7 @@ async def on_date_selected_for_test(_callback, _widget, manager: DialogManager, expires_at = datetime.combine(selected_date, time.min) await test_dao.update(test_id, expires_at=expires_at) await _callback.answer("✅ Срок действия обновлен") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) @inject @@ -413,18 +414,18 @@ async def on_remove_expires(_callback: CallbackQuery, _button: Button, manager: await test_dao.update(test_id, expires_at=None) await _callback.answer("✅ Срок действия удален") - await manager.switch_to(AdminTestsSG.test_detail) + await manager.switch_to(SharedTestsSG.test_detail) async def on_add_test_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(AdminCreateTestSG.input_title, mode=StartMode.RESET_STACK) + await manager.start(SharedCreateTestSG.input_title, mode=StartMode.RESET_STACK) async def on_back_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): - await manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) + await manager.done() -tests_dialog = Dialog( +shared_tests_dialog = Dialog( Window( Format("📝 Тесты\n\nВсего: {count}"), ScrollingGroup( @@ -443,7 +444,7 @@ tests_dialog = Dialog( Button(Const("➕ Добавить тест"), id="add_test", on_click=on_add_test_clicked), Button(Const("◀️ Назад"), id="back", on_click=on_back_clicked), ), - state=AdminTestsSG.tests_list, + state=SharedTestsSG.tests_list, getter=get_tests_data, ), Window( @@ -464,7 +465,7 @@ tests_dialog = Dialog( Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), ), - state=AdminTestsSG.test_detail, + state=SharedTestsSG.test_detail, getter=get_test_detail, ), Window( @@ -476,7 +477,7 @@ tests_dialog = Dialog( Button(Const("📅 Срок действия"), id="edit_expires", on_click=on_edit_expires), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_detail), ), - state=AdminTestsSG.edit_menu, + state=SharedTestsSG.edit_menu, ), Window( Const("🔑 Изменение пароля\n\n💬 Введите новый пароль или удалите текущий:\n(максимум 255 символов)"), @@ -485,7 +486,7 @@ tests_dialog = Dialog( Button(Const("🗑 Удалить пароль"), id="remove_password", on_click=on_remove_password), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), - state=AdminTestsSG.edit_password, + state=SharedTestsSG.edit_password, ), Window( Const("🔄 Изменение количества попыток\n\n🔢 Введите новое количество попыток (1-100) или удалите ограничение:"), @@ -494,7 +495,7 @@ tests_dialog = Dialog( Button(Const("🗑 Без ограничений"), id="remove_attempts", on_click=on_remove_attempts), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), - state=AdminTestsSG.edit_attempts, + state=SharedTestsSG.edit_attempts, ), Window( Const("👥 Изменение группы\n\n🎓 Выберите группу или удалите привязку:"), @@ -514,7 +515,7 @@ tests_dialog = Dialog( Button(Const("🗑 Для всех групп"), id="remove_group", on_click=on_remove_group), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), - state=AdminTestsSG.edit_group, + state=SharedTestsSG.edit_group, getter=get_groups_for_edit, ), Window( @@ -524,7 +525,7 @@ tests_dialog = Dialog( Button(Const("🗑 Удалить срок"), id="remove_expires", on_click=on_remove_expires), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_edit_menu), ), - state=AdminTestsSG.edit_expires, + state=SharedTestsSG.edit_expires, ), Window( Format("📊 Статистика теста\n\nПрошли тест: {count}"), @@ -544,13 +545,13 @@ tests_dialog = Dialog( Button(Const("🔄 Обновить"), id="refresh", on_click=on_statistics), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_detail), ), - state=AdminTestsSG.statistics, + state=SharedTestsSG.statistics, getter=get_statistics_data, ), Window( Format("{attempt_info}"), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_statistics), - state=AdminTestsSG.attempt_detail, + state=SharedTestsSG.attempt_detail, getter=get_attempt_detail, ), ) From f46a0ac45b85ff494272df9ae7a8c93e1835df01 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 14:20:54 +0300 Subject: [PATCH 52/57] commit --- .../bot/shared_dialogs/templates.py | 264 +++++++++--------- src/trudex/domain/test_parser.py | 32 +-- 2 files changed, 149 insertions(+), 147 deletions(-) diff --git a/src/trudex/application/bot/shared_dialogs/templates.py b/src/trudex/application/bot/shared_dialogs/templates.py index dcc9599..9e47330 100644 --- a/src/trudex/application/bot/shared_dialogs/templates.py +++ b/src/trudex/application/bot/shared_dialogs/templates.py @@ -54,18 +54,18 @@ SPEC_INFO = """📋 Спецификация формата JSON Формат вопроса (single/multiple): { - "text": "Текст вопроса", "question_type": "single", - "options": [ - {"text": "Вариант 1", "is_correct": true}, - {"text": "Вариант 2", "is_correct": false} + "question": "Текст вопроса", + "answers": [ + {"option": "Вариант 1", "is_correct": true}, + {"option": "Вариант 2", "is_correct": false} ] } Формат вопроса (input): { - "text": "Текст вопроса", "question_type": "input", + "question": "Текст вопроса", "correct_answer": "правильный ответ" } @@ -75,96 +75,95 @@ SPEC_INFO = """📋 Спецификация формата JSON • Минимум 2 варианта ответа для single/multiple""" -TEMPLATE_SINGLE = { - "title": "Пример теста с одиночным выбором", - "description": "Демонстрация формата single вопросов", - "password": None, - "attempts": None, - "expires_at": None, - "for_group": None, - "questions": [ - { - "text": "Какой язык программирования используется для разработки Telegram ботов?", - "question_type": "single", - "options": [ - {"text": "Python", "is_correct": True}, - {"text": "HTML", "is_correct": False}, - {"text": "CSS", "is_correct": False}, - ], - }, - ], -} +TEMPLATE_ULTIMATE = """// ═══════════════════════════════════════════════════════════════ +// УЛЬТИМАТИВНЫЙ ШАБЛОН ТЕСТА +// ═══════════════════════════════════════════════════════════════ +// +// 📝 Название: Ультимативный пример теста +// 📄 Описание: Полная демонстрация всех возможностей формата +// +// ⚙️ НАСТРОЙКИ: +// • Пароль: test2024 +// • Попыток: 5 +// • Срок действия: 31 декабря 2026, 23:59 +// • Для группы: 2024 (или null для всех) +// +// ❓ ВОПРОСЫ (всего 6): +// 1. [single] - Один правильный ответ (3 варианта) +// 2. [single] - Один правильный ответ (4 варианта) +// 3. [multiple] - Несколько правильных (4 варианта, 2 верных) +// 4. [multiple] - Несколько правильных (5 вариантов, 3 верных) +// 5. [input] - Ввод текста (точный ответ) +// 6. [input] - Ввод текста (регистр игнорируется) +// +// 💡 ПОДСКАЗКИ: +// • null означает "не задано" / "без ограничений" +// • expires_at в формате ISO 8601: YYYY-MM-DDTHH:MM:SS +// • for_group - номер группы или null для всех пользователей +// +// ═══════════════════════════════════════════════════════════════ -TEMPLATE_MULTIPLE = { - "title": "Пример теста с множественным выбором", - "description": "Демонстрация формата multiple вопросов", - "password": None, - "attempts": None, - "expires_at": None, - "for_group": None, - "questions": [ - { - "text": "Выберите языки программирования:", - "question_type": "multiple", - "options": [ - {"text": "Python", "is_correct": True}, - {"text": "JavaScript", "is_correct": True}, - {"text": "HTML", "is_correct": False}, - {"text": "CSS", "is_correct": False}, - ], - }, - ], -} - -TEMPLATE_INPUT = { - "title": "Пример теста с вводом текста", - "description": "Демонстрация формата input вопросов", - "password": None, - "attempts": None, - "expires_at": None, - "for_group": None, - "questions": [ - { - "text": "Как называется библиотека для создания Telegram ботов на Python?", - "question_type": "input", - "correct_answer": "aiogram", - }, - ], -} - -TEMPLATE_FULL = { - "title": "Полный пример теста", - "description": "Тест со всеми типами вопросов и настройками", - "password": "secret123", - "attempts": 3, - "expires_at": "2026-12-31T23:59:59", - "for_group": 1234, - "questions": [ - { - "text": "Выберите правильный ответ:", - "question_type": "single", - "options": [ - {"text": "Вариант A", "is_correct": False}, - {"text": "Вариант B", "is_correct": True}, - {"text": "Вариант C", "is_correct": False}, - ], - }, - { - "text": "Выберите все правильные ответы:", - "question_type": "multiple", - "options": [ - {"text": "Ответ 1", "is_correct": True}, - {"text": "Ответ 2", "is_correct": True}, - {"text": "Ответ 3", "is_correct": False}, - ], - }, - { - "text": "Введите ответ:", - "question_type": "input", - "correct_answer": "ответ", - }, - ], +{ + "title": "Ультимативный пример теста", + "description": "Полная демонстрация всех возможностей формата: разные типы вопросов, настройки доступа, ограничения по времени и группам", + "password": "test2024", + "attempts": 5, + "expires_at": "2026-12-31T23:59:59", + "for_group": 2024, + "questions": [ + { + "question_type": "single", + "question": "Какой язык программирования чаще всего используется для создания Telegram ботов?", + "answers": [ + {"option": "Python", "is_correct": true}, + {"option": "HTML", "is_correct": false}, + {"option": "CSS", "is_correct": false} + ] + }, + { + "question_type": "single", + "question": "Сколько байт в одном килобайте?", + "answers": [ + {"option": "100", "is_correct": false}, + {"option": "1000", "is_correct": false}, + {"option": "1024", "is_correct": true}, + {"option": "2048", "is_correct": false} + ] + }, + { + "question_type": "multiple", + "question": "Выберите все языки программирования из списка:", + "answers": [ + {"option": "Python", "is_correct": true}, + {"option": "JavaScript", "is_correct": true}, + {"option": "HTML", "is_correct": false}, + {"option": "CSS", "is_correct": false} + ] + }, + { + "question_type": "multiple", + "question": "Какие из перечисленных являются базами данных?", + "answers": [ + {"option": "PostgreSQL", "is_correct": true}, + {"option": "MongoDB", "is_correct": true}, + {"option": "Redis", "is_correct": true}, + {"option": "React", "is_correct": false}, + {"option": "Docker", "is_correct": false} + ] + }, + { + "question_type": "input", + "question": "Как называется популярная библиотека для создания Telegram ботов на Python? (одно слово)", + "correct_answer": "aiogram" + }, + { + "question_type": "input", + "question": "Напишите название протокола для безопасной передачи данных в интернете (4 буквы, регистр не важен)", + "correct_answer": "HTTPS" + } + ] } +""" async def on_export_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: @@ -204,11 +203,14 @@ async def on_test_selected_for_export( item_id: str, test_repo: FromDishka[TestRepository], ) -> None: + assert _callback.message is not None + await _callback.answer("⏳ Экспортирую тест...") + test_id = int(item_id) test, questions_with_options = await test_repo.get_full_test(test_id) if not test: - await _callback.answer("❌ Тест не найден") + await _callback.message.answer("❌ Тест не найден") return export_data: dict = { @@ -225,8 +227,8 @@ async def on_test_selected_for_export( for question, options in questions_with_options: question_data: dict = { - "text": question.text, "question_type": question.question_type.value, + "question": question.text, } if question.question_type == QuestionType.INPUT: @@ -234,8 +236,8 @@ async def on_test_selected_for_export( if correct_options: question_data["correct_answer"] = correct_options[0].text else: - question_data["options"] = [ - {"text": o.text, "is_correct": o.is_correct} + question_data["answers"] = [ + {"option": o.text, "is_correct": o.is_correct} for o in options ] @@ -243,41 +245,46 @@ async def on_test_selected_for_export( json_str = json.dumps(export_data, ensure_ascii=False, indent=2) + # Build comment header + created_str = test.created_at.strftime("%d.%m.%Y %H:%M") if test.created_at else "—" + updated_str = test.updated_at.strftime("%d.%m.%Y %H:%M") if test.updated_at else "—" + questions_count = len(questions_with_options) + + comment_header = f"""// ═══════════════════════════════════════════════════════════════ +// ЭКСПОРТ ТЕСТА: {test.title} +// ═══════════════════════════════════════════════════════════════ +// +// ❓ Вопросов: {questions_count} +// 📅 Создан: {created_str} +// 🔄 Обновлён: {updated_str} +// +// ═══════════════════════════════════════════════════════════════ + +""" + + full_content = comment_header + json_str + safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in test.title)[:50] filename = f"{safe_title}.json" - assert _callback.message is not None await _callback.message.answer_document( - document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), + document=BufferedInputFile(full_content.encode("utf-8"), filename=filename), caption=f"📤 Экспорт теста: {test.title}", ) -async def send_template(callback: CallbackQuery, template: dict, name: str) -> None: - json_str = json.dumps(template, ensure_ascii=False, indent=2) +async def send_template(callback: CallbackQuery, template_str: str, name: str, title: str) -> None: filename = f"template_{name}.json" assert callback.message is not None await callback.message.answer_document( - document=BufferedInputFile(json_str.encode("utf-8"), filename=filename), - caption=f"📄 Шаблон: {template['title']}", + document=BufferedInputFile(template_str.encode("utf-8"), filename=filename), + caption=f"📄 Шаблон: {title}", ) -async def on_template_single(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await send_template(_callback, TEMPLATE_SINGLE, "single") - - -async def on_template_multiple(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await send_template(_callback, TEMPLATE_MULTIPLE, "multiple") - - -async def on_template_input(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await send_template(_callback, TEMPLATE_INPUT, "input") - - -async def on_template_full(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await send_template(_callback, TEMPLATE_FULL, "full") +async def on_template_ultimate(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: + await send_template(_callback, TEMPLATE_ULTIMATE, "ultimate", "Ультимативный пример теста") async def create_test_from_parsed( @@ -332,20 +339,22 @@ async def on_import_file( await message.answer("❌ Файл слишком большой (максимум 1 МБ)") return + progress_msg = await message.answer("⏳ Импортирую тест...") + file = await bot_inst.get_file(message.document.file_id) if not file.file_path: - await message.answer("❌ Не удалось загрузить файл") + await progress_msg.edit_text("❌ Не удалось загрузить файл") return file_bytes = await bot_inst.download_file(file.file_path) if not file_bytes: - await message.answer("❌ Не удалось загрузить файл") + await progress_msg.edit_text("❌ Не удалось загрузить файл") return try: json_str = file_bytes.read().decode("utf-8") except UnicodeDecodeError: - await message.answer("❌ Файл должен быть в кодировке UTF-8") + await progress_msg.edit_text("❌ Файл должен быть в кодировке UTF-8") return parser = TestParser() @@ -353,7 +362,7 @@ async def on_import_file( if isinstance(result, list): if not result: - await message.answer("❌ Неизвестная ошибка валидации") + await progress_msg.edit_text("❌ Неизвестная ошибка валидации") return error_lines = ["❌ Ошибки валидации:\n"] for err in result[:10]: @@ -361,12 +370,12 @@ async def on_import_file( error_lines.append(f"• {err.message}{path_str}") if len(result) > 10: error_lines.append(f"\n... и ещё {len(result) - 10} ошибок") - await message.answer("\n".join(error_lines)) + await progress_msg.edit_text("\n".join(error_lines)) return await create_test_from_parsed(result, test_dao, question_dao, option_dao) - await message.answer( + await progress_msg.edit_text( f"✅ Тест импортирован!\n\n" f"📝 Название: {result.title}\n" f"❓ Вопросов: {len(result.questions)}\n\n" @@ -407,14 +416,7 @@ shared_templates_dialog = Dialog( ), Window( Const(SPEC_INFO), - Row( - Button(Const("📌 Single"), id="tpl_single", on_click=on_template_single), - Button(Const("📋 Multiple"), id="tpl_multiple", on_click=on_template_multiple), - ), - Row( - Button(Const("✏️ Input"), id="tpl_input", on_click=on_template_input), - Button(Const("📦 Полный"), id="tpl_full", on_click=on_template_full), - ), + Button(Const("📦 Ультимативный шаблон"), id="tpl_ultimate", on_click=on_template_ultimate), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_templates), state=SharedTemplatesSG.spec, ), diff --git a/src/trudex/domain/test_parser.py b/src/trudex/domain/test_parser.py index 9fc6009..ba77bec 100644 --- a/src/trudex/domain/test_parser.py +++ b/src/trudex/domain/test_parser.py @@ -193,18 +193,18 @@ class TestParser: return questions def _parse_question(self, data: dict, path: str, errors: list[ParseError]) -> ParsedQuestion | None: - text = data.get("text") + text = data.get("question") if not text or not isinstance(text, str): - errors.append(ParseError("Поле 'text' обязательно и должно быть строкой", path=f"{path}.text")) + errors.append(ParseError("Поле 'question' обязательно и должно быть строкой", path=f"{path}.question")) return None text = text.strip() if not text: - errors.append(ParseError("Текст вопроса не может быть пустым", path=f"{path}.text")) + errors.append(ParseError("Текст вопроса не может быть пустым", path=f"{path}.question")) return None if len(text) > 2000: - errors.append(ParseError("Текст вопроса слишком длинный (максимум 2000)", path=f"{path}.text")) + errors.append(ParseError("Текст вопроса слишком длинный (максимум 2000)", path=f"{path}.question")) return None question_type = data.get("question_type") @@ -264,45 +264,45 @@ class TestParser: question_type: str, errors: list[ParseError], ) -> ParsedQuestion | None: - options_data = data.get("options") + options_data = data.get("answers") if not options_data or not isinstance(options_data, list): errors.append(ParseError( - f"Для типа '{question_type}' поле 'options' обязательно и должно быть массивом", - path=f"{path}.options" + f"Для типа '{question_type}' поле 'answers' обязательно и должно быть массивом", + path=f"{path}.answers" )) return None if len(options_data) < 2: - errors.append(ParseError("Минимум 2 варианта ответа", path=f"{path}.options")) + errors.append(ParseError("Минимум 2 варианта ответа", path=f"{path}.answers")) return None if len(options_data) > 10: - errors.append(ParseError("Максимум 10 вариантов ответа", path=f"{path}.options")) + errors.append(ParseError("Максимум 10 вариантов ответа", path=f"{path}.answers")) return None options: list[ParsedOption] = [] correct_count = 0 for j, opt_data in enumerate(options_data): - opt_path = f"{path}.options[{j}]" + opt_path = f"{path}.answers[{j}]" if not isinstance(opt_data, dict): errors.append(ParseError("Вариант ответа должен быть объектом", path=opt_path)) continue - opt_text = opt_data.get("text") + opt_text = opt_data.get("option") if not opt_text or not isinstance(opt_text, str): - errors.append(ParseError("Поле 'text' обязательно", path=f"{opt_path}.text")) + errors.append(ParseError("Поле 'option' обязательно", path=f"{opt_path}.option")) continue opt_text = opt_text.strip() if not opt_text: - errors.append(ParseError("Текст варианта не может быть пустым", path=f"{opt_path}.text")) + errors.append(ParseError("Текст варианта не может быть пустым", path=f"{opt_path}.option")) continue if len(opt_text) > 255: - errors.append(ParseError("Текст варианта слишком длинный (максимум 255)", path=f"{opt_path}.text")) + errors.append(ParseError("Текст варианта слишком длинный (максимум 255)", path=f"{opt_path}.option")) continue is_correct = opt_data.get("is_correct") @@ -319,13 +319,13 @@ class TestParser: return None if correct_count == 0: - errors.append(ParseError("Должен быть хотя бы один правильный ответ", path=f"{path}.options")) + errors.append(ParseError("Должен быть хотя бы один правильный ответ", path=f"{path}.answers")) return None if question_type == "single" and correct_count > 1: errors.append(ParseError( f"Для типа 'single' должен быть ровно один правильный ответ (найдено {correct_count})", - path=f"{path}.options" + path=f"{path}.answers" )) return None From 260171086bdec3ff69d4d90d91d9ef296b734a72 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 16:05:19 +0300 Subject: [PATCH 53/57] commit --- .../ca107b03ddf8_add_indexes_and_fk.py | 43 +++++++++ .../application/bot/admin_dialogs/users.py | 3 +- .../application/bot/creator_dialogs/users.py | 3 +- .../bot/middlewares/reject_not_creator.py | 1 - .../application/bot/shared_dialogs/tests.py | 95 ++++++++++++++++++- .../application/bot/user_dialogs/deeplink.py | 18 +++- .../application/bot/user_dialogs/take_test.py | 18 +++- .../infrastructure/database/dao/user.py | 7 ++ src/trudex/infrastructure/database/models.py | 17 ++-- .../infrastructure/database/repo/test.py | 22 +++++ .../database/repo/test_attempt.py | 18 ++-- src/trudex/infrastructure/di.py | 5 + src/trudex/infrastructure/utils/broadcast.py | 25 ++++- .../infrastructure/utils/rate_limiter.py | 57 +++++++++++ 14 files changed, 302 insertions(+), 30 deletions(-) create mode 100644 alembic/versions/ca107b03ddf8_add_indexes_and_fk.py create mode 100644 src/trudex/infrastructure/utils/rate_limiter.py diff --git a/alembic/versions/ca107b03ddf8_add_indexes_and_fk.py b/alembic/versions/ca107b03ddf8_add_indexes_and_fk.py new file mode 100644 index 0000000..980f226 --- /dev/null +++ b/alembic/versions/ca107b03ddf8_add_indexes_and_fk.py @@ -0,0 +1,43 @@ +"""add_indexes_and_fk + +Revision ID: ca107b03ddf8 +Revises: 40f5317720a4 +Create Date: 2026-01-04 15:32:14.881408 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'ca107b03ddf8' +down_revision: str | None = '40f5317720a4' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_options_question_id'), 'options', ['question_id'], unique=False) + op.create_index(op.f('ix_questions_test_id'), 'questions', ['test_id'], unique=False) + op.create_index(op.f('ix_test_attempts_test_id'), 'test_attempts', ['test_id'], unique=False) + op.create_foreign_key(None, 'test_attempts', 'users', ['user_id'], ['id']) + op.create_index(op.f('ix_user_answers_attempt_id'), 'user_answers', ['attempt_id'], unique=False) + op.create_index(op.f('ix_user_answers_question_id'), 'user_answers', ['question_id'], unique=False) + op.create_index(op.f('ix_users_group'), 'users', ['group'], unique=False) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_group'), table_name='users') + op.drop_index(op.f('ix_user_answers_question_id'), table_name='user_answers') + op.drop_index(op.f('ix_user_answers_attempt_id'), table_name='user_answers') + op.drop_constraint(None, 'test_attempts', type_='foreignkey') + op.drop_index(op.f('ix_test_attempts_test_id'), table_name='test_attempts') + op.drop_index(op.f('ix_questions_test_id'), table_name='questions') + op.drop_index(op.f('ix_options_question_id'), table_name='options') + # ### end Alembic commands ### diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index c1b67ba..b2cc96a 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -68,8 +68,7 @@ async def on_user_input(message: Message, _widget: MessageInput, manager: Dialog user = None if text.startswith("@"): username = text[1:] - all_users = await user_dao.get_all() - user = next((u for u in all_users if u.username == username), None) + user = await user_dao.get_by_username(username) elif text.isdigit(): user = await user_dao.get_by_id(int(text)) diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index 6158593..f71c027 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -137,8 +137,7 @@ async def on_user_input(message: Message, _widget: MessageInput, manager: Dialog user = None if text.startswith("@"): username = text[1:] - all_users = await user_dao.get_all() - user = next((u for u in all_users if u.username == username), None) + user = await user_dao.get_by_username(username) elif text.isdigit(): user = await user_dao.get_by_id(int(text)) diff --git a/src/trudex/application/bot/middlewares/reject_not_creator.py b/src/trudex/application/bot/middlewares/reject_not_creator.py index 8fded29..86aadab 100644 --- a/src/trudex/application/bot/middlewares/reject_not_creator.py +++ b/src/trudex/application/bot/middlewares/reject_not_creator.py @@ -30,7 +30,6 @@ class RejectNotCreatorMiddleware(BaseMiddleware): if user_id == config.bot.creator_id: return await handler(event, data) - await event.answer("У вас нет доступа к панели создателя.") return return await handler(event, data) diff --git a/src/trudex/application/bot/shared_dialogs/tests.py b/src/trudex/application/bot/shared_dialogs/tests.py index e97f320..021098e 100644 --- a/src/trudex/application/bot/shared_dialogs/tests.py +++ b/src/trudex/application/bot/shared_dialogs/tests.py @@ -1,17 +1,19 @@ import asyncio import functools +import json from datetime import date, datetime, time from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Button, Calendar, Column, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import Button, Calendar, Column, Row, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.shared_dialogs.states import SharedCreateTestSG, SharedTestsSG +from trudex.domain.schemas import QuestionType from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.repo.test import TestRepository @@ -199,11 +201,16 @@ async def get_attempt_detail( "📋 Ответы:\n", ] + # Загружаем все вопросы с опциями за один запрос + question_ids = [answer.question_id for answer in answers] + questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) + for i, answer in enumerate(answers, 1): - question, options = await test_repo.get_question_with_options(answer.question_id) - if not question: + question_data = questions_map.get(answer.question_id) + if not question_data: continue + question, options = question_data correct_options = [opt for opt in options if opt.is_correct] correct_texts = [opt.text for opt in correct_options] @@ -221,6 +228,87 @@ async def get_attempt_detail( return {"attempt_info": "\n".join(lines)} +@inject +async def on_export_test( + _callback: CallbackQuery, + _button: Button, + manager: DialogManager, + test_repo: FromDishka[TestRepository], +) -> None: + test_id = manager.dialog_data.get("selected_test_id") + + if not test_id: + await _callback.answer("❌ Тест не найден") + return + + assert _callback.message is not None + await _callback.answer("⏳ Экспортирую тест...") + + test, questions_with_options = await test_repo.get_full_test(test_id) + + if not test: + await _callback.message.answer("❌ Тест не найден") + return + + export_data: dict = { + "title": test.title, + "description": test.description, + "password": test.password, + "attempts": test.attempts, + "expires_at": test.expires_at.isoformat() if test.expires_at else None, + "for_group": test.for_group, + "questions": [], + } + + questions_list: list = export_data["questions"] + + for question, options in questions_with_options: + question_data: dict = { + "question_type": question.question_type.value, + "question": question.text, + } + + if question.question_type == QuestionType.INPUT: + correct_options = [o for o in options if o.is_correct] + if correct_options: + question_data["correct_answer"] = correct_options[0].text + else: + question_data["answers"] = [ + {"option": o.text, "is_correct": o.is_correct} + for o in options + ] + + questions_list.append(question_data) + + json_str = json.dumps(export_data, ensure_ascii=False, indent=2) + + created_str = test.created_at.strftime("%d.%m.%Y %H:%M") if test.created_at else "—" + updated_str = test.updated_at.strftime("%d.%m.%Y %H:%M") if test.updated_at else "—" + questions_count = len(questions_with_options) + + comment_header = f"""// ═══════════════════════════════════════════════════════════════ +// ЭКСПОРТ ТЕСТА: {test.title} +// ═══════════════════════════════════════════════════════════════ +// +// ❓ Вопросов: {questions_count} +// 📅 Создан: {created_str} +// 🔄 Обновлён: {updated_str} +// +// ═══════════════════════════════════════════════════════════════ + +""" + + full_content = comment_header + json_str + + safe_title = "".join(c if c.isalnum() or c in "-_" else "_" for c in test.title)[:50] + filename = f"{safe_title}.json" + + await _callback.message.answer_document( + document=BufferedInputFile(full_content.encode("utf-8"), filename=filename), + caption=f"📤 Экспорт теста: {test.title}", + ) + + @inject async def on_share_test(_callback: CallbackQuery, _button: Button, manager: DialogManager, config: FromDishka[Config], bot_inst: FromDishka[Bot]): test_id = manager.dialog_data.get("selected_test_id") @@ -462,6 +550,7 @@ shared_tests_dialog = Dialog( ), Button(Const("📊 Статистика"), id="statistics", on_click=on_statistics), Button(Const("🔗 Поделиться"), id="share", on_click=on_share_test), + Button(Const("📤 Экспорт"), id="export", on_click=on_export_test), Button(Const("✏️ Изменить"), id="edit_menu", on_click=on_edit_menu), Button(Const("◀️ Назад"), id="back", on_click=on_back_to_list), ), diff --git a/src/trudex/application/bot/user_dialogs/deeplink.py b/src/trudex/application/bot/user_dialogs/deeplink.py index 1cd907d..b990ae5 100644 --- a/src/trudex/application/bot/user_dialogs/deeplink.py +++ b/src/trudex/application/bot/user_dialogs/deeplink.py @@ -11,6 +11,7 @@ from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.models import QuestionType from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository +from trudex.infrastructure.utils.rate_limiter import PasswordRateLimiter @inject @@ -60,6 +61,7 @@ async def on_start_deeplink_test( test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], + rate_limiter: FromDishka[PasswordRateLimiter], ): assert _callback.from_user is not None @@ -89,6 +91,12 @@ async def on_start_deeplink_test( await attempt_repo.attempt_dao.delete(active_attempt.id) if test.password: + # Проверяем rate limit перед показом экрана ввода пароля + allowed, wait_time = await rate_limiter.check(user_id) + if not allowed: + minutes = int(wait_time // 60) + 1 + await _callback.answer(f"⏳ Слишком много попыток. Подождите {minutes} мин.", show_alert=True) + return await manager.switch_to(UserDeeplinkSG.password_input) else: await start_test_without_password(manager, test_repo, attempt_repo, test_id, user_id) @@ -141,6 +149,7 @@ async def on_deeplink_password_input( test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], + rate_limiter: FromDishka[PasswordRateLimiter], ): assert message.from_user is not None @@ -164,7 +173,14 @@ async def on_deeplink_password_input( manager, test_repo, attempt_repo, test_id, message.from_user.id ) else: - await message.answer("❌ Неверный пароль") + # Проверяем rate limit при неверном пароле + allowed, wait_time = await rate_limiter.check(message.from_user.id) + if not allowed: + minutes = int(wait_time // 60) + 1 + await message.answer(f"❌ Неверный пароль\n⏳ Слишком много попыток. Подождите {minutes} мин.") + await manager.start(UserMenuSG.main, mode=StartMode.RESET_STACK) + else: + await message.answer("❌ Неверный пароль") async def on_back_to_menu(_callback: CallbackQuery, _button: Button, manager: DialogManager): diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index c1b6e6f..e618084 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -12,6 +12,7 @@ from trudex.infrastructure.database.dao.test import TestDAO from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO from trudex.infrastructure.database.repo.test import TestRepository from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository +from trudex.infrastructure.utils.rate_limiter import PasswordRateLimiter from trudex.infrastructure.utils.timezone import now_msk_naive @@ -32,6 +33,7 @@ async def on_start_test( test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], + rate_limiter: FromDishka[PasswordRateLimiter], ): assert _callback.from_user is not None test_id = manager.dialog_data.get("selected_test_id") @@ -66,6 +68,12 @@ async def on_start_test( await attempt_repo.attempt_dao.delete(active_attempt.id) if test.password: + # Проверяем rate limit перед показом экрана ввода пароля + allowed, wait_time = await rate_limiter.check(user_id) + if not allowed: + minutes = int(wait_time // 60) + 1 + await _callback.answer(f"⏳ Слишком много попыток. Подождите {minutes} мин.", show_alert=True) + return await manager.start(UserTestSG.password_input, mode=StartMode.NORMAL, data={"test_id": test_id}) else: _, questions = await test_repo.get_test_with_questions(test_id) @@ -100,6 +108,7 @@ async def on_password_input( test_dao: FromDishka[TestDAO], test_repo: FromDishka[TestRepository], attempt_repo: FromDishka[TestAttemptRepository], + rate_limiter: FromDishka[PasswordRateLimiter], ): assert message.from_user is not None start_data = manager.start_data or {} @@ -137,7 +146,14 @@ async def on_password_input( await manager.switch_to(first_state) else: - await message.answer("❌ Неверный пароль") + # Проверяем rate limit при неверном пароле + allowed, wait_time = await rate_limiter.check(message.from_user.id) + if not allowed: + minutes = int(wait_time // 60) + 1 + await message.answer(f"❌ Неверный пароль\n⏳ Слишком много попыток. Подождите {minutes} мин.") + await manager.done() + else: + await message.answer("❌ Неверный пароль") @inject diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/trudex/infrastructure/database/dao/user.py index 8b88842..3914f2c 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/trudex/infrastructure/database/dao/user.py @@ -27,6 +27,13 @@ class UserDAO: model = result.scalar_one_or_none() return UserDTO(model).to_domain() if model else None + async def get_by_username(self, username: str) -> DomainUser | None: + result = await self.session.execute( + select(User).where(User.username == username) + ) + model = result.scalar_one_or_none() + return UserDTO(model).to_domain() if model else None + async def get_all(self) -> list[DomainUser]: result = await self.session.execute( select(User).order_by(User.created_at.desc()) diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index 2d18dca..a780cc5 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -16,11 +16,11 @@ class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) - username: Mapped[str | None] = mapped_column(String(32)) + username: Mapped[str | None] = mapped_column(String(32), index=True) first_name: Mapped[str] = mapped_column(String(64)) last_name: Mapped[str | None] = mapped_column(String(64)) name: Mapped[str | None] = mapped_column(String(128)) - group: Mapped[int | None] = mapped_column(CheckConstraint("group >= 1000 AND group <= 9999")) + group: Mapped[int | None] = mapped_column(CheckConstraint("group >= 1000 AND group <= 9999"), index=True) is_admin: Mapped[bool] = mapped_column(default=False) name_updated_at: Mapped[datetime | None] = mapped_column(default=None) group_updated_at: Mapped[datetime | None] = mapped_column(default=None) @@ -70,7 +70,7 @@ class Question(Base): __tablename__ = "questions" id: Mapped[int] = mapped_column(primary_key=True) - test_id: Mapped[int] = mapped_column(ForeignKey("tests.id")) + test_id: Mapped[int] = mapped_column(ForeignKey("tests.id"), index=True) text: Mapped[str] = mapped_column(Text) position: Mapped[int] = mapped_column(Integer, default=0) question_type: Mapped[QuestionType] = mapped_column(default=QuestionType.SINGLE) @@ -88,7 +88,7 @@ class Option(Base): __tablename__ = "options" id: Mapped[int] = mapped_column(primary_key=True) - question_id: Mapped[int] = mapped_column(ForeignKey("questions.id")) + question_id: Mapped[int] = mapped_column(ForeignKey("questions.id"), index=True) text: Mapped[str] = mapped_column(String(255)) is_correct: Mapped[bool] = mapped_column(default=False) explanation: Mapped[str | None] = mapped_column(Text) @@ -101,13 +101,14 @@ class TestAttempt(Base): __tablename__ = "test_attempts" id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(BigInteger, index=True) - test_id: Mapped[int] = mapped_column(ForeignKey("tests.id")) + user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), index=True) + test_id: Mapped[int] = mapped_column(ForeignKey("tests.id"), index=True) started_at: Mapped[datetime] = mapped_column(server_default=func.now()) finished_at: Mapped[datetime | None] = mapped_column(default=None) score: Mapped[int] = mapped_column(Integer, default=0) is_passed: Mapped[bool] = mapped_column(default=False) + user: Mapped["User"] = relationship() test: Mapped["Test"] = relationship() answers: Mapped[list["UserAnswer"]] = relationship( back_populates="attempt", @@ -120,8 +121,8 @@ class UserAnswer(Base): __tablename__ = "user_answers" id: Mapped[int] = mapped_column(primary_key=True) - attempt_id: Mapped[int] = mapped_column(ForeignKey("test_attempts.id")) - question_id: Mapped[int] = mapped_column(ForeignKey("questions.id")) + attempt_id: Mapped[int] = mapped_column(ForeignKey("test_attempts.id"), index=True) + question_id: Mapped[int] = mapped_column(ForeignKey("questions.id"), index=True) selected_option_id: Mapped[int | None] = mapped_column(ForeignKey("options.id"), default=None) text_answer: Mapped[str | None] = mapped_column(Text, default=None) is_correct: Mapped[bool] = mapped_column(default=False) diff --git a/src/trudex/infrastructure/database/repo/test.py b/src/trudex/infrastructure/database/repo/test.py index 521b913..b2dcae1 100644 --- a/src/trudex/infrastructure/database/repo/test.py +++ b/src/trudex/infrastructure/database/repo/test.py @@ -122,6 +122,28 @@ class TestRepository: count = result.scalar_one() return count + async def get_questions_with_options_by_ids( + self, question_ids: list[int] + ) -> dict[int, tuple[Question, list[Option]]]: + """Загружает вопросы с опциями по списку ID за один запрос.""" + if not question_ids: + return {} + + result = await self.session.execute( + select(QuestionModel) + .where(QuestionModel.id.in_(question_ids)) + .options(selectinload(QuestionModel.options)) + ) + question_models = list(result.scalars().all()) + + questions_dict: dict[int, tuple[Question, list[Option]]] = {} + for qm in question_models: + question = QuestionDTO(qm).to_domain() + options = [OptionDTO(o).to_domain() for o in qm.options] + questions_dict[qm.id] = (question, options) + + return questions_dict + async def duplicate_test(self, test_id: int, new_title: str) -> Test | None: test, questions_with_options = await self.get_full_test(test_id) if not test: diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/trudex/infrastructure/database/repo/test_attempt.py index 4842e4e..56ae2ce 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/trudex/infrastructure/database/repo/test_attempt.py @@ -155,18 +155,16 @@ class TestAttemptRepository: return [UserAnswerDTO(model).to_domain() for model in models] async def get_question_statistics(self, question_id: int) -> dict[str, int]: - total_result = await self.session.execute( - select(func.count(UserAnswerModel.id)) + result = await self.session.execute( + select( + func.count(UserAnswerModel.id).label("total"), + func.sum(func.cast(UserAnswerModel.is_correct, func.Integer)).label("correct") + ) .where(UserAnswerModel.question_id == question_id) ) - total = total_result.scalar_one() - - correct_result = await self.session.execute( - select(func.count(UserAnswerModel.id)) - .where(UserAnswerModel.question_id == question_id) - .where(UserAnswerModel.is_correct == True) - ) - correct = correct_result.scalar_one() + row = result.one() + total = row.total or 0 + correct = row.correct or 0 return { "total_answers": total, diff --git a/src/trudex/infrastructure/di.py b/src/trudex/infrastructure/di.py index e4706d3..4b23fe0 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/trudex/infrastructure/di.py @@ -18,12 +18,17 @@ from trudex.infrastructure.database.repo.test_attempt import TestAttemptReposito from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.scheduling.tasks import deactivate_expired_tests from trudex.infrastructure.utils.config import Config +from trudex.infrastructure.utils.rate_limiter import PasswordRateLimiter class DatabaseProvider(Provider): @provide(scope=Scope.APP) def get_session_maker(self, config: Config) -> async_sessionmaker[AsyncSession]: return new_session_maker(config.database.url) + + @provide(scope=Scope.APP) + def get_password_rate_limiter(self) -> PasswordRateLimiter: + return PasswordRateLimiter() @provide(scope=Scope.REQUEST) async def get_session( diff --git a/src/trudex/infrastructure/utils/broadcast.py b/src/trudex/infrastructure/utils/broadcast.py index a30c35f..0059c81 100644 --- a/src/trudex/infrastructure/utils/broadcast.py +++ b/src/trudex/infrastructure/utils/broadcast.py @@ -3,7 +3,13 @@ import logging from dataclasses import dataclass from aiogram import Bot -from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.exceptions import ( + TelegramAPIError, + TelegramBadRequest, + TelegramForbiddenError, + TelegramNetworkError, + TelegramRetryAfter, +) from trudex.infrastructure.database.dao.user import UserDAO @@ -28,14 +34,29 @@ async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: U try: await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) success += 1 + except TelegramRetryAfter as e: + logger.warning("Rate limited, waiting %d seconds", e.retry_after) + await asyncio.sleep(e.retry_after) + # Retry after waiting + try: + await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) + success += 1 + except TelegramAPIError: + failed += 1 except TelegramForbiddenError: logger.debug("Broadcast failed (forbidden): user_id=%d", user.id) failed += 1 except TelegramBadRequest as e: logger.debug("Broadcast failed (bad request): user_id=%d, error=%s", user.id, e) failed += 1 + except TelegramNetworkError as e: + logger.warning("Network error during broadcast: user_id=%d, error=%s", user.id, e) + failed += 1 + except TelegramAPIError as e: + logger.warning("Telegram API error during broadcast: user_id=%d, error=%s", user.id, e) + failed += 1 - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) logger.info("Broadcast completed: success=%d, failed=%d, total=%d", success, failed, len(users)) return BroadcastStats(success=success, failed=failed, total=len(users)) diff --git a/src/trudex/infrastructure/utils/rate_limiter.py b/src/trudex/infrastructure/utils/rate_limiter.py new file mode 100644 index 0000000..06daf03 --- /dev/null +++ b/src/trudex/infrastructure/utils/rate_limiter.py @@ -0,0 +1,57 @@ +import asyncio +import time +from dataclasses import dataclass + + +@dataclass +class UserBucket: + tokens: float + last_updated: float + + +class RateLimiter: + def __init__(self, rate: int, period: int): + self.rate = rate + self.period = period + self.fill_rate = rate / period + self.buckets: dict[int, UserBucket] = {} + self._lock = asyncio.Lock() + + async def check(self, user_id: int) -> tuple[bool, float]: + async with self._lock: + now = time.time() + + if user_id not in self.buckets: + self.buckets[user_id] = UserBucket( + tokens=self.rate - 1, + last_updated=now + ) + return True, 0.0 + + bucket = self.buckets[user_id] + + elapsed = now - bucket.last_updated + added_tokens = elapsed * self.fill_rate + bucket.tokens = min(self.rate, bucket.tokens + added_tokens) + bucket.last_updated = now + + if bucket.tokens >= 1: + bucket.tokens -= 1 + return True, 0.0 + else: + wait_time = (1 - bucket.tokens) / self.fill_rate + return False, wait_time + + async def cleanup(self) -> None: + async with self._lock: + full_buckets = [ + user_id for user_id, bucket in self.buckets.items() + if bucket.tokens >= self.rate + ] + for user_id in full_buckets: + del self.buckets[user_id] + + +class PasswordRateLimiter(RateLimiter): + def __init__(self): + super().__init__(rate=5, period=3600) From f3c7f3d10a9b91be6fb9d3752f7bbff172a14230 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 18:02:03 +0300 Subject: [PATCH 54/57] commit --- pyproject.toml | 1 + .../application/bot/admin_dialogs/states.py | 2 + .../application/bot/admin_dialogs/users.py | 151 +++++++++++++++++- .../application/bot/creator_dialogs/states.py | 2 + .../application/bot/creator_dialogs/users.py | 147 +++++++++++++++++ .../bot/shared_dialogs/templates.py | 9 ++ src/trudex/domain/test_parser.py | 8 +- src/trudex/infrastructure/database/models.py | 1 - uv.lock | 11 ++ 9 files changed, 326 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 826eb2d..f0b1136 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pydantic>=2.10.5", "qrcode[pil]>=8.2", "pycryptodome>=3.23.0", + "json5>=0.13.0", ] [dependency-groups] diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index fcc4680..b85bfbc 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -9,3 +9,5 @@ class AdminUsersSG(StatesGroup): users_list = State() users_input = State() user_detail = State() + user_stats = State() + user_result_detail = State() diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index b2cc96a..b0eb6e6 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -8,6 +8,9 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import AdminUsersSG from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository +from trudex.infrastructure.utils.timezone import to_msk @inject @@ -84,6 +87,125 @@ async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: Di await manager.done() +async def on_user_stats_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(AdminUsersSG.user_stats) + + +@inject +async def get_user_stats_data( + dialog_manager: DialogManager, + user_dao: FromDishka[UserDAO], + attempt_repo: FromDishka[TestAttemptRepository], + **_kwargs, +): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"stats_info": "Пользователь не выбран", "results": [], "count": 0} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"stats_info": "Пользователь не найден", "results": [], "count": 0} + + stats = await attempt_repo.get_user_stats(user_id) + attempts_with_tests = await attempt_repo.get_finished_attempts_with_tests(user_id) + + name = user.name or user.first_name + + if stats["total_attempts"] > 0: + accuracy_str = f"📊 Средняя точность: {stats['avg_score']}%" + tests_str = f"📝 Пройдено тестов: {stats['total_attempts']}" + else: + accuracy_str = "📊 Средняя точность: " + tests_str = "📝 Пройдено тестов: 0" + + stats_info = ( + f"📊 Статистика: {name}\n\n" + f"{tests_str}\n" + f"{accuracy_str}" + ) + + results = [] + for attempt, test_title in attempts_with_tests: + status = "✅" if attempt.is_passed else "❌" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y") if finished_at_msk else "" + results.append((f"{status} {test_title} — {attempt.score}% ({date_str})", attempt.id)) + + return { + "stats_info": stats_info, + "results": results, + "count": len(results), + } + + +async def on_result_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_attempt_id"] = int(item_id) + await manager.switch_to(AdminUsersSG.user_result_detail) + + +@inject +async def get_user_result_detail( + dialog_manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], + test_repo: FromDishka[TestRepository], + **_kwargs +): + attempt_id = dialog_manager.dialog_data.get("selected_attempt_id") + + if not attempt_id: + return {"result_info": "❌ Результат не найден"} + + attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id) + + if not attempt: + return {"result_info": "❌ Результат не найден"} + + test, _ = await test_repo.get_test_with_questions(attempt.test_id) + test_title = test.title if test else "Неизвестный тест" + + status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" + + correct_count = sum(1 for a in answers if a.is_correct) + total_count = len(answers) + + lines = [ + f"📝 {test_title}\n", + f"📊 Результат: {attempt.score}%", + f"✏️ Правильных ответов: {correct_count} из {total_count}", + f"📅 Дата: {date_str}", + f"🏆 Статус: {status}", + "\n📋 Ответы:\n", + ] + + # Загружаем все вопросы за один запрос + question_ids = [answer.question_id for answer in answers] + questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) + + for i, answer in enumerate(answers, 1): + question_data = questions_map.get(answer.question_id) + if not question_data: + continue + + question, options = question_data + correct_options = [opt for opt in options if opt.is_correct] + correct_texts = [opt.text for opt in correct_options] + + status_icon = "✅" if answer.is_correct else "❌" + + user_answer = answer.text_answer or "" + if "|" in user_answer: + user_answer = ", ".join(user_answer.split("|")) + + lines.append(f"{status_icon} Вопрос {i}") + lines.append(f"
{question.text}
") + lines.append(f"👤 Ответ: {user_answer or '—'}") + lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") + + return {"result_info": "\n".join(lines)} + + admin_users_dialog = Dialog( Window( Format("👥 Пользователи\n\nВсего: {count}"), @@ -114,8 +236,35 @@ admin_users_dialog = Dialog( ), Window( Format("{user_info}"), - SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminUsersSG.users_list), + Column( + Button(Const("📊 Статистика"), id="stats", on_click=on_user_stats_clicked), + SwitchTo(Const("◀️ Назад"), id="back_to_list", state=AdminUsersSG.users_list), + ), state=AdminUsersSG.user_detail, getter=get_user_detail_data, ), + Window( + Format("{stats_info}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="result_select", + item_id_getter=lambda x: x[1], + items="results", + on_click=on_result_selected, + ), + id="results_scroll", + width=1, + height=5, + ), + SwitchTo(Const("◀️ Назад"), id="back_to_detail", state=AdminUsersSG.user_detail), + state=AdminUsersSG.user_stats, + getter=get_user_stats_data, + ), + Window( + Format("{result_info}"), + SwitchTo(Const("◀️ Назад"), id="back_to_stats", state=AdminUsersSG.user_stats), + state=AdminUsersSG.user_result_detail, + getter=get_user_result_detail, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index 749c2f1..7fae2b6 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -9,5 +9,7 @@ class CreatorUsersSG(StatesGroup): users_list = State() users_input = State() user_detail = State() + user_stats = State() + user_result_detail = State() make_admin_confirm = State() remove_admin_confirm = State() diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index f71c027..eee5525 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -11,9 +11,12 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import CreatorUsersSG from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.database.repo.test import TestRepository +from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository from trudex.infrastructure.database.repo.user import UserRepository from trudex.infrastructure.utils.bot_commands import setup_bot_commands from trudex.infrastructure.utils.config import Config +from trudex.infrastructure.utils.timezone import to_msk @inject @@ -208,6 +211,125 @@ async def on_back_to_main(_callback: CallbackQuery, _button: Button, manager: Di await manager.done() +async def on_user_stats_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await manager.switch_to(CreatorUsersSG.user_stats) + + +@inject +async def get_user_stats_data( + dialog_manager: DialogManager, + user_dao: FromDishka[UserDAO], + attempt_repo: FromDishka[TestAttemptRepository], + **_kwargs, +): + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"stats_info": "Пользователь не выбран", "results": [], "count": 0} + + user = await user_dao.get_by_id(user_id) + if not user: + return {"stats_info": "Пользователь не найден", "results": [], "count": 0} + + stats = await attempt_repo.get_user_stats(user_id) + attempts_with_tests = await attempt_repo.get_finished_attempts_with_tests(user_id) + + name = user.name or user.first_name + + if stats["total_attempts"] > 0: + accuracy_str = f"📊 Средняя точность: {stats['avg_score']}%" + tests_str = f"📝 Пройдено тестов: {stats['total_attempts']}" + else: + accuracy_str = "📊 Средняя точность: " + tests_str = "📝 Пройдено тестов: 0" + + stats_info = ( + f"📊 Статистика: {name}\n\n" + f"{tests_str}\n" + f"{accuracy_str}" + ) + + results = [] + for attempt, test_title in attempts_with_tests: + status = "✅" if attempt.is_passed else "❌" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y") if finished_at_msk else "" + results.append((f"{status} {test_title} — {attempt.score}% ({date_str})", attempt.id)) + + return { + "stats_info": stats_info, + "results": results, + "count": len(results), + } + + +async def on_result_selected(_callback: CallbackQuery, _widget: Select, manager: DialogManager, item_id: str): + manager.dialog_data["selected_attempt_id"] = int(item_id) + await manager.switch_to(CreatorUsersSG.user_result_detail) + + +@inject +async def get_user_result_detail( + dialog_manager: DialogManager, + attempt_repo: FromDishka[TestAttemptRepository], + test_repo: FromDishka[TestRepository], + **_kwargs +): + attempt_id = dialog_manager.dialog_data.get("selected_attempt_id") + + if not attempt_id: + return {"result_info": "❌ Результат не найден"} + + attempt, answers = await attempt_repo.get_attempt_with_answers(attempt_id) + + if not attempt: + return {"result_info": "❌ Результат не найден"} + + test, _ = await test_repo.get_test_with_questions(attempt.test_id) + test_title = test.title if test else "Неизвестный тест" + + status = "✅ Пройден" if attempt.is_passed else "❌ Не пройден" + finished_at_msk = to_msk(attempt.finished_at) + date_str = finished_at_msk.strftime("%d.%m.%Y %H:%M") if finished_at_msk else "—" + + correct_count = sum(1 for a in answers if a.is_correct) + total_count = len(answers) + + lines = [ + f"📝 {test_title}\n", + f"📊 Результат: {attempt.score}%", + f"✏️ Правильных ответов: {correct_count} из {total_count}", + f"📅 Дата: {date_str}", + f"🏆 Статус: {status}", + "\n📋 Ответы:\n", + ] + + # Загружаем все вопросы за один запрос + question_ids = [answer.question_id for answer in answers] + questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) + + for i, answer in enumerate(answers, 1): + question_data = questions_map.get(answer.question_id) + if not question_data: + continue + + question, options = question_data + correct_options = [opt for opt in options if opt.is_correct] + correct_texts = [opt.text for opt in correct_options] + + status_icon = "✅" if answer.is_correct else "❌" + + user_answer = answer.text_answer or "" + if "|" in user_answer: + user_answer = ", ".join(user_answer.split("|")) + + lines.append(f"{status_icon} Вопрос {i}") + lines.append(f"
{question.text}
") + lines.append(f"👤 Ответ: {user_answer or '—'}") + lines.append(f"✓ Правильно: {', '.join(correct_texts)}\n") + + return {"result_info": "\n".join(lines)} + + creator_users_dialog = Dialog( Window( Format("👥 Пользователи\n\nВсего: {count}"), @@ -239,6 +361,7 @@ creator_users_dialog = Dialog( Window( Format("{user_info}"), Column( + Button(Const("📊 Статистика"), id="stats", on_click=on_user_stats_clicked), Button(Const("👑 Сделать администратором"), id="make_admin", on_click=on_make_admin_clicked, when="show_make_admin"), Button(Const("🚫 Снять администратора"), id="remove_admin", on_click=on_remove_admin_clicked, when="show_remove_admin"), SwitchTo(Const("◀️ Назад"), id="back_to_list", state=CreatorUsersSG.users_list), @@ -246,6 +369,30 @@ creator_users_dialog = Dialog( state=CreatorUsersSG.user_detail, getter=get_user_detail_data, ), + Window( + Format("{stats_info}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="result_select", + item_id_getter=lambda x: x[1], + items="results", + on_click=on_result_selected, + ), + id="results_scroll", + width=1, + height=5, + ), + SwitchTo(Const("◀️ Назад"), id="back_to_detail", state=CreatorUsersSG.user_detail), + state=CreatorUsersSG.user_stats, + getter=get_user_stats_data, + ), + Window( + Format("{result_info}"), + SwitchTo(Const("◀️ Назад"), id="back_to_stats", state=CreatorUsersSG.user_stats), + state=CreatorUsersSG.user_result_detail, + getter=get_user_result_detail, + ), Window( Format("{confirm_text}"), Row( diff --git a/src/trudex/application/bot/shared_dialogs/templates.py b/src/trudex/application/bot/shared_dialogs/templates.py index 9e47330..9849860 100644 --- a/src/trudex/application/bot/shared_dialogs/templates.py +++ b/src/trudex/application/bot/shared_dialogs/templates.py @@ -12,6 +12,7 @@ from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.shared_dialogs.states import SharedTemplatesSG, SharedTestsSG from trudex.domain.schemas import QuestionType from trudex.domain.test_parser import ParsedTest, TestParser +from trudex.infrastructure.database.dao.group import GroupDAO from trudex.infrastructure.database.dao.option import OptionDAO from trudex.infrastructure.database.dao.question import QuestionDAO from trudex.infrastructure.database.dao.test import TestDAO @@ -330,6 +331,7 @@ async def on_import_file( test_dao: FromDishka[TestDAO], question_dao: FromDishka[QuestionDAO], option_dao: FromDishka[OptionDAO], + group_dao: FromDishka[GroupDAO], ) -> None: if not message.document: await message.answer("❌ Отправьте JSON файл") @@ -373,6 +375,13 @@ async def on_import_file( await progress_msg.edit_text("\n".join(error_lines)) return + # Проверяем существование группы + if result.for_group is not None: + group = await group_dao.get_by_number(result.for_group) + if not group: + await progress_msg.edit_text(f"❌ Группа {result.for_group} не существует") + return + await create_test_from_parsed(result, test_dao, question_dao, option_dao) await progress_msg.edit_text( diff --git a/src/trudex/domain/test_parser.py b/src/trudex/domain/test_parser.py index ba77bec..b4bfad8 100644 --- a/src/trudex/domain/test_parser.py +++ b/src/trudex/domain/test_parser.py @@ -1,4 +1,4 @@ -import json +import json5 from dataclasses import dataclass from datetime import datetime @@ -39,9 +39,9 @@ class TestParser: def parse(self, json_str: str) -> ParsedTest | list[ParseError]: try: - data = json.loads(json_str) - except json.JSONDecodeError as e: - return [ParseError(f"Невалидный JSON: {e.msg}", path=None)] + data = json5.loads(json_str) + except ValueError as e: + return [ParseError(f"Невалидный JSON: {e}", path=None)] if not isinstance(data, dict): return [ParseError("JSON должен быть объектом", path=None)] diff --git a/src/trudex/infrastructure/database/models.py b/src/trudex/infrastructure/database/models.py index a780cc5..1b5c816 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/trudex/infrastructure/database/models.py @@ -10,7 +10,6 @@ from trudex.domain.schemas import QuestionType class Base(DeclarativeBase): pass - @final class User(Base): __tablename__ = "users" diff --git a/uv.lock b/uv.lock index c3874ba..6ff0ab6 100644 --- a/uv.lock +++ b/uv.lock @@ -380,6 +380,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "json5" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, +] + [[package]] name = "magic-filter" version = "1.0.12" @@ -773,6 +782,7 @@ dependencies = [ { name = "asyncpg" }, { name = "dishka" }, { name = "httpx" }, + { name = "json5" }, { name = "pycryptodome" }, { name = "pydantic" }, { name = "qrcode", extra = ["pil"] }, @@ -795,6 +805,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.31.0" }, { name = "dishka", specifier = ">=1.7.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "json5", specifier = ">=0.13.0" }, { name = "pycryptodome", specifier = ">=3.23.0" }, { name = "pydantic", specifier = ">=2.10.5" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, From 326ced233bbaf272f1fc89f0d69472dafff435fa Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 4 Jan 2026 19:22:06 +0300 Subject: [PATCH 55/57] commit --- src/trudex/application/__main__.py | 3 - .../application/bot/admin_dialogs/users.py | 1 - .../application/bot/creator_dialogs/users.py | 1 - .../bot/shared_dialogs/templates.py | 69 +++++++++++++++++-- .../application/bot/shared_dialogs/tests.py | 1 - .../application/bot/user_dialogs/deeplink.py | 2 - .../application/bot/user_dialogs/take_test.py | 20 ++++-- src/trudex/domain/test_parser.py | 40 ++++++++++- src/trudex/infrastructure/utils/broadcast.py | 1 - 9 files changed, 118 insertions(+), 20 deletions(-) diff --git a/src/trudex/application/__main__.py b/src/trudex/application/__main__.py index ff8d583..730d3a2 100644 --- a/src/trudex/application/__main__.py +++ b/src/trudex/application/__main__.py @@ -52,16 +52,13 @@ async def main() -> None: take_test_dialog, registration_dialog, deeplink_dialog, - # Shared dialogs shared_tests_dialog, shared_groups_dialog, shared_broadcast_dialog, shared_templates_dialog, shared_create_test_dialog, - # Admin dialogs admin_menu_dialog, admin_users_dialog, - # Creator dialogs creator_menu_dialog, creator_users_dialog, ) diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/trudex/application/bot/admin_dialogs/users.py index b0eb6e6..3ea52bc 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/trudex/application/bot/admin_dialogs/users.py @@ -179,7 +179,6 @@ async def get_user_result_detail( "\n📋 Ответы:\n", ] - # Загружаем все вопросы за один запрос question_ids = [answer.question_id for answer in answers] questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/trudex/application/bot/creator_dialogs/users.py index eee5525..7e493bd 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/trudex/application/bot/creator_dialogs/users.py @@ -303,7 +303,6 @@ async def get_user_result_detail( "\n📋 Ответы:\n", ] - # Загружаем все вопросы за один запрос question_ids = [answer.question_id for answer in answers] questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) diff --git a/src/trudex/application/bot/shared_dialogs/templates.py b/src/trudex/application/bot/shared_dialogs/templates.py index 9849860..5ac977c 100644 --- a/src/trudex/application/bot/shared_dialogs/templates.py +++ b/src/trudex/application/bot/shared_dialogs/templates.py @@ -1,5 +1,6 @@ import json +import httpx from aiogram import Bot from aiogram.types import BufferedInputFile, CallbackQuery, ContentType, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window @@ -57,6 +58,7 @@ SPEC_INFO = """📋 Спецификация формата JSON { "question_type": "single", "question": "Текст вопроса", + "image_url": "https://...", "answers": [ {"option": "Вариант 1", "is_correct": true}, {"option": "Вариант 2", "is_correct": false} @@ -67,13 +69,15 @@ SPEC_INFO = """📋 Спецификация формата JSON { "question_type": "input", "question": "Текст вопроса", + "image_url": "https://...", "correct_answer": "правильный ответ" } ⚠️ Важно: • Для single — ровно один is_correct: true • Для multiple — один или более is_correct: true -• Минимум 2 варианта ответа для single/multiple""" +• Минимум 2 варианта ответа для single/multiple +• image_url — опционально, URL изображения к вопросу""" TEMPLATE_ULTIMATE = """// ═══════════════════════════════════════════════════════════════ @@ -91,7 +95,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════ // // ❓ ВОПРОСЫ (всего 6): // 1. [single] - Один правильный ответ (3 варианта) -// 2. [single] - Один правильный ответ (4 варианта) +// 2. [single] - Один правильный ответ (4 варианта) + изображение // 3. [multiple] - Несколько правильных (4 варианта, 2 верных) // 4. [multiple] - Несколько правильных (5 вариантов, 3 верных) // 5. [input] - Ввод текста (точный ответ) @@ -101,6 +105,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════ // • null означает "не задано" / "без ограничений" // • expires_at в формате ISO 8601: YYYY-MM-DDTHH:MM:SS // • for_group - номер группы или null для всех пользователей +// • image_url - URL изображения к вопросу (опционально) // // ═══════════════════════════════════════════════════════════════ @@ -124,6 +129,7 @@ TEMPLATE_ULTIMATE = """// ══════════════════ { "question_type": "single", "question": "Сколько байт в одном килобайте?", + "image_url": "https://example.com/kilobyte.png", "answers": [ {"option": "100", "is_correct": false}, {"option": "1000", "is_correct": false}, @@ -232,6 +238,9 @@ async def on_test_selected_for_export( "question": question.text, } + if question.tg_file_id: + question_data["tg_file_id"] = question.tg_file_id + if question.question_type == QuestionType.INPUT: correct_options = [o for o in options if o.is_correct] if correct_options: @@ -246,7 +255,6 @@ async def on_test_selected_for_export( json_str = json.dumps(export_data, ensure_ascii=False, indent=2) - # Build comment header created_str = test.created_at.strftime("%d.%m.%Y %H:%M") if test.created_at else "—" updated_str = test.updated_at.strftime("%d.%m.%Y %H:%M") if test.updated_at else "—" questions_count = len(questions_with_options) @@ -288,11 +296,44 @@ async def on_template_ultimate(_callback: CallbackQuery, _button: Button, _manag await send_template(_callback, TEMPLATE_ULTIMATE, "ultimate", "Ультимативный пример теста") +async def download_image(url: str) -> bytes | None: + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url) + if response.status_code != 200: + return None + content_type = response.headers.get("content-type", "") + if not content_type.startswith("image/"): + return None + if len(response.content) > 10 * 1024 * 1024: + return None + return response.content + except httpx.HTTPError: + return None + + +async def upload_image_to_telegram(bot: Bot, image_data: bytes, chat_id: int) -> str | None: + try: + msg = await bot.send_photo( + chat_id=chat_id, + photo=BufferedInputFile(image_data, filename="image.jpg"), + disable_notification=True, + ) + await msg.delete() + if msg.photo: + return msg.photo[-1].file_id + return None + except Exception: + return None + + async def create_test_from_parsed( parsed: ParsedTest, test_dao: TestDAO, question_dao: QuestionDAO, option_dao: OptionDAO, + bot: Bot | None = None, + chat_id: int | None = None, ) -> int: test = await test_dao.create( title=parsed.title, @@ -305,11 +346,19 @@ async def create_test_from_parsed( ) for position, q in enumerate(parsed.questions): + tg_file_id: str | None = None + + if q.image_url and bot and chat_id: + image_data = await download_image(q.image_url) + if image_data: + tg_file_id = await upload_image_to_telegram(bot, image_data, chat_id) + question = await question_dao.create( test_id=test.id, text=q.text, position=position, question_type=q.question_type, + tg_file_id=tg_file_id, ) for opt in q.options: @@ -375,14 +424,24 @@ async def on_import_file( await progress_msg.edit_text("\n".join(error_lines)) return - # Проверяем существование группы if result.for_group is not None: group = await group_dao.get_by_number(result.for_group) if not group: await progress_msg.edit_text(f"❌ Группа {result.for_group} не существует") return - await create_test_from_parsed(result, test_dao, question_dao, option_dao) + has_images = any(q.image_url for q in result.questions) + if has_images: + await progress_msg.edit_text("⏳ Загружаю изображения...") + + await create_test_from_parsed( + result, + test_dao, + question_dao, + option_dao, + bot=bot_inst if has_images else None, + chat_id=message.chat.id if has_images else None, + ) await progress_msg.edit_text( f"✅ Тест импортирован!\n\n" diff --git a/src/trudex/application/bot/shared_dialogs/tests.py b/src/trudex/application/bot/shared_dialogs/tests.py index 021098e..8b689d1 100644 --- a/src/trudex/application/bot/shared_dialogs/tests.py +++ b/src/trudex/application/bot/shared_dialogs/tests.py @@ -201,7 +201,6 @@ async def get_attempt_detail( "📋 Ответы:\n", ] - # Загружаем все вопросы с опциями за один запрос question_ids = [answer.question_id for answer in answers] questions_map = await test_repo.get_questions_with_options_by_ids(question_ids) diff --git a/src/trudex/application/bot/user_dialogs/deeplink.py b/src/trudex/application/bot/user_dialogs/deeplink.py index b990ae5..8f24726 100644 --- a/src/trudex/application/bot/user_dialogs/deeplink.py +++ b/src/trudex/application/bot/user_dialogs/deeplink.py @@ -91,7 +91,6 @@ async def on_start_deeplink_test( await attempt_repo.attempt_dao.delete(active_attempt.id) if test.password: - # Проверяем rate limit перед показом экрана ввода пароля allowed, wait_time = await rate_limiter.check(user_id) if not allowed: minutes = int(wait_time // 60) + 1 @@ -173,7 +172,6 @@ async def on_deeplink_password_input( manager, test_repo, attempt_repo, test_id, message.from_user.id ) else: - # Проверяем rate limit при неверном пароле allowed, wait_time = await rate_limiter.check(message.from_user.id) if not allowed: minutes = int(wait_time // 60) + 1 diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/trudex/application/bot/user_dialogs/take_test.py index e618084..c0cae97 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/trudex/application/bot/user_dialogs/take_test.py @@ -1,7 +1,10 @@ +from aiogram.enums import ContentType as AiogramContentType from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, StartMode, Window +from aiogram_dialog.api.entities import MediaAttachment, MediaId from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Button, Column, Multiselect, Radio +from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -68,7 +71,6 @@ async def on_start_test( await attempt_repo.attempt_dao.delete(active_attempt.id) if test.password: - # Проверяем rate limit перед показом экрана ввода пароля allowed, wait_time = await rate_limiter.check(user_id) if not allowed: minutes = int(wait_time // 60) + 1 @@ -146,7 +148,6 @@ async def on_password_input( await manager.switch_to(first_state) else: - # Проверяем rate limit при неверном пароле allowed, wait_time = await rate_limiter.check(message.from_user.id) if not allowed: minutes = int(wait_time // 60) + 1 @@ -190,19 +191,27 @@ async def get_question_data( questions = dialog_manager.dialog_data.get("questions") or start_data.get("questions", []) if not questions or current_index >= len(questions): - return {"question_text": "Ошибка", "options": []} + return {"question_text": "Ошибка", "options": [], "media": None} question_id = questions[current_index] question, options = await test_repo.get_question_with_options(question_id) if not question: - return {"question_text": "Ошибка", "options": []} + return {"question_text": "Ошибка", "options": [], "media": None} progress = f"{current_index + 1}/{len(questions)}" + media = None + if question.tg_file_id: + media = MediaAttachment( + type=AiogramContentType.PHOTO, + file_id=MediaId(question.tg_file_id), + ) + return { "question_text": f"📝 Вопрос {progress}\n\n
{question.text}
", "options": [(opt.text, str(opt.id)) for opt in options], + "media": media, } @@ -492,6 +501,7 @@ take_test_dialog = Dialog( state=UserTestSG.password_input, ), Window( + DynamicMedia("media", when="media"), Format("{question_text}\n\nВыберите один вариант ответа:"), Column( Radio( @@ -508,6 +518,7 @@ take_test_dialog = Dialog( getter=get_question_data, ), Window( + DynamicMedia("media", when="media"), Format("{question_text}\n\nВыберите несколько вариантов ответа:"), Column( Multiselect( @@ -524,6 +535,7 @@ take_test_dialog = Dialog( getter=get_question_data, ), Window( + DynamicMedia("media", when="media"), Format("{question_text}\n\nВведите ответ:"), MessageInput(on_text_answer_input), state=UserTestSG.question_input, diff --git a/src/trudex/domain/test_parser.py b/src/trudex/domain/test_parser.py index b4bfad8..fa72f6f 100644 --- a/src/trudex/domain/test_parser.py +++ b/src/trudex/domain/test_parser.py @@ -15,6 +15,7 @@ class ParsedQuestion: question_type: str options: list[ParsedOption] correct_answer: str | None = None + image_url: str | None = None @dataclass @@ -219,16 +220,48 @@ class TestParser: )) return None + image_url = self._parse_image_url(data, path, errors) + if question_type == "input": - return self._parse_input_question(data, path, text, errors) + return self._parse_input_question(data, path, text, image_url, errors) else: - return self._parse_choice_question(data, path, text, question_type, errors) + return self._parse_choice_question(data, path, text, question_type, image_url, errors) + + def _parse_image_url( + self, + data: dict, + path: str, + errors: list[ParseError], + ) -> str | None: + image_url = data.get("image_url") + + if image_url is None: + return None + + if not isinstance(image_url, str): + errors.append(ParseError("Поле 'image_url' должно быть строкой", path=f"{path}.image_url")) + return None + + image_url = image_url.strip() + if not image_url: + return None + + if not image_url.startswith(("http://", "https://")): + errors.append(ParseError("Поле 'image_url' должно быть URL (http:// или https://)", path=f"{path}.image_url")) + return None + + if len(image_url) > 2000: + errors.append(ParseError("URL изображения слишком длинный (максимум 2000)", path=f"{path}.image_url")) + return None + + return image_url def _parse_input_question( self, data: dict, path: str, text: str, + image_url: str | None, errors: list[ParseError], ) -> ParsedQuestion | None: correct_answer = data.get("correct_answer") @@ -254,6 +287,7 @@ class TestParser: question_type="input", options=[ParsedOption(text=correct_answer, is_correct=True)], correct_answer=correct_answer, + image_url=image_url, ) def _parse_choice_question( @@ -262,6 +296,7 @@ class TestParser: path: str, text: str, question_type: str, + image_url: str | None, errors: list[ParseError], ) -> ParsedQuestion | None: options_data = data.get("answers") @@ -333,4 +368,5 @@ class TestParser: text=text, question_type=question_type, options=options, + image_url=image_url, ) diff --git a/src/trudex/infrastructure/utils/broadcast.py b/src/trudex/infrastructure/utils/broadcast.py index 0059c81..8e775d7 100644 --- a/src/trudex/infrastructure/utils/broadcast.py +++ b/src/trudex/infrastructure/utils/broadcast.py @@ -37,7 +37,6 @@ async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: U except TelegramRetryAfter as e: logger.warning("Rate limited, waiting %d seconds", e.retry_after) await asyncio.sleep(e.retry_after) - # Retry after waiting try: await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) success += 1 From efe3f4ab43fc14cc50f83073ed0c95f38aa1c11b Mon Sep 17 00:00:00 2001 From: kolo Date: Tue, 6 Jan 2026 18:06:51 +0300 Subject: [PATCH 56/57] update --- alembic/env.py | 4 +- pyproject.toml | 2 +- src/{trudex => quizzi}/__init__.py | 0 .../application/__init__.py | 0 .../application/__main__.py | 40 ++++---- .../application/bot/__init__.py | 0 .../application/bot/admin_dialogs/__init__.py | 0 .../bot/admin_dialogs/main_menu.py | 4 +- .../application/bot/admin_dialogs/states.py | 0 .../application/bot/admin_dialogs/users.py | 10 +- .../bot/creator_dialogs/__init__.py | 0 .../bot/creator_dialogs/main_menu.py | 4 +- .../application/bot/creator_dialogs/states.py | 0 .../application/bot/creator_dialogs/users.py | 16 +-- .../application/bot/handlers.py | 18 ++-- .../application/bot/middlewares/__init__.py | 0 .../bot/middlewares/reject_not_admin.py | 4 +- .../bot/middlewares/reject_not_creator.py | 2 +- .../bot/shared_dialogs/__init__.py | 0 .../bot/shared_dialogs/broadcast.py | 6 +- .../bot/shared_dialogs/create_test.py | 14 +-- .../application/bot/shared_dialogs/groups.py | 4 +- .../application/bot/shared_dialogs/states.py | 0 .../bot/shared_dialogs/templates.py | 16 +-- .../application/bot/shared_dialogs/tests.py | 20 ++-- .../application/bot/user_dialogs/__init__.py | 0 .../application/bot/user_dialogs/deeplink.py | 12 +-- .../application/bot/user_dialogs/main_menu.py | 20 ++-- .../bot/user_dialogs/registration.py | 6 +- .../application/bot/user_dialogs/states.py | 0 .../application/bot/user_dialogs/take_test.py | 16 +-- src/{trudex => quizzi}/domain/__init__.py | 0 src/{trudex => quizzi}/domain/schemas.py | 0 src/{trudex => quizzi}/domain/test_parser.py | 0 .../infrastructure/__init__.py | 0 .../infrastructure/database/__init__.py | 0 .../infrastructure/database/config.py | 0 .../infrastructure/database/dao/__init__.py | 0 .../infrastructure/database/dao/group.py | 6 +- .../infrastructure/database/dao/option.py | 6 +- .../infrastructure/database/dao/question.py | 8 +- .../infrastructure/database/dao/test.py | 6 +- .../database/dao/test_attempt.py | 6 +- .../infrastructure/database/dao/user.py | 6 +- .../database/dao/user_answer.py | 6 +- .../infrastructure/database/dto/__init__.py | 0 .../infrastructure/database/dto/group.py | 4 +- .../infrastructure/database/dto/option.py | 4 +- .../infrastructure/database/dto/question.py | 4 +- .../infrastructure/database/dto/test.py | 4 +- .../database/dto/test_attempt.py | 4 +- .../infrastructure/database/dto/user.py | 4 +- .../database/dto/user_answer.py | 4 +- .../infrastructure/database/models.py | 2 +- .../infrastructure/database/repo/__init__.py | 5 + .../infrastructure/database/repo/test.py | 22 ++--- .../database/repo/test_attempt.py | 22 ++--- .../infrastructure/database/repo/user.py | 8 +- src/{trudex => quizzi}/infrastructure/di.py | 28 +++--- .../infrastructure/scheduling/__init__.py | 0 .../infrastructure/scheduling/tasks.py | 4 +- .../infrastructure/utils/__init__.py | 0 .../infrastructure/utils/bot_commands.py | 4 +- .../infrastructure/utils/broadcast.py | 2 +- .../infrastructure/utils/config.py | 0 .../infrastructure/utils/qr_generator.py | 0 .../infrastructure/utils/rate_limiter.py | 0 .../infrastructure/utils/test_id_to_hash.py | 0 .../infrastructure/utils/timezone.py | 0 .../infrastructure/database/repo/__init__.py | 5 - uv.lock | 98 +++++++++---------- 71 files changed, 245 insertions(+), 245 deletions(-) rename src/{trudex => quizzi}/__init__.py (100%) rename src/{trudex => quizzi}/application/__init__.py (100%) rename src/{trudex => quizzi}/application/__main__.py (65%) rename src/{trudex => quizzi}/application/bot/__init__.py (100%) rename src/{trudex => quizzi}/application/bot/admin_dialogs/__init__.py (100%) rename src/{trudex => quizzi}/application/bot/admin_dialogs/main_menu.py (93%) rename src/{trudex => quizzi}/application/bot/admin_dialogs/states.py (100%) rename src/{trudex => quizzi}/application/bot/admin_dialogs/users.py (96%) rename src/{trudex => quizzi}/application/bot/creator_dialogs/__init__.py (100%) rename src/{trudex => quizzi}/application/bot/creator_dialogs/main_menu.py (93%) rename src/{trudex => quizzi}/application/bot/creator_dialogs/states.py (100%) rename src/{trudex => quizzi}/application/bot/creator_dialogs/users.py (96%) rename src/{trudex => quizzi}/application/bot/handlers.py (92%) rename src/{trudex => quizzi}/application/bot/middlewares/__init__.py (100%) rename src/{trudex => quizzi}/application/bot/middlewares/reject_not_admin.py (91%) rename src/{trudex => quizzi}/application/bot/middlewares/reject_not_creator.py (95%) rename src/{trudex => quizzi}/application/bot/shared_dialogs/__init__.py (100%) rename src/{trudex => quizzi}/application/bot/shared_dialogs/broadcast.py (93%) rename src/{trudex => quizzi}/application/bot/shared_dialogs/create_test.py (98%) rename src/{trudex => quizzi}/application/bot/shared_dialogs/groups.py (98%) rename src/{trudex => quizzi}/application/bot/shared_dialogs/states.py (100%) rename src/{trudex => quizzi}/application/bot/shared_dialogs/templates.py (97%) rename src/{trudex => quizzi}/application/bot/shared_dialogs/tests.py (97%) rename src/{trudex => quizzi}/application/bot/user_dialogs/__init__.py (100%) rename src/{trudex => quizzi}/application/bot/user_dialogs/deeplink.py (94%) rename src/{trudex => quizzi}/application/bot/user_dialogs/main_menu.py (96%) rename src/{trudex => quizzi}/application/bot/user_dialogs/registration.py (94%) rename src/{trudex => quizzi}/application/bot/user_dialogs/states.py (100%) rename src/{trudex => quizzi}/application/bot/user_dialogs/take_test.py (97%) rename src/{trudex => quizzi}/domain/__init__.py (100%) rename src/{trudex => quizzi}/domain/schemas.py (100%) rename src/{trudex => quizzi}/domain/test_parser.py (100%) rename src/{trudex => quizzi}/infrastructure/__init__.py (100%) rename src/{trudex => quizzi}/infrastructure/database/__init__.py (100%) rename src/{trudex => quizzi}/infrastructure/database/config.py (100%) rename src/{trudex => quizzi}/infrastructure/database/dao/__init__.py (100%) rename src/{trudex => quizzi}/infrastructure/database/dao/group.py (92%) rename src/{trudex => quizzi}/infrastructure/database/dao/option.py (93%) rename src/{trudex => quizzi}/infrastructure/database/dao/question.py (92%) rename src/{trudex => quizzi}/infrastructure/database/dao/test.py (96%) rename src/{trudex => quizzi}/infrastructure/database/dao/test_attempt.py (93%) rename src/{trudex => quizzi}/infrastructure/database/dao/user.py (96%) rename src/{trudex => quizzi}/infrastructure/database/dao/user_answer.py (93%) rename src/{trudex => quizzi}/infrastructure/database/dto/__init__.py (100%) rename src/{trudex => quizzi}/infrastructure/database/dto/group.py (75%) rename src/{trudex => quizzi}/infrastructure/database/dto/option.py (77%) rename src/{trudex => quizzi}/infrastructure/database/dto/question.py (78%) rename src/{trudex => quizzi}/infrastructure/database/dto/test.py (85%) rename src/{trudex => quizzi}/infrastructure/database/dto/test_attempt.py (80%) rename src/{trudex => quizzi}/infrastructure/database/dto/user.py (85%) rename src/{trudex => quizzi}/infrastructure/database/dto/user_answer.py (80%) rename src/{trudex => quizzi}/infrastructure/database/models.py (99%) create mode 100644 src/quizzi/infrastructure/database/repo/__init__.py rename src/{trudex => quizzi}/infrastructure/database/repo/test.py (91%) rename src/{trudex => quizzi}/infrastructure/database/repo/test_attempt.py (93%) rename src/{trudex => quizzi}/infrastructure/database/repo/user.py (89%) rename src/{trudex => quizzi}/infrastructure/di.py (76%) rename src/{trudex => quizzi}/infrastructure/scheduling/__init__.py (100%) rename src/{trudex => quizzi}/infrastructure/scheduling/tasks.py (82%) rename src/{trudex => quizzi}/infrastructure/utils/__init__.py (100%) rename src/{trudex => quizzi}/infrastructure/utils/bot_commands.py (90%) rename src/{trudex => quizzi}/infrastructure/utils/broadcast.py (97%) rename src/{trudex => quizzi}/infrastructure/utils/config.py (100%) rename src/{trudex => quizzi}/infrastructure/utils/qr_generator.py (100%) rename src/{trudex => quizzi}/infrastructure/utils/rate_limiter.py (100%) rename src/{trudex => quizzi}/infrastructure/utils/test_id_to_hash.py (100%) rename src/{trudex => quizzi}/infrastructure/utils/timezone.py (100%) delete mode 100644 src/trudex/infrastructure/database/repo/__init__.py diff --git a/alembic/env.py b/alembic/env.py index 17ad9ee..377de3b 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -5,8 +5,8 @@ from sqlalchemy import Connection from sqlalchemy.ext.asyncio import create_async_engine from alembic import context -from trudex.infrastructure.database.models import Base -from trudex.infrastructure.utils.config import Config +from quizzi.infrastructure.database.models import Base +from quizzi.infrastructure.utils.config import Config # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/pyproject.toml b/pyproject.toml index f0b1136..eb586b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "trudex" +name = "quizzi" version = "0.1.0" description = "Occupational health and safety testing platform" authors = [{ name = "kolo", email = "kolo.is.main@gmail.com" }] diff --git a/src/trudex/__init__.py b/src/quizzi/__init__.py similarity index 100% rename from src/trudex/__init__.py rename to src/quizzi/__init__.py diff --git a/src/trudex/application/__init__.py b/src/quizzi/application/__init__.py similarity index 100% rename from src/trudex/application/__init__.py rename to src/quizzi/application/__init__.py diff --git a/src/trudex/application/__main__.py b/src/quizzi/application/__main__.py similarity index 65% rename from src/trudex/application/__main__.py rename to src/quizzi/application/__main__.py index 730d3a2..52df068 100644 --- a/src/trudex/application/__main__.py +++ b/src/quizzi/application/__main__.py @@ -9,26 +9,26 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from dishka import make_async_container from dishka.integrations.aiogram import setup_dishka -from trudex.application.bot.admin_dialogs.main_menu import admin_menu_dialog -from trudex.application.bot.admin_dialogs.users import admin_users_dialog -from trudex.application.bot.creator_dialogs.main_menu import creator_menu_dialog -from trudex.application.bot.creator_dialogs.users import creator_users_dialog -from trudex.application.bot.handlers import router -from trudex.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware -from trudex.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware -from trudex.application.bot.shared_dialogs.broadcast import shared_broadcast_dialog -from trudex.application.bot.shared_dialogs.create_test import shared_create_test_dialog -from trudex.application.bot.shared_dialogs.groups import shared_groups_dialog -from trudex.application.bot.shared_dialogs.templates import shared_templates_dialog -from trudex.application.bot.shared_dialogs.tests import shared_tests_dialog -from trudex.application.bot.user_dialogs.deeplink import deeplink_dialog -from trudex.application.bot.user_dialogs.main_menu import user_menu_dialog -from trudex.application.bot.user_dialogs.registration import registration_dialog -from trudex.application.bot.user_dialogs.take_test import take_test_dialog -from trudex.infrastructure.database.repo.user import UserRepository -from trudex.infrastructure.di import DatabaseProvider, SchedulerProvider -from trudex.infrastructure.utils.bot_commands import setup_bot_commands -from trudex.infrastructure.utils.config import Config +from quizzi.application.bot.admin_dialogs.main_menu import admin_menu_dialog +from quizzi.application.bot.admin_dialogs.users import admin_users_dialog +from quizzi.application.bot.creator_dialogs.main_menu import creator_menu_dialog +from quizzi.application.bot.creator_dialogs.users import creator_users_dialog +from quizzi.application.bot.handlers import router +from quizzi.application.bot.middlewares.reject_not_admin import RejectNotAdminMiddleware +from quizzi.application.bot.middlewares.reject_not_creator import RejectNotCreatorMiddleware +from quizzi.application.bot.shared_dialogs.broadcast import shared_broadcast_dialog +from quizzi.application.bot.shared_dialogs.create_test import shared_create_test_dialog +from quizzi.application.bot.shared_dialogs.groups import shared_groups_dialog +from quizzi.application.bot.shared_dialogs.templates import shared_templates_dialog +from quizzi.application.bot.shared_dialogs.tests import shared_tests_dialog +from quizzi.application.bot.user_dialogs.deeplink import deeplink_dialog +from quizzi.application.bot.user_dialogs.main_menu import user_menu_dialog +from quizzi.application.bot.user_dialogs.registration import registration_dialog +from quizzi.application.bot.user_dialogs.take_test import take_test_dialog +from quizzi.infrastructure.database.repo.user import UserRepository +from quizzi.infrastructure.di import DatabaseProvider, SchedulerProvider +from quizzi.infrastructure.utils.bot_commands import setup_bot_commands +from quizzi.infrastructure.utils.config import Config async def main() -> None: diff --git a/src/trudex/application/bot/__init__.py b/src/quizzi/application/bot/__init__.py similarity index 100% rename from src/trudex/application/bot/__init__.py rename to src/quizzi/application/bot/__init__.py diff --git a/src/trudex/application/bot/admin_dialogs/__init__.py b/src/quizzi/application/bot/admin_dialogs/__init__.py similarity index 100% rename from src/trudex/application/bot/admin_dialogs/__init__.py rename to src/quizzi/application/bot/admin_dialogs/__init__.py diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/quizzi/application/bot/admin_dialogs/main_menu.py similarity index 93% rename from src/trudex/application/bot/admin_dialogs/main_menu.py rename to src/quizzi/application/bot/admin_dialogs/main_menu.py index 52f5ee5..a0d1cb8 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/quizzi/application/bot/admin_dialogs/main_menu.py @@ -3,8 +3,8 @@ from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG -from trudex.application.bot.shared_dialogs.states import ( +from quizzi.application.bot.admin_dialogs.states import AdminMenuSG, AdminUsersSG +from quizzi.application.bot.shared_dialogs.states import ( SharedBroadcastSG, SharedGroupsSG, SharedTemplatesSG, diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/quizzi/application/bot/admin_dialogs/states.py similarity index 100% rename from src/trudex/application/bot/admin_dialogs/states.py rename to src/quizzi/application/bot/admin_dialogs/states.py diff --git a/src/trudex/application/bot/admin_dialogs/users.py b/src/quizzi/application/bot/admin_dialogs/users.py similarity index 96% rename from src/trudex/application/bot/admin_dialogs/users.py rename to src/quizzi/application/bot/admin_dialogs/users.py index 3ea52bc..7616cf3 100644 --- a/src/trudex/application/bot/admin_dialogs/users.py +++ b/src/quizzi/application/bot/admin_dialogs/users.py @@ -6,11 +6,11 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.admin_dialogs.states import AdminUsersSG -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.utils.timezone import to_msk +from quizzi.application.bot.admin_dialogs.states import AdminUsersSG +from quizzi.infrastructure.database.dao.user import UserDAO +from quizzi.infrastructure.database.repo.test import TestRepository +from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository +from quizzi.infrastructure.utils.timezone import to_msk @inject diff --git a/src/trudex/application/bot/creator_dialogs/__init__.py b/src/quizzi/application/bot/creator_dialogs/__init__.py similarity index 100% rename from src/trudex/application/bot/creator_dialogs/__init__.py rename to src/quizzi/application/bot/creator_dialogs/__init__.py diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/quizzi/application/bot/creator_dialogs/main_menu.py similarity index 93% rename from src/trudex/application/bot/creator_dialogs/main_menu.py rename to src/quizzi/application/bot/creator_dialogs/main_menu.py index d11cdae..941ff84 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/quizzi/application/bot/creator_dialogs/main_menu.py @@ -3,8 +3,8 @@ from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Column from aiogram_dialog.widgets.text import Const -from trudex.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG -from trudex.application.bot.shared_dialogs.states import ( +from quizzi.application.bot.creator_dialogs.states import CreatorMenuSG, CreatorUsersSG +from quizzi.application.bot.shared_dialogs.states import ( SharedBroadcastSG, SharedGroupsSG, SharedTemplatesSG, diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/quizzi/application/bot/creator_dialogs/states.py similarity index 100% rename from src/trudex/application/bot/creator_dialogs/states.py rename to src/quizzi/application/bot/creator_dialogs/states.py diff --git a/src/trudex/application/bot/creator_dialogs/users.py b/src/quizzi/application/bot/creator_dialogs/users.py similarity index 96% rename from src/trudex/application/bot/creator_dialogs/users.py rename to src/quizzi/application/bot/creator_dialogs/users.py index 7e493bd..e147d0b 100644 --- a/src/trudex/application/bot/creator_dialogs/users.py +++ b/src/quizzi/application/bot/creator_dialogs/users.py @@ -9,14 +9,14 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.creator_dialogs.states import CreatorUsersSG -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.database.repo.user import UserRepository -from trudex.infrastructure.utils.bot_commands import setup_bot_commands -from trudex.infrastructure.utils.config import Config -from trudex.infrastructure.utils.timezone import to_msk +from quizzi.application.bot.creator_dialogs.states import CreatorUsersSG +from quizzi.infrastructure.database.dao.user import UserDAO +from quizzi.infrastructure.database.repo.test import TestRepository +from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository +from quizzi.infrastructure.database.repo.user import UserRepository +from quizzi.infrastructure.utils.bot_commands import setup_bot_commands +from quizzi.infrastructure.utils.config import Config +from quizzi.infrastructure.utils.timezone import to_msk @inject diff --git a/src/trudex/application/bot/handlers.py b/src/quizzi/application/bot/handlers.py similarity index 92% rename from src/trudex/application/bot/handlers.py rename to src/quizzi/application/bot/handlers.py index 2b4d54f..59344ed 100644 --- a/src/trudex/application/bot/handlers.py +++ b/src/quizzi/application/bot/handlers.py @@ -7,15 +7,15 @@ from aiogram_dialog import DialogManager, StartMode from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent from dishka.integrations.aiogram import FromDishka -from trudex.application.bot.admin_dialogs.states import AdminMenuSG -from trudex.application.bot.creator_dialogs.states import CreatorMenuSG -from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG -from trudex.infrastructure.database.dao.group import GroupDAO -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.utils.config import Config -from trudex.infrastructure.utils.test_id_to_hash import decode_id -from trudex.infrastructure.utils.timezone import now_msk_naive +from quizzi.application.bot.admin_dialogs.states import AdminMenuSG +from quizzi.application.bot.creator_dialogs.states import CreatorMenuSG +from quizzi.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG +from quizzi.infrastructure.database.dao.group import GroupDAO +from quizzi.infrastructure.database.dao.test import TestDAO +from quizzi.infrastructure.database.dao.user import UserDAO +from quizzi.infrastructure.utils.config import Config +from quizzi.infrastructure.utils.test_id_to_hash import decode_id +from quizzi.infrastructure.utils.timezone import now_msk_naive router = Router() logger = logging.getLogger(__name__) diff --git a/src/trudex/application/bot/middlewares/__init__.py b/src/quizzi/application/bot/middlewares/__init__.py similarity index 100% rename from src/trudex/application/bot/middlewares/__init__.py rename to src/quizzi/application/bot/middlewares/__init__.py diff --git a/src/trudex/application/bot/middlewares/reject_not_admin.py b/src/quizzi/application/bot/middlewares/reject_not_admin.py similarity index 91% rename from src/trudex/application/bot/middlewares/reject_not_admin.py rename to src/quizzi/application/bot/middlewares/reject_not_admin.py index a9d7f90..cc9354d 100644 --- a/src/trudex/application/bot/middlewares/reject_not_admin.py +++ b/src/quizzi/application/bot/middlewares/reject_not_admin.py @@ -5,8 +5,8 @@ from aiogram import BaseMiddleware from aiogram.types import Message, TelegramObject from dishka import AsyncContainer -from trudex.infrastructure.database.repo import UserRepository -from trudex.infrastructure.utils.config import Config +from quizzi.infrastructure.database.repo import UserRepository +from quizzi.infrastructure.utils.config import Config class RejectNotAdminMiddleware(BaseMiddleware): diff --git a/src/trudex/application/bot/middlewares/reject_not_creator.py b/src/quizzi/application/bot/middlewares/reject_not_creator.py similarity index 95% rename from src/trudex/application/bot/middlewares/reject_not_creator.py rename to src/quizzi/application/bot/middlewares/reject_not_creator.py index 86aadab..d343b83 100644 --- a/src/trudex/application/bot/middlewares/reject_not_creator.py +++ b/src/quizzi/application/bot/middlewares/reject_not_creator.py @@ -5,7 +5,7 @@ from aiogram import BaseMiddleware from aiogram.types import Message, TelegramObject from dishka import AsyncContainer -from trudex.infrastructure.utils.config import Config +from quizzi.infrastructure.utils.config import Config class RejectNotCreatorMiddleware(BaseMiddleware): diff --git a/src/trudex/application/bot/shared_dialogs/__init__.py b/src/quizzi/application/bot/shared_dialogs/__init__.py similarity index 100% rename from src/trudex/application/bot/shared_dialogs/__init__.py rename to src/quizzi/application/bot/shared_dialogs/__init__.py diff --git a/src/trudex/application/bot/shared_dialogs/broadcast.py b/src/quizzi/application/bot/shared_dialogs/broadcast.py similarity index 93% rename from src/trudex/application/bot/shared_dialogs/broadcast.py rename to src/quizzi/application/bot/shared_dialogs/broadcast.py index 2940f82..17006f0 100644 --- a/src/trudex/application/bot/shared_dialogs/broadcast.py +++ b/src/quizzi/application/bot/shared_dialogs/broadcast.py @@ -6,9 +6,9 @@ from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.shared_dialogs.states import SharedBroadcastSG -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.utils.broadcast import broadcast_message +from quizzi.application.bot.shared_dialogs.states import SharedBroadcastSG +from quizzi.infrastructure.database.dao.user import UserDAO +from quizzi.infrastructure.utils.broadcast import broadcast_message async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): diff --git a/src/trudex/application/bot/shared_dialogs/create_test.py b/src/quizzi/application/bot/shared_dialogs/create_test.py similarity index 98% rename from src/trudex/application/bot/shared_dialogs/create_test.py rename to src/quizzi/application/bot/shared_dialogs/create_test.py index c91a134..268030f 100644 --- a/src/trudex/application/bot/shared_dialogs/create_test.py +++ b/src/quizzi/application/bot/shared_dialogs/create_test.py @@ -8,13 +8,13 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.shared_dialogs.states import SharedCreateTestSG, SharedTestsSG -from trudex.infrastructure.database.dao.group import GroupDAO -from trudex.infrastructure.database.dao.option import OptionDAO -from trudex.infrastructure.database.dao.question import QuestionDAO -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.utils.timezone import to_msk +from quizzi.application.bot.shared_dialogs.states import SharedCreateTestSG, SharedTestsSG +from quizzi.infrastructure.database.dao.group import GroupDAO +from quizzi.infrastructure.database.dao.option import OptionDAO +from quizzi.infrastructure.database.dao.question import QuestionDAO +from quizzi.infrastructure.database.dao.test import TestDAO +from quizzi.infrastructure.database.repo.test import TestRepository +from quizzi.infrastructure.utils.timezone import to_msk async def on_title_input(message: Message, _widget: MessageInput, manager: DialogManager): diff --git a/src/trudex/application/bot/shared_dialogs/groups.py b/src/quizzi/application/bot/shared_dialogs/groups.py similarity index 98% rename from src/trudex/application/bot/shared_dialogs/groups.py rename to src/quizzi/application/bot/shared_dialogs/groups.py index dac8871..159cada 100644 --- a/src/trudex/application/bot/shared_dialogs/groups.py +++ b/src/quizzi/application/bot/shared_dialogs/groups.py @@ -6,8 +6,8 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.shared_dialogs.states import SharedGroupsSG -from trudex.infrastructure.database.dao.group import GroupDAO +from quizzi.application.bot.shared_dialogs.states import SharedGroupsSG +from quizzi.infrastructure.database.dao.group import GroupDAO async def on_group_click(_callback: CallbackQuery, _widget, _manager: DialogManager, _item_id: str): diff --git a/src/trudex/application/bot/shared_dialogs/states.py b/src/quizzi/application/bot/shared_dialogs/states.py similarity index 100% rename from src/trudex/application/bot/shared_dialogs/states.py rename to src/quizzi/application/bot/shared_dialogs/states.py diff --git a/src/trudex/application/bot/shared_dialogs/templates.py b/src/quizzi/application/bot/shared_dialogs/templates.py similarity index 97% rename from src/trudex/application/bot/shared_dialogs/templates.py rename to src/quizzi/application/bot/shared_dialogs/templates.py index 5ac977c..2be5ec3 100644 --- a/src/trudex/application/bot/shared_dialogs/templates.py +++ b/src/quizzi/application/bot/shared_dialogs/templates.py @@ -10,14 +10,14 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.shared_dialogs.states import SharedTemplatesSG, SharedTestsSG -from trudex.domain.schemas import QuestionType -from trudex.domain.test_parser import ParsedTest, TestParser -from trudex.infrastructure.database.dao.group import GroupDAO -from trudex.infrastructure.database.dao.option import OptionDAO -from trudex.infrastructure.database.dao.question import QuestionDAO -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.repo.test import TestRepository +from quizzi.application.bot.shared_dialogs.states import SharedTemplatesSG, SharedTestsSG +from quizzi.domain.schemas import QuestionType +from quizzi.domain.test_parser import ParsedTest, TestParser +from quizzi.infrastructure.database.dao.group import GroupDAO +from quizzi.infrastructure.database.dao.option import OptionDAO +from quizzi.infrastructure.database.dao.question import QuestionDAO +from quizzi.infrastructure.database.dao.test import TestDAO +from quizzi.infrastructure.database.repo.test import TestRepository TEMPLATES_INFO = ( diff --git a/src/trudex/application/bot/shared_dialogs/tests.py b/src/quizzi/application/bot/shared_dialogs/tests.py similarity index 97% rename from src/trudex/application/bot/shared_dialogs/tests.py rename to src/quizzi/application/bot/shared_dialogs/tests.py index 8b689d1..93a45ca 100644 --- a/src/trudex/application/bot/shared_dialogs/tests.py +++ b/src/quizzi/application/bot/shared_dialogs/tests.py @@ -12,16 +12,16 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.shared_dialogs.states import SharedCreateTestSG, SharedTestsSG -from trudex.domain.schemas import QuestionType -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.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.utils.config import Config -from trudex.infrastructure.utils.qr_generator import generate_qr_bytes -from trudex.infrastructure.utils.test_id_to_hash import encode_id -from trudex.infrastructure.utils.timezone import to_msk +from quizzi.application.bot.shared_dialogs.states import SharedCreateTestSG, SharedTestsSG +from quizzi.domain.schemas import QuestionType +from quizzi.infrastructure.database.dao.group import GroupDAO +from quizzi.infrastructure.database.dao.test import TestDAO +from quizzi.infrastructure.database.repo.test import TestRepository +from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository +from quizzi.infrastructure.utils.config import Config +from quizzi.infrastructure.utils.qr_generator import generate_qr_bytes +from quizzi.infrastructure.utils.test_id_to_hash import encode_id +from quizzi.infrastructure.utils.timezone import to_msk @inject diff --git a/src/trudex/application/bot/user_dialogs/__init__.py b/src/quizzi/application/bot/user_dialogs/__init__.py similarity index 100% rename from src/trudex/application/bot/user_dialogs/__init__.py rename to src/quizzi/application/bot/user_dialogs/__init__.py diff --git a/src/trudex/application/bot/user_dialogs/deeplink.py b/src/quizzi/application/bot/user_dialogs/deeplink.py similarity index 94% rename from src/trudex/application/bot/user_dialogs/deeplink.py rename to src/quizzi/application/bot/user_dialogs/deeplink.py index 8f24726..74dcb61 100644 --- a/src/trudex/application/bot/user_dialogs/deeplink.py +++ b/src/quizzi/application/bot/user_dialogs/deeplink.py @@ -6,12 +6,12 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserTestSG -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.models import QuestionType -from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.utils.rate_limiter import PasswordRateLimiter +from quizzi.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserTestSG +from quizzi.infrastructure.database.dao.test import TestDAO +from quizzi.infrastructure.database.models import QuestionType +from quizzi.infrastructure.database.repo.test import TestRepository +from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository +from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter @inject diff --git a/src/trudex/application/bot/user_dialogs/main_menu.py b/src/quizzi/application/bot/user_dialogs/main_menu.py similarity index 96% rename from src/trudex/application/bot/user_dialogs/main_menu.py rename to src/quizzi/application/bot/user_dialogs/main_menu.py index 1460415..08de0fb 100644 --- a/src/trudex/application/bot/user_dialogs/main_menu.py +++ b/src/quizzi/application/bot/user_dialogs/main_menu.py @@ -11,16 +11,16 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.user_dialogs.states import UserMenuSG -from trudex.application.bot.user_dialogs.take_test import on_start_test -from trudex.infrastructure.database.dao.group import GroupDAO -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.utils.config import Config -from trudex.infrastructure.utils.qr_generator import generate_qr_bytes -from trudex.infrastructure.utils.test_id_to_hash import encode_id -from trudex.infrastructure.utils.timezone import now_msk, now_msk_naive, to_msk +from quizzi.application.bot.user_dialogs.states import UserMenuSG +from quizzi.application.bot.user_dialogs.take_test import on_start_test +from quizzi.infrastructure.database.dao.group import GroupDAO +from quizzi.infrastructure.database.dao.user import UserDAO +from quizzi.infrastructure.database.repo.test import TestRepository +from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository +from quizzi.infrastructure.utils.config import Config +from quizzi.infrastructure.utils.qr_generator import generate_qr_bytes +from quizzi.infrastructure.utils.test_id_to_hash import encode_id +from quizzi.infrastructure.utils.timezone import now_msk, now_msk_naive, to_msk def can_edit_field(updated_at: datetime | None) -> bool: diff --git a/src/trudex/application/bot/user_dialogs/registration.py b/src/quizzi/application/bot/user_dialogs/registration.py similarity index 94% rename from src/trudex/application/bot/user_dialogs/registration.py rename to src/quizzi/application/bot/user_dialogs/registration.py index 953cf23..6e12ed1 100644 --- a/src/trudex/application/bot/user_dialogs/registration.py +++ b/src/quizzi/application/bot/user_dialogs/registration.py @@ -6,9 +6,9 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG -from trudex.infrastructure.database.dao.group import GroupDAO -from trudex.infrastructure.database.dao.user import UserDAO +from quizzi.application.bot.user_dialogs.states import UserDeeplinkSG, UserMenuSG, UserRegistrationSG +from quizzi.infrastructure.database.dao.group import GroupDAO +from quizzi.infrastructure.database.dao.user import UserDAO @inject diff --git a/src/trudex/application/bot/user_dialogs/states.py b/src/quizzi/application/bot/user_dialogs/states.py similarity index 100% rename from src/trudex/application/bot/user_dialogs/states.py rename to src/quizzi/application/bot/user_dialogs/states.py diff --git a/src/trudex/application/bot/user_dialogs/take_test.py b/src/quizzi/application/bot/user_dialogs/take_test.py similarity index 97% rename from src/trudex/application/bot/user_dialogs/take_test.py rename to src/quizzi/application/bot/user_dialogs/take_test.py index c0cae97..6052234 100644 --- a/src/trudex/application/bot/user_dialogs/take_test.py +++ b/src/quizzi/application/bot/user_dialogs/take_test.py @@ -9,14 +9,14 @@ from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject -from trudex.application.bot.user_dialogs.states import UserMenuSG, UserTestSG -from trudex.domain.schemas import QuestionType -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO -from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.utils.rate_limiter import PasswordRateLimiter -from trudex.infrastructure.utils.timezone import now_msk_naive +from quizzi.application.bot.user_dialogs.states import UserMenuSG, UserTestSG +from quizzi.domain.schemas import QuestionType +from quizzi.infrastructure.database.dao.test import TestDAO +from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO +from quizzi.infrastructure.database.repo.test import TestRepository +from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository +from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter +from quizzi.infrastructure.utils.timezone import now_msk_naive async def get_state_for_question_type(question_type: str): diff --git a/src/trudex/domain/__init__.py b/src/quizzi/domain/__init__.py similarity index 100% rename from src/trudex/domain/__init__.py rename to src/quizzi/domain/__init__.py diff --git a/src/trudex/domain/schemas.py b/src/quizzi/domain/schemas.py similarity index 100% rename from src/trudex/domain/schemas.py rename to src/quizzi/domain/schemas.py diff --git a/src/trudex/domain/test_parser.py b/src/quizzi/domain/test_parser.py similarity index 100% rename from src/trudex/domain/test_parser.py rename to src/quizzi/domain/test_parser.py diff --git a/src/trudex/infrastructure/__init__.py b/src/quizzi/infrastructure/__init__.py similarity index 100% rename from src/trudex/infrastructure/__init__.py rename to src/quizzi/infrastructure/__init__.py diff --git a/src/trudex/infrastructure/database/__init__.py b/src/quizzi/infrastructure/database/__init__.py similarity index 100% rename from src/trudex/infrastructure/database/__init__.py rename to src/quizzi/infrastructure/database/__init__.py diff --git a/src/trudex/infrastructure/database/config.py b/src/quizzi/infrastructure/database/config.py similarity index 100% rename from src/trudex/infrastructure/database/config.py rename to src/quizzi/infrastructure/database/config.py diff --git a/src/trudex/infrastructure/database/dao/__init__.py b/src/quizzi/infrastructure/database/dao/__init__.py similarity index 100% rename from src/trudex/infrastructure/database/dao/__init__.py rename to src/quizzi/infrastructure/database/dao/__init__.py diff --git a/src/trudex/infrastructure/database/dao/group.py b/src/quizzi/infrastructure/database/dao/group.py similarity index 92% rename from src/trudex/infrastructure/database/dao/group.py rename to src/quizzi/infrastructure/database/dao/group.py index 42a0487..0a7841b 100644 --- a/src/trudex/infrastructure/database/dao/group.py +++ b/src/quizzi/infrastructure/database/dao/group.py @@ -1,9 +1,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from trudex.domain.schemas import Group as DomainGroup -from trudex.infrastructure.database.dto.group import GroupDTO -from trudex.infrastructure.database.models import Group +from quizzi.domain.schemas import Group as DomainGroup +from quizzi.infrastructure.database.dto.group import GroupDTO +from quizzi.infrastructure.database.models import Group class GroupDAO: diff --git a/src/trudex/infrastructure/database/dao/option.py b/src/quizzi/infrastructure/database/dao/option.py similarity index 93% rename from src/trudex/infrastructure/database/dao/option.py rename to src/quizzi/infrastructure/database/dao/option.py index 5c36a8a..3d2c1dd 100644 --- a/src/trudex/infrastructure/database/dao/option.py +++ b/src/quizzi/infrastructure/database/dao/option.py @@ -1,9 +1,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from trudex.domain.schemas import Option as DomainOption -from trudex.infrastructure.database.dto.option import OptionDTO -from trudex.infrastructure.database.models import Option +from quizzi.domain.schemas import Option as DomainOption +from quizzi.infrastructure.database.dto.option import OptionDTO +from quizzi.infrastructure.database.models import Option class OptionDAO: diff --git a/src/trudex/infrastructure/database/dao/question.py b/src/quizzi/infrastructure/database/dao/question.py similarity index 92% rename from src/trudex/infrastructure/database/dao/question.py rename to src/quizzi/infrastructure/database/dao/question.py index ac483c9..25df910 100644 --- a/src/trudex/infrastructure/database/dao/question.py +++ b/src/quizzi/infrastructure/database/dao/question.py @@ -1,10 +1,10 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from trudex.domain.schemas import Question as DomainQuestion -from trudex.domain.schemas import QuestionType -from trudex.infrastructure.database.dto.question import QuestionDTO -from trudex.infrastructure.database.models import Question +from quizzi.domain.schemas import Question as DomainQuestion +from quizzi.domain.schemas import QuestionType +from quizzi.infrastructure.database.dto.question import QuestionDTO +from quizzi.infrastructure.database.models import Question class QuestionDAO: diff --git a/src/trudex/infrastructure/database/dao/test.py b/src/quizzi/infrastructure/database/dao/test.py similarity index 96% rename from src/trudex/infrastructure/database/dao/test.py rename to src/quizzi/infrastructure/database/dao/test.py index 2694653..5917259 100644 --- a/src/trudex/infrastructure/database/dao/test.py +++ b/src/quizzi/infrastructure/database/dao/test.py @@ -4,9 +4,9 @@ from typing import NotRequired, TypedDict, Unpack from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from trudex.domain.schemas import Test as DomainTest -from trudex.infrastructure.database.dto.test import TestDTO -from trudex.infrastructure.database.models import Test +from quizzi.domain.schemas import Test as DomainTest +from quizzi.infrastructure.database.dto.test import TestDTO +from quizzi.infrastructure.database.models import Test class _UNSET: diff --git a/src/trudex/infrastructure/database/dao/test_attempt.py b/src/quizzi/infrastructure/database/dao/test_attempt.py similarity index 93% rename from src/trudex/infrastructure/database/dao/test_attempt.py rename to src/quizzi/infrastructure/database/dao/test_attempt.py index 82d44a0..f7f1ef8 100644 --- a/src/trudex/infrastructure/database/dao/test_attempt.py +++ b/src/quizzi/infrastructure/database/dao/test_attempt.py @@ -3,9 +3,9 @@ from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from trudex.domain.schemas import TestAttempt as DomainTestAttempt -from trudex.infrastructure.database.dto.test_attempt import TestAttemptDTO -from trudex.infrastructure.database.models import TestAttempt +from quizzi.domain.schemas import TestAttempt as DomainTestAttempt +from quizzi.infrastructure.database.dto.test_attempt import TestAttemptDTO +from quizzi.infrastructure.database.models import TestAttempt class TestAttemptDAO: diff --git a/src/trudex/infrastructure/database/dao/user.py b/src/quizzi/infrastructure/database/dao/user.py similarity index 96% rename from src/trudex/infrastructure/database/dao/user.py rename to src/quizzi/infrastructure/database/dao/user.py index 3914f2c..ab2bd0d 100644 --- a/src/trudex/infrastructure/database/dao/user.py +++ b/src/quizzi/infrastructure/database/dao/user.py @@ -3,9 +3,9 @@ from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from trudex.domain.schemas import User as DomainUser -from trudex.infrastructure.database.dto.user import UserDTO -from trudex.infrastructure.database.models import User +from quizzi.domain.schemas import User as DomainUser +from quizzi.infrastructure.database.dto.user import UserDTO +from quizzi.infrastructure.database.models import User class _UNSET: diff --git a/src/trudex/infrastructure/database/dao/user_answer.py b/src/quizzi/infrastructure/database/dao/user_answer.py similarity index 93% rename from src/trudex/infrastructure/database/dao/user_answer.py rename to src/quizzi/infrastructure/database/dao/user_answer.py index 57be605..87b03db 100644 --- a/src/trudex/infrastructure/database/dao/user_answer.py +++ b/src/quizzi/infrastructure/database/dao/user_answer.py @@ -1,9 +1,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from trudex.domain.schemas import UserAnswer as DomainUserAnswer -from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO -from trudex.infrastructure.database.models import UserAnswer +from quizzi.domain.schemas import UserAnswer as DomainUserAnswer +from quizzi.infrastructure.database.dto.user_answer import UserAnswerDTO +from quizzi.infrastructure.database.models import UserAnswer class UserAnswerDAO: diff --git a/src/trudex/infrastructure/database/dto/__init__.py b/src/quizzi/infrastructure/database/dto/__init__.py similarity index 100% rename from src/trudex/infrastructure/database/dto/__init__.py rename to src/quizzi/infrastructure/database/dto/__init__.py diff --git a/src/trudex/infrastructure/database/dto/group.py b/src/quizzi/infrastructure/database/dto/group.py similarity index 75% rename from src/trudex/infrastructure/database/dto/group.py rename to src/quizzi/infrastructure/database/dto/group.py index 767e847..bade435 100644 --- a/src/trudex/infrastructure/database/dto/group.py +++ b/src/quizzi/infrastructure/database/dto/group.py @@ -1,5 +1,5 @@ -from trudex.domain.schemas import Group as DomainGroup -from trudex.infrastructure.database.models import Group as GroupModel +from quizzi.domain.schemas import Group as DomainGroup +from quizzi.infrastructure.database.models import Group as GroupModel class GroupDTO: diff --git a/src/trudex/infrastructure/database/dto/option.py b/src/quizzi/infrastructure/database/dto/option.py similarity index 77% rename from src/trudex/infrastructure/database/dto/option.py rename to src/quizzi/infrastructure/database/dto/option.py index e05397e..b9e513b 100644 --- a/src/trudex/infrastructure/database/dto/option.py +++ b/src/quizzi/infrastructure/database/dto/option.py @@ -1,5 +1,5 @@ -from trudex.domain.schemas import Option as DomainOption -from trudex.infrastructure.database.models import Option as OptionModel +from quizzi.domain.schemas import Option as DomainOption +from quizzi.infrastructure.database.models import Option as OptionModel class OptionDTO: diff --git a/src/trudex/infrastructure/database/dto/question.py b/src/quizzi/infrastructure/database/dto/question.py similarity index 78% rename from src/trudex/infrastructure/database/dto/question.py rename to src/quizzi/infrastructure/database/dto/question.py index a63bbbf..ebb5949 100644 --- a/src/trudex/infrastructure/database/dto/question.py +++ b/src/quizzi/infrastructure/database/dto/question.py @@ -1,5 +1,5 @@ -from trudex.domain.schemas import Question as DomainQuestion -from trudex.infrastructure.database.models import Question as QuestionModel +from quizzi.domain.schemas import Question as DomainQuestion +from quizzi.infrastructure.database.models import Question as QuestionModel class QuestionDTO: diff --git a/src/trudex/infrastructure/database/dto/test.py b/src/quizzi/infrastructure/database/dto/test.py similarity index 85% rename from src/trudex/infrastructure/database/dto/test.py rename to src/quizzi/infrastructure/database/dto/test.py index 05d20bc..796ff30 100644 --- a/src/trudex/infrastructure/database/dto/test.py +++ b/src/quizzi/infrastructure/database/dto/test.py @@ -1,5 +1,5 @@ -from trudex.domain.schemas import Test as DomainTest -from trudex.infrastructure.database.models import Test as TestModel +from quizzi.domain.schemas import Test as DomainTest +from quizzi.infrastructure.database.models import Test as TestModel class TestDTO: diff --git a/src/trudex/infrastructure/database/dto/test_attempt.py b/src/quizzi/infrastructure/database/dto/test_attempt.py similarity index 80% rename from src/trudex/infrastructure/database/dto/test_attempt.py rename to src/quizzi/infrastructure/database/dto/test_attempt.py index 786eb38..0cb01d9 100644 --- a/src/trudex/infrastructure/database/dto/test_attempt.py +++ b/src/quizzi/infrastructure/database/dto/test_attempt.py @@ -1,5 +1,5 @@ -from trudex.domain.schemas import TestAttempt as DomainTestAttempt -from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel +from quizzi.domain.schemas import TestAttempt as DomainTestAttempt +from quizzi.infrastructure.database.models import TestAttempt as TestAttemptModel class TestAttemptDTO: diff --git a/src/trudex/infrastructure/database/dto/user.py b/src/quizzi/infrastructure/database/dto/user.py similarity index 85% rename from src/trudex/infrastructure/database/dto/user.py rename to src/quizzi/infrastructure/database/dto/user.py index cb357e1..8736ea0 100644 --- a/src/trudex/infrastructure/database/dto/user.py +++ b/src/quizzi/infrastructure/database/dto/user.py @@ -1,5 +1,5 @@ -from trudex.domain.schemas import User as DomainUser -from trudex.infrastructure.database.models import User as UserModel +from quizzi.domain.schemas import User as DomainUser +from quizzi.infrastructure.database.models import User as UserModel class UserDTO: diff --git a/src/trudex/infrastructure/database/dto/user_answer.py b/src/quizzi/infrastructure/database/dto/user_answer.py similarity index 80% rename from src/trudex/infrastructure/database/dto/user_answer.py rename to src/quizzi/infrastructure/database/dto/user_answer.py index 58f0ddc..1b4bf58 100644 --- a/src/trudex/infrastructure/database/dto/user_answer.py +++ b/src/quizzi/infrastructure/database/dto/user_answer.py @@ -1,5 +1,5 @@ -from trudex.domain.schemas import UserAnswer as DomainUserAnswer -from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel +from quizzi.domain.schemas import UserAnswer as DomainUserAnswer +from quizzi.infrastructure.database.models import UserAnswer as UserAnswerModel class UserAnswerDTO: diff --git a/src/trudex/infrastructure/database/models.py b/src/quizzi/infrastructure/database/models.py similarity index 99% rename from src/trudex/infrastructure/database/models.py rename to src/quizzi/infrastructure/database/models.py index 1b5c816..cef9774 100644 --- a/src/trudex/infrastructure/database/models.py +++ b/src/quizzi/infrastructure/database/models.py @@ -4,7 +4,7 @@ from typing import final from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship -from trudex.domain.schemas import QuestionType +from quizzi.domain.schemas import QuestionType class Base(DeclarativeBase): diff --git a/src/quizzi/infrastructure/database/repo/__init__.py b/src/quizzi/infrastructure/database/repo/__init__.py new file mode 100644 index 0000000..b715470 --- /dev/null +++ b/src/quizzi/infrastructure/database/repo/__init__.py @@ -0,0 +1,5 @@ +from quizzi.infrastructure.database.repo.test import TestRepository +from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository +from quizzi.infrastructure.database.repo.user import UserRepository + +__all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"] diff --git a/src/trudex/infrastructure/database/repo/test.py b/src/quizzi/infrastructure/database/repo/test.py similarity index 91% rename from src/trudex/infrastructure/database/repo/test.py rename to src/quizzi/infrastructure/database/repo/test.py index b2dcae1..e09b1ca 100644 --- a/src/trudex/infrastructure/database/repo/test.py +++ b/src/quizzi/infrastructure/database/repo/test.py @@ -4,16 +4,16 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from trudex.domain.schemas import Option, Question, Test -from trudex.infrastructure.database.dao.option import OptionDAO -from trudex.infrastructure.database.dao.question import QuestionDAO -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.dto.option import OptionDTO -from trudex.infrastructure.database.dto.question import QuestionDTO -from trudex.infrastructure.database.dto.test import TestDTO -from trudex.infrastructure.database.models import Option as OptionModel -from trudex.infrastructure.database.models import Question as QuestionModel -from trudex.infrastructure.database.models import Test as TestModel +from quizzi.domain.schemas import Option, Question, Test +from quizzi.infrastructure.database.dao.option import OptionDAO +from quizzi.infrastructure.database.dao.question import QuestionDAO +from quizzi.infrastructure.database.dao.test import TestDAO +from quizzi.infrastructure.database.dto.option import OptionDTO +from quizzi.infrastructure.database.dto.question import QuestionDTO +from quizzi.infrastructure.database.dto.test import TestDTO +from quizzi.infrastructure.database.models import Option as OptionModel +from quizzi.infrastructure.database.models import Question as QuestionModel +from quizzi.infrastructure.database.models import Test as TestModel @final @@ -176,7 +176,7 @@ class TestRepository: return new_test async def get_available_tests_for_user(self, user_id: int, user_group: int | None) -> list[Test]: - from trudex.infrastructure.database.models import TestAttempt + from quizzi.infrastructure.database.models import TestAttempt subquery = ( select( diff --git a/src/trudex/infrastructure/database/repo/test_attempt.py b/src/quizzi/infrastructure/database/repo/test_attempt.py similarity index 93% rename from src/trudex/infrastructure/database/repo/test_attempt.py rename to src/quizzi/infrastructure/database/repo/test_attempt.py index 56ae2ce..1686ffb 100644 --- a/src/trudex/infrastructure/database/repo/test_attempt.py +++ b/src/quizzi/infrastructure/database/repo/test_attempt.py @@ -4,14 +4,14 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from trudex.domain.schemas import TestAttempt, UserAnswer -from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO -from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO -from trudex.infrastructure.database.dto.test_attempt import TestAttemptDTO -from trudex.infrastructure.database.dto.user_answer import UserAnswerDTO -from trudex.infrastructure.database.models import TestAttempt as TestAttemptModel -from trudex.infrastructure.database.models import UserAnswer as UserAnswerModel -from trudex.infrastructure.utils.timezone import now_msk_naive +from quizzi.domain.schemas import TestAttempt, UserAnswer +from quizzi.infrastructure.database.dao.test_attempt import TestAttemptDAO +from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO +from quizzi.infrastructure.database.dto.test_attempt import TestAttemptDTO +from quizzi.infrastructure.database.dto.user_answer import UserAnswerDTO +from quizzi.infrastructure.database.models import TestAttempt as TestAttemptModel +from quizzi.infrastructure.database.models import UserAnswer as UserAnswerModel +from quizzi.infrastructure.utils.timezone import now_msk_naive @final @@ -173,7 +173,7 @@ class TestAttemptRepository: } async def get_most_difficult_questions(self, test_id: int, limit: int = 10) -> list[tuple[int, float]]: - from trudex.infrastructure.database.models import Question as QuestionModel + from quizzi.infrastructure.database.models import Question as QuestionModel result = await self.session.execute( select( @@ -209,7 +209,7 @@ class TestAttemptRepository: } async def get_finished_attempts_with_tests(self, user_id: int) -> list[tuple[TestAttempt, str]]: - from trudex.infrastructure.database.models import Test as TestModel + from quizzi.infrastructure.database.models import Test as TestModel result = await self.session.execute( select(TestAttemptModel, TestModel.title) @@ -222,7 +222,7 @@ class TestAttemptRepository: return [(TestAttemptDTO(row[0]).to_domain(), row[1]) for row in rows] async def get_test_attempts_with_users(self, test_id: int) -> list[tuple[TestAttempt, str]]: - from trudex.infrastructure.database.models import User as UserModel + from quizzi.infrastructure.database.models import User as UserModel result = await self.session.execute( select(TestAttemptModel, UserModel.name, UserModel.first_name) diff --git a/src/trudex/infrastructure/database/repo/user.py b/src/quizzi/infrastructure/database/repo/user.py similarity index 89% rename from src/trudex/infrastructure/database/repo/user.py rename to src/quizzi/infrastructure/database/repo/user.py index 681e0be..ff1d04f 100644 --- a/src/trudex/infrastructure/database/repo/user.py +++ b/src/quizzi/infrastructure/database/repo/user.py @@ -3,10 +3,10 @@ from typing import final from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from trudex.domain.schemas import User -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.database.dto.user import UserDTO -from trudex.infrastructure.database.models import User as UserModel +from quizzi.domain.schemas import User +from quizzi.infrastructure.database.dao.user import UserDAO +from quizzi.infrastructure.database.dto.user import UserDTO +from quizzi.infrastructure.database.models import User as UserModel @final diff --git a/src/trudex/infrastructure/di.py b/src/quizzi/infrastructure/di.py similarity index 76% rename from src/trudex/infrastructure/di.py rename to src/quizzi/infrastructure/di.py index 4b23fe0..6a8053b 100644 --- a/src/trudex/infrastructure/di.py +++ b/src/quizzi/infrastructure/di.py @@ -5,20 +5,20 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from dishka import AsyncContainer, Provider, Scope, provide from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from trudex.infrastructure.database.config import new_session_maker -from trudex.infrastructure.database.dao.group import GroupDAO -from trudex.infrastructure.database.dao.option import OptionDAO -from trudex.infrastructure.database.dao.question import QuestionDAO -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.database.dao.test_attempt import TestAttemptDAO -from trudex.infrastructure.database.dao.user import UserDAO -from trudex.infrastructure.database.dao.user_answer import UserAnswerDAO -from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.database.repo.user import UserRepository -from trudex.infrastructure.scheduling.tasks import deactivate_expired_tests -from trudex.infrastructure.utils.config import Config -from trudex.infrastructure.utils.rate_limiter import PasswordRateLimiter +from quizzi.infrastructure.database.config import new_session_maker +from quizzi.infrastructure.database.dao.group import GroupDAO +from quizzi.infrastructure.database.dao.option import OptionDAO +from quizzi.infrastructure.database.dao.question import QuestionDAO +from quizzi.infrastructure.database.dao.test import TestDAO +from quizzi.infrastructure.database.dao.test_attempt import TestAttemptDAO +from quizzi.infrastructure.database.dao.user import UserDAO +from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO +from quizzi.infrastructure.database.repo.test import TestRepository +from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository +from quizzi.infrastructure.database.repo.user import UserRepository +from quizzi.infrastructure.scheduling.tasks import deactivate_expired_tests +from quizzi.infrastructure.utils.config import Config +from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter class DatabaseProvider(Provider): diff --git a/src/trudex/infrastructure/scheduling/__init__.py b/src/quizzi/infrastructure/scheduling/__init__.py similarity index 100% rename from src/trudex/infrastructure/scheduling/__init__.py rename to src/quizzi/infrastructure/scheduling/__init__.py diff --git a/src/trudex/infrastructure/scheduling/tasks.py b/src/quizzi/infrastructure/scheduling/tasks.py similarity index 82% rename from src/trudex/infrastructure/scheduling/tasks.py rename to src/quizzi/infrastructure/scheduling/tasks.py index 85f91f7..da94a34 100644 --- a/src/trudex/infrastructure/scheduling/tasks.py +++ b/src/quizzi/infrastructure/scheduling/tasks.py @@ -2,8 +2,8 @@ import logging from dishka import AsyncContainer -from trudex.infrastructure.database.dao.test import TestDAO -from trudex.infrastructure.utils.timezone import now_msk_naive +from quizzi.infrastructure.database.dao.test import TestDAO +from quizzi.infrastructure.utils.timezone import now_msk_naive logger = logging.getLogger(__name__) diff --git a/src/trudex/infrastructure/utils/__init__.py b/src/quizzi/infrastructure/utils/__init__.py similarity index 100% rename from src/trudex/infrastructure/utils/__init__.py rename to src/quizzi/infrastructure/utils/__init__.py diff --git a/src/trudex/infrastructure/utils/bot_commands.py b/src/quizzi/infrastructure/utils/bot_commands.py similarity index 90% rename from src/trudex/infrastructure/utils/bot_commands.py rename to src/quizzi/infrastructure/utils/bot_commands.py index d017f64..7cbf22b 100644 --- a/src/trudex/infrastructure/utils/bot_commands.py +++ b/src/quizzi/infrastructure/utils/bot_commands.py @@ -1,8 +1,8 @@ from aiogram import Bot from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeChat -from trudex.infrastructure.database.repo.user import UserRepository -from trudex.infrastructure.utils.config import Config +from quizzi.infrastructure.database.repo.user import UserRepository +from quizzi.infrastructure.utils.config import Config async def setup_bot_commands(bot: Bot, config: Config, user_repo: UserRepository) -> None: diff --git a/src/trudex/infrastructure/utils/broadcast.py b/src/quizzi/infrastructure/utils/broadcast.py similarity index 97% rename from src/trudex/infrastructure/utils/broadcast.py rename to src/quizzi/infrastructure/utils/broadcast.py index 8e775d7..e94f3e2 100644 --- a/src/trudex/infrastructure/utils/broadcast.py +++ b/src/quizzi/infrastructure/utils/broadcast.py @@ -11,7 +11,7 @@ from aiogram.exceptions import ( TelegramRetryAfter, ) -from trudex.infrastructure.database.dao.user import UserDAO +from quizzi.infrastructure.database.dao.user import UserDAO logger = logging.getLogger(__name__) diff --git a/src/trudex/infrastructure/utils/config.py b/src/quizzi/infrastructure/utils/config.py similarity index 100% rename from src/trudex/infrastructure/utils/config.py rename to src/quizzi/infrastructure/utils/config.py diff --git a/src/trudex/infrastructure/utils/qr_generator.py b/src/quizzi/infrastructure/utils/qr_generator.py similarity index 100% rename from src/trudex/infrastructure/utils/qr_generator.py rename to src/quizzi/infrastructure/utils/qr_generator.py diff --git a/src/trudex/infrastructure/utils/rate_limiter.py b/src/quizzi/infrastructure/utils/rate_limiter.py similarity index 100% rename from src/trudex/infrastructure/utils/rate_limiter.py rename to src/quizzi/infrastructure/utils/rate_limiter.py diff --git a/src/trudex/infrastructure/utils/test_id_to_hash.py b/src/quizzi/infrastructure/utils/test_id_to_hash.py similarity index 100% rename from src/trudex/infrastructure/utils/test_id_to_hash.py rename to src/quizzi/infrastructure/utils/test_id_to_hash.py diff --git a/src/trudex/infrastructure/utils/timezone.py b/src/quizzi/infrastructure/utils/timezone.py similarity index 100% rename from src/trudex/infrastructure/utils/timezone.py rename to src/quizzi/infrastructure/utils/timezone.py diff --git a/src/trudex/infrastructure/database/repo/__init__.py b/src/trudex/infrastructure/database/repo/__init__.py deleted file mode 100644 index 3d25dad..0000000 --- a/src/trudex/infrastructure/database/repo/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from trudex.infrastructure.database.repo.test import TestRepository -from trudex.infrastructure.database.repo.test_attempt import TestAttemptRepository -from trudex.infrastructure.database.repo.user import UserRepository - -__all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"] diff --git a/uv.lock b/uv.lock index 6ff0ab6..14ba0c3 100644 --- a/uv.lock +++ b/uv.lock @@ -717,6 +717,55 @@ pil = [ { name = "pillow" }, ] +[[package]] +name = "quizzi" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiogram" }, + { name = "aiogram-dialog" }, + { name = "alembic" }, + { name = "apscheduler" }, + { name = "asyncpg" }, + { name = "dishka" }, + { name = "httpx" }, + { name = "json5" }, + { name = "pycryptodome" }, + { name = "pydantic" }, + { name = "qrcode", extra = ["pil"] }, + { name = "sqlalchemy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "isort" }, + { name = "ruff" }, + { name = "watchfiles" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiogram", specifier = ">=3.23.0" }, + { name = "aiogram-dialog", specifier = ">=2.4.0" }, + { name = "alembic", specifier = ">=1.17.2" }, + { name = "apscheduler", specifier = ">=3.10.4" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "dishka", specifier = ">=1.7.2" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "json5", specifier = ">=0.13.0" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "pydantic", specifier = ">=2.10.5" }, + { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, + { name = "sqlalchemy", specifier = ">=2.0.45" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "isort", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.14.10" }, + { name = "watchfiles", specifier = ">=1.1.1" }, +] + [[package]] name = "ruff" version = "0.14.10" @@ -770,55 +819,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, ] -[[package]] -name = "trudex" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "aiogram" }, - { name = "aiogram-dialog" }, - { name = "alembic" }, - { name = "apscheduler" }, - { name = "asyncpg" }, - { name = "dishka" }, - { name = "httpx" }, - { name = "json5" }, - { name = "pycryptodome" }, - { name = "pydantic" }, - { name = "qrcode", extra = ["pil"] }, - { name = "sqlalchemy" }, -] - -[package.dev-dependencies] -dev = [ - { name = "isort" }, - { name = "ruff" }, - { name = "watchfiles" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiogram", specifier = ">=3.23.0" }, - { name = "aiogram-dialog", specifier = ">=2.4.0" }, - { name = "alembic", specifier = ">=1.17.2" }, - { name = "apscheduler", specifier = ">=3.10.4" }, - { name = "asyncpg", specifier = ">=0.31.0" }, - { name = "dishka", specifier = ">=1.7.2" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "json5", specifier = ">=0.13.0" }, - { name = "pycryptodome", specifier = ">=3.23.0" }, - { name = "pydantic", specifier = ">=2.10.5" }, - { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, - { name = "sqlalchemy", specifier = ">=2.0.45" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "isort", specifier = ">=7.0.0" }, - { name = "ruff", specifier = ">=0.14.10" }, - { name = "watchfiles", specifier = ">=1.1.1" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" From 1eb758d8ad1deb520479b46b22c75225b9d985b1 Mon Sep 17 00:00:00 2001 From: kolo Date: Tue, 6 Jan 2026 18:08:29 +0300 Subject: [PATCH 57/57] update --- justfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index e8c867f..7f3d791 100644 --- a/justfile +++ b/justfile @@ -2,10 +2,10 @@ set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] set shell := ["bash", "-c"] dev: - watchfiles --filter python ".venv/Scripts/python -m trudex.application" src + watchfiles --filter python ".venv/Scripts/python -m quizzi.application" src run: - python -m trudex + python -m quizzi.application lint: ruff check src