diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..5f9b3b6 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,87 @@ +name: Check project in all branches + +on: + workflow_call: + push: + branches-ignore: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + POSTGRES_USER: inclusive_dance + POSTGRES_PASSWORD: 3d88lacj9327s + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_DB: inclusive_dance_bot + + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: 389jf928fhf10 + REDIS_DB: '3' + + PROJECT_PATH: ./inclusive_dance_bot/ + TEST_PATH: ./tests/ + +jobs: + checking: + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup python 3.11 + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install -U pip poetry + poetry config virtualenvs.create false + poetry install + + - name: Run flake8 + run: flake8 --max-line-length 88 --format=default ${{ env.PROJECT_PATH }} 2>&1 | tee flake8.txt + + - name: Run black + run: black ${{ env.PROJECT_PATH }} --check + + - name: Run bandit + run: bandit -r -ll -iii ${{ env.PROJECT_PATH }} -f json -o ./bandit.json + + - name: Run mypy + run: mypy --config-file ./pyproject.toml ${{ env.PROJECT_PATH }} + + test: + name: Run service tests with pytest + runs-on: ubuntu-22.04 + container: python:3.11-slim + needs: checking + services: + postgres: + image: postgres:15 + env: + TZ: UTC + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install dependencies + run: | + python -m pip install -U pip poetry + poetry config virtualenvs.create false + poetry install + + - name: Run pytest + run: python -m pytest ${{ env.TEST_PATH }} --junitxml=./junit.xml --cov=${{ env.PROJECT_PATH }} --cov-report=xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 52922a5..2eeff45 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ default_language_version: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-added-large-files - id: check-ast @@ -33,7 +33,7 @@ repos: - id: text-unicode-replacement-char - repo: https://github.com/asottile/pyupgrade - rev: v3.11.1 + rev: v3.15.0 hooks: - id: pyupgrade args: [--py311-plus] @@ -49,7 +49,7 @@ repos: - --ignore-init-module-imports - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 23.10.1 hooks: - id: black language_version: python3.11 @@ -87,7 +87,7 @@ repos: - id: docker-compose-check - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.10.0 + rev: v2.11.0 hooks: - id: pretty-format-yaml args: [--autofix, --indent, '2', --offset, '2'] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..02408e0 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +PROJECT_PATH = ./inclusive_dance_bot/ +TEST_PATH = ./tests/ + +HELP_FUN = \ + %help; while(<>){push@{$$help{$$2//'options'}},[$$1,$$3] \ + if/^([\w-_]+)\s*:.*\#\#(?:@(\w+))?\s(.*)$$/}; \ + print"$$_:\n", map" $$_->[0]".(" "x(20-length($$_->[0])))."$$_->[1]\n",\ + @{$$help{$$_}},"\n" for keys %help; \ + +help: ##@Help Show this help + @echo -e "Usage: make [target] ...\n" + @perl -e '$(HELP_FUN)' $(MAKEFILE_LIST) + +lint-ci: flake black bandit mypy ##@Linting Run all linters in CI + +flake: ##@Linting Run flake8 + flake8 --max-line-length 88 --format=default $(PROJECT_PATH) 2>&1 | tee flake8.txt + +black: ##@Linting Run black + black $(PROJECT_PATH) --check + +bandit: ##@Linting Run bandit + bandit -r -ll -iii $(PROJECT_PATH) -f json -o ./bandit.json + +mypy: ##@Linting Run mypy + mypy --config-file ./pyproject.toml $(PROJECT_PATH) + +test: ##@Test Run tests with pytest + pytest -vvx $(TEST_PATH) + +test-ci: ##@Test Run tests with pytest and coverage in CI + pytest $(TEST_PATH) --junitxml=./junit.xml --cov=$(PROJECT_PATH) --cov-report=xml + + +local: ##@Develop Run db and redis containers + docker-compose -f docker-compose.dev.yaml up db redis -d + +local_down: ##@Develop Stop containers with delete volumes + docker-compose -f docker-compose.dev.yaml down -v \ No newline at end of file diff --git a/README.md b/README.md index 8e0fb42..2de33dd 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,79 @@ ## Stack + +

+ Python + Docker + Postgres + Redis + GitHub + GitHub Actions +

+
+ ## Description +Telegram bot for informing the audience and feedback. You can fully control the bot from the admin menu: change the displayed menus and messages, collect feedback and send out announcements on a schedule. + +The bot was created completely asynchronous using [aiogram 3.0+](https://aiogram.dev/), [aiogram-dialogs 2.0+](https://aiogram-dialog.readthedocs.io/en/2.0.0/), [aiomisc](https://aiomisc.readthedocs.io/) and [sqalchemy](https://www.sqlalchemy.org/) libraries. + +Initially, this project was developed to help the organization of [Inclusive Dance](https://inclusive-dance.ru/). + ## Using -### Environment Variables +### Settings + +Environment variables are used to configure the bot for connecting to Telegram, Postgres and Redis. ```bash +TELEGRAM_BOT_TOKEN # your bot token +TELEGRAM_BOT_ADMIN_IDS # Superadmin IDs, who can appoint other admins + +DEBUG # Flag for debugging (using in sqlalchemy engine for echo) + +POSTGRES_HOST # database host +POSTGRES_PORT # database port +POSTGRES_USER # database user +POSTGRES_PASSWORD # database password +POSTGRES_DB # database name + +REDIS_HOST # redis host +REDIS_PORT # redis port +REDIS_PASSWORD # redis password +REDIS_DB # redis db +``` + +### Docker + +You can get image from public Docker Hub from [here](https://hub.docker.com/r/andytakker/inclusive_dance_bot). + +```bash +docker pull andytakker/inclusive_dance_bot ``` -## Running +### Local +Before you need to create env and install dependencies + +```bash +python3.11 -m venv .venv +source .venv/bin/activate +pip install -U pip poetry +poetry install +``` + +For start the bot local you can use poetry + +```bash +poetry run bot +``` + +### Test + +For the main functions of the bot, tests are written using pytest. For running + +```bash +pytest -vx ./tests/ +``` diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..1a37c6e --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,24 @@ +version: '3.8' +services: + db: + image: postgres:15 + restart: unless-stopped + environment: + POSTGRES_USER: $POSTGRES_USER + POSTGRES_PASSWORD: $POSTGRES_PASSWORD + POSTGRES_DB: $POSTGRES_DB + ports: + - 5432:5432 + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD} + ports: + - 6379:6379 + +volumes: + postgres_data: + driver: local diff --git a/docker-compose.yaml b/docker-compose.yaml index d5a2332..590f76f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,8 +16,6 @@ services: image: redis restart: unless-stopped command: redis-server --requirepass ${REDIS_PASSWORD} - ports: - - 6379:6379 bot: image: andytakker/inclusive_dance_bot:latest diff --git a/inclusive_dance_bot/__main__.py b/inclusive_dance_bot/__main__.py new file mode 100644 index 0000000..1fbc71c --- /dev/null +++ b/inclusive_dance_bot/__main__.py @@ -0,0 +1,30 @@ +import logging + +from aiomisc import Service, entrypoint +from aiomisc_log import basic_config + +from inclusive_dance_bot.config import Settings +from inclusive_dance_bot.deps import config_deps +from inclusive_dance_bot.services.bot import AiogramBotService +from inclusive_dance_bot.services.periodic import PeriodicMailingService + +log = logging.getLogger(__name__) + + +def main() -> None: + settings = Settings() + basic_config() + config_deps(app_settings=settings) + services: list[Service] = [ + AiogramBotService(), + PeriodicMailingService( + interval=settings.PERIODIC_INTERVAL, + delay=0, + gap=settings.MAILING_GAP, + ), + ] + with entrypoint( + *services, + ) as loop: + log.info("Entrypoint started") + loop.run_forever() diff --git a/inclusive_dance_bot/bot/__main__.py b/inclusive_dance_bot/bot/__main__.py deleted file mode 100644 index d848b6f..0000000 --- a/inclusive_dance_bot/bot/__main__.py +++ /dev/null @@ -1,61 +0,0 @@ -import asyncio -import logging - -from aiogram import Dispatcher -from aiogram_dialog import setup_dialogs - -from inclusive_dance_bot.bot.dialogs import register_dialogs -from inclusive_dance_bot.bot.factory import create_bot, create_storage -from inclusive_dance_bot.bot.middlewares.settings import SettingsMiddleware -from inclusive_dance_bot.bot.middlewares.storage import StorageMiddleware -from inclusive_dance_bot.bot.middlewares.uow import UowMiddleware -from inclusive_dance_bot.bot.ui_commands import set_ui_commands -from inclusive_dance_bot.config import Settings -from inclusive_dance_bot.db.factory import create_engine, create_session_factory -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.services.storage import Storage - -log = logging.getLogger(__name__) - - -async def start_bot(settings: Settings) -> None: - logging.basicConfig( - level=logging.INFO if not settings.DEBUG else logging.DEBUG, - format="%(asctime)s | %(name)-12s | %(levelname)-8s | %(message)s", - datefmt="%H:%M:%S %d.%m.%Y", - ) - log.info("Init bot") - if settings.TELEGRAM_BOT_TOKEN.get_secret_value() == "default": - raise ValueError("You should set env TELEGRAM_BOT_TOKEN") - engine = create_engine(connection_uri=settings.build_db_connection_uri()) - session_factory = create_session_factory(engine=engine) - uow = UnitOfWork(sessionmaker=session_factory) - storage = Storage(uow=uow) - async with uow: - await storage.refresh_all() - - bot = create_bot(settings=settings) - bot_storage = create_storage(settings=settings) - dp = Dispatcher(storage=bot_storage) - dp.update.outer_middleware(SettingsMiddleware(settings=settings)) - dp.update.outer_middleware(UowMiddleware(uow=uow)) - dp.update.outer_middleware(StorageMiddleware(storage=storage)) - register_dialogs(dp) - setup_dialogs(dp) - - await set_ui_commands(bot) - await bot.delete_webhook(drop_pending_updates=True) - try: - await dp.start_polling(bot) - finally: - await engine.dispose() - log.info("Stopped") - - -def main() -> None: - settings = Settings() - asyncio.run(start_bot(settings=settings)) - - -if __name__ == "__main__": - main() diff --git a/inclusive_dance_bot/bot/dialogs/__init__.py b/inclusive_dance_bot/bot/dialogs/__init__.py index d257aa1..9b7dcd3 100644 --- a/inclusive_dance_bot/bot/dialogs/__init__.py +++ b/inclusive_dance_bot/bot/dialogs/__init__.py @@ -1,20 +1,13 @@ -from aiogram import F, Router +from aiogram import Router from aiogram.filters import Command -from inclusive_dance_bot.bot.dialogs.admins import register_admin_dialogs +from inclusive_dance_bot.bot.dialogs import admins, users from inclusive_dance_bot.bot.dialogs.commands import start_command -from inclusive_dance_bot.bot.dialogs.users import register_user_dialogs from inclusive_dance_bot.bot.ui_commands import Commands def register_dialogs(router: Router) -> None: dialog_router = Router() - - # admin_router = register_admin_dialogs() - # dialog_router.include_router(admin_router) - - user_router = register_user_dialogs() - dialog_router.include_router(user_router) - + dialog_router.include_routers(admins.dialog_router, users.dialog_router) dialog_router.message(Command(Commands.START))(start_command) router.include_router(dialog_router) diff --git a/inclusive_dance_bot/bot/dialogs/admins/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/__init__.py index 99ffcbe..e218f48 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/__init__.py +++ b/inclusive_dance_bot/bot/dialogs/admins/__init__.py @@ -1,10 +1,20 @@ from aiogram import Router -from inclusive_dance_bot.bot.dialogs.admins.main_menu import AdminMainMenuDialog +from inclusive_dance_bot.bot.dialogs.admins import ( + feedbacks, + mailings, + main_menu, + manage_admins, + submenu, + url, +) - -def register_admin_dialogs() -> Router: - dialog_router = Router(name="admin_router") - dialog_router.include_router(AdminMainMenuDialog()) - - return dialog_router +dialog_router = Router(name="admin_router") +dialog_router.include_routers( + feedbacks.router, + main_menu.dialog, + submenu.router, + url.router, + manage_admins.router, + mailings.router, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/__init__.py new file mode 100644 index 0000000..01cbd31 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/__init__.py @@ -0,0 +1,9 @@ +from aiogram import Router + +from inclusive_dance_bot.bot.dialogs.admins.feedbacks import answer, items + +router = Router() +router.include_routers( + items.dialog, + answer.dialog, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/__init__.py new file mode 100644 index 0000000..ac97b58 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/__init__.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.feedbacks.answer import input_message + +dialog = Dialog( + input_message.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/input_message.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/input_message.py new file mode 100644 index 0000000..062e7f2 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/input_message.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Window + +from inclusive_dance_bot.bot.dialogs.admins.states import FeedbackAsnwerSG + +window = Window( + state=FeedbackAsnwerSG.input_message, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/__init__.py new file mode 100644 index 0000000..0614ca0 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/__init__.py @@ -0,0 +1,8 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.feedbacks.items import archive, new + +dialog = Dialog( + new.window, + archive.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/archive.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/archive.py new file mode 100644 index 0000000..86057df --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/archive.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Window + +from inclusive_dance_bot.bot.dialogs.admins.states import FeedbackItemsSG + +window = Window( + state=FeedbackItemsSG.archive, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/new.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/new.py new file mode 100644 index 0000000..928dc2a --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/new.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Window + +from inclusive_dance_bot.bot.dialogs.admins.states import FeedbackItemsSG + +window = Window( + state=FeedbackItemsSG.new, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/__init__.py new file mode 100644 index 0000000..1cc5667 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/__init__.py @@ -0,0 +1,10 @@ +from aiogram import Router + +from inclusive_dance_bot.bot.dialogs.admins.mailings import cancel, create, read + +router = Router() +router.include_routers( + read.dialog, + cancel.dialog, + create.dialog, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/__init__.py new file mode 100644 index 0000000..c157dbb --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/__init__.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.mailings.cancel import confirm + +dialog = Dialog( + confirm.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/confirm.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/confirm.py new file mode 100644 index 0000000..5ef84a7 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/confirm.py @@ -0,0 +1,30 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, ShowMode, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CancelMailingSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.enums import MailingStatus +from inclusive_dance_bot.logic.mailing import update_mailing_by_id + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + mailing_id = dialog_manager.start_data["mailing_id"] + await update_mailing_by_id( + uow=uow, mailing_id=mailing_id, status=MailingStatus.CANCELLED + ) + dialog_manager.show_mode = ShowMode.SEND + await c.bot.send_message(c.from_user.id, text="Рассылка была отменена") # type: ignore[union-attr] + await dialog_manager.done() + + +window = Window( + Const("Подтверждаете отмену рассылки?"), + Row(CANCEL, Button(id="confirm", text=Const("Да"), on_click=on_click)), + state=CancelMailingSG.confirm, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/__init__.py new file mode 100644 index 0000000..7d75c13 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/__init__.py @@ -0,0 +1,21 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.mailings.create import ( + choose_user_types, + confirm, + input_content, + input_date, + input_is_immediately, + input_time, + input_title, +) + +dialog = Dialog( + input_title.window, + input_content.window, + choose_user_types.window, + input_is_immediately.window, + input_date.window, + input_time.window, + confirm.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/choose_user_types.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/choose_user_types.py new file mode 100644 index 0000000..f742a3f --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/choose_user_types.py @@ -0,0 +1,50 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Multiselect, Next, ScrollingGroup +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.logic.storage import Storage + + +async def get_user_types_data( + dialog_manager: DialogManager, storage: Storage, **kwargs: Any +) -> dict[str, Any]: + user_types = await storage.get_user_types() + return { + "user_types": user_types.values(), + } + + +async def save_data( + c: CallbackQuery, + widget: Button, + manager: DialogManager, +) -> None: + ids: list[int] = manager.find("s_user_types").get_checked() # type: ignore[union-attr] + manager.dialog_data["user_types"] = ids + + +window = Window( + Format("Выберите типы пользователей которым нужно отпраавить сообщение"), + ScrollingGroup( + Multiselect( + checked_text=Format("\U00002705 {item.name}"), + unchecked_text=Format("{item.name}"), + id="s_user_types", + item_id_getter=lambda x: x.id, + type_factory=int, + items="user_types", + ), + id="user_types", + width=1, + height=10, + ), + BACK, + Next(text=Const("Далее"), on_click=save_data), + state=CreateMailingSG.user_types, + getter=get_user_types_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/confirm.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/confirm.py new file mode 100644 index 0000000..7f17dd6 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/confirm.py @@ -0,0 +1,89 @@ +from datetime import date, datetime, time, timedelta, timezone +from typing import Any + +from aiogram import F +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, ShowMode, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.mailing import save_mailing +from inclusive_dance_bot.logic.storage import Storage + + +def parse_dt( + t: str | None, + d: str | None, +) -> datetime | None: + if t and d: + return datetime.combine( + date.fromisoformat(d), + time.fromisoformat(t), + tzinfo=timezone(offset=timedelta(hours=3)), + ) + return None + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + t = dialog_manager.dialog_data.get("time") + d = dialog_manager.dialog_data.get("date") + scheduled_at = parse_dt(t=t, d=d) + await save_mailing( + uow=uow, + bot=dialog_manager.middleware_data["bot"], + title=dialog_manager.dialog_data["title"], + content=dialog_manager.dialog_data["content"], + scheduled_at=None if not scheduled_at else scheduled_at, + user_type_ids=dialog_manager.dialog_data["user_types"], + ) + dialog_manager.show_mode = ShowMode.SEND + await c.bot.send_message(c.from_user.id, text="Рассылка создана") # type: ignore[union-attr] + await dialog_manager.done() + + +async def get_mailing_data( + dialog_manager: DialogManager, storage: Storage, **kwargs: Any +) -> dict[str, Any]: + user_types = filter( + lambda ut: ut.id in dialog_manager.dialog_data["user_types"], + (await storage.get_user_types()).values(), + ) + t = dialog_manager.dialog_data.get("time") + d = dialog_manager.dialog_data.get("date") + dt = parse_dt(t=t, d=d) + return { + "title": dialog_manager.dialog_data["title"], + "content": dialog_manager.dialog_data["content"], + "user_types": ", ".join(map(lambda x: x.name, user_types)), + "time": t, + "date": d, + "dt": dt, + "is_immediately": t is None or d is None, + } + + +window = Window( + Format( + "Рассылка\n\n" + "Заголовок: {title}\n" + "Основной текст: {content}\n" + "Целевые пользователи: {user_types}\n" + ), + Format( + "Дата и время отправки: {dt:%H:%M %d.%m.%Y}", + when=~F["is_immediately"], + ), + Format("Будет отправлено немедлено", when=F["is_immediately"]), + Row( + BACK, + Button(text=Const("Сохранить"), id="save", on_click=on_click), + ), + state=CreateMailingSG.confirm, + getter=get_mailing_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_content.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_content.py new file mode 100644 index 0000000..534e2f2 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_content.py @@ -0,0 +1,32 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import ManagedTextInput, TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.bot.dialogs.utils.validators import validate_length + +MESSAGE = "Введите сообщение\n\nОграничение - 3072 символов" + + +async def on_success( + message: Message, + widget: ManagedTextInput[str], + dialog_manager: DialogManager, + value: str, +) -> None: + dialog_manager.dialog_data["content"] = message.html_text + await dialog_manager.next() + + +window = Window( + Const(MESSAGE), + TextInput( + id="input_content", + on_success=on_success, # type: ignore[arg-type] + type_factory=validate_length(3072), + ), + BACK, + state=CreateMailingSG.content, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_date.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_date.py new file mode 100644 index 0000000..c2f3696 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_date.py @@ -0,0 +1,34 @@ +from datetime import date + +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import ManagedTextInput, TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.bot.dialogs.utils.validators import validate_date + +MESSAGE = "Введите дату отправки сообщения\n\nФормат: ДД.ММ.ГГГГ" + + +async def on_success( + message: Message, + widget: ManagedTextInput[str], + dialog_manager: DialogManager, + value: date, +) -> None: + dialog_manager.dialog_data["date"] = value.isoformat() + await dialog_manager.next() + + +window = Window( + Const(MESSAGE), + TextInput( + id="input_date", + on_success=on_success, # type: ignore[arg-type] + type_factory=validate_date(), + ), + BACK, + state=CreateMailingSG.date, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_is_immediately.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_is_immediately.py new file mode 100644 index 0000000..ee0f3ca --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_is_immediately.py @@ -0,0 +1,18 @@ +from aiogram_dialog import Window +from aiogram_dialog.widgets.kbd import Next, Row, SwitchTo +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK + +window = Window( + Const("Когда отправить?"), + Row( + Next(text=Const("По расписанию")), + SwitchTo( + text=Const("Немедленно"), id="send_mailing", state=CreateMailingSG.confirm + ), + ), + BACK, + state=CreateMailingSG.is_immediately, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_time.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_time.py new file mode 100644 index 0000000..ffa1838 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_time.py @@ -0,0 +1,34 @@ +from datetime import time + +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import ManagedTextInput, TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.bot.dialogs.utils.validators import validate_time + +MESSAGE = "Введите время отправки сообщения по Москве\n\nФормат: ЧЧ:ММ" + + +async def on_success( + message: Message, + widget: ManagedTextInput[str], + dialog_manager: DialogManager, + value: time, +) -> None: + dialog_manager.dialog_data["time"] = value.isoformat(timespec="minutes") + await dialog_manager.next() + + +window = Window( + Const(MESSAGE), + TextInput( + id="input_time", + on_success=on_success, # type: ignore[arg-type] + type_factory=validate_time(), + ), + BACK, + state=CreateMailingSG.time, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_title.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_title.py new file mode 100644 index 0000000..6eb152b --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_title.py @@ -0,0 +1,32 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import ManagedTextInput, TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.bot.dialogs.utils.validators import validate_length + +MESSAGE = "Введите тему сообщения.\n\nОграничение - 512 символов" + + +async def on_success( + message: Message, + widget: ManagedTextInput[str], + dialog_manager: DialogManager, + value: str, +) -> None: + dialog_manager.dialog_data["title"] = message.html_text + await dialog_manager.next() + + +window = Window( + Const(MESSAGE), + TextInput( + id="input_title", + on_success=on_success, # type: ignore[arg-type] + type_factory=validate_length(512), + ), + CANCEL, + state=CreateMailingSG.title, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/read/__init__.py new file mode 100644 index 0000000..f4ad2ac --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/read/__init__.py @@ -0,0 +1,9 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.mailings.read import item, items, menu + +dialog = Dialog( + menu.window, + items.window, + item.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/item.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/read/item.py new file mode 100644 index 0000000..4509b22 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/read/item.py @@ -0,0 +1,65 @@ +from datetime import timedelta +from typing import Any + +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import CancelMailingSG, MailingsSG +from inclusive_dance_bot.bot.dialogs.utils import start_with_data +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.enums import MailingStatus + +TEMPLATE_MESSAGE = """ +Рассылка + +Тема: {title} +Создано: {created_at} +Отправка в: {scheduled_at} +Статус: {status} +Аудитория: {user_types} + +{content} +""" + + +async def get_mailing( + uow: UnitOfWork, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + mailing_id = dialog_manager.dialog_data["mailing_id"] + mailing = await uow.mailings.get_by_id(mailing_id=mailing_id) + + scheduled_at = "Отправлено сразу" + if mailing.scheduled_at: + scheduled_at = (mailing.scheduled_at + timedelta(hours=3)).strftime( + "%H:%M:%S %d.%m.%Y" + ) + + created_at = (mailing.created_at + timedelta(hours=3)).strftime("%H:%M:%S %d.%m.%Y") + return { + "title": mailing.title, + "content": mailing.content, + "scheduled_at": scheduled_at, + "status": mailing.status, + "created_at": created_at, + "user_types": ", ".join(ut.name for ut in mailing.user_types), + } + + +def when_(data: dict, widget: Button, dialog_manager: DialogManager) -> bool: + return data.get("status") == MailingStatus.SCHEDULED + + +window = Window( + Format(TEMPLATE_MESSAGE), + Button( + Const("Отменить отправку"), + id="change_url_value", + on_click=start_with_data(state=CancelMailingSG.confirm, field="mailing_id"), + when=when_, # type: ignore[arg-type] + ), + BACK, + state=MailingsSG.item, + getter=get_mailing, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/items.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/read/items.py new file mode 100644 index 0000000..bd5aa16 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/read/items.py @@ -0,0 +1,65 @@ +from typing import Any + +from aiogram import F +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format, List + +from inclusive_dance_bot.bot.dialogs.admins.states import MailingsSG +from inclusive_dance_bot.bot.dialogs.utils import sync_scroll +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.db.uow.main import UnitOfWork + +MAILING_TEMPLATE = """\ +[{pos}] {item.title} +Создано: {item.created_at:%H:%M %d.%m.%Y} +Статус: {item.status} +""" + + +async def get_mailings_data( + uow: UnitOfWork, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + if dialog_manager.dialog_data.get("is_sent"): + mailings = await uow.mailings.get_archive_mailings() + else: + mailings = await uow.mailings.get_new_mailings() + return {"mailings": mailings} + + +async def open_mailing( + c: CallbackQuery, widget: Button, dialog_manager: DialogManager, mailing_id: int +) -> None: + dialog_manager.dialog_data["mailing_id"] = mailing_id + await dialog_manager.next() + + +window = Window( + Const("Запланированные рассылки\n", when=~F["is_sent"]), + Const("Архив рассылок\n", when=F["is_sent"]), + List( + Format(MAILING_TEMPLATE), + items="mailings", + id="scroll_message_mailings", + page_size=10, + ), + ScrollingGroup( + Select( + Format("{pos}"), + id="s_mailing", + item_id_getter=lambda x: x.id, + items="mailings", + on_click=open_mailing, # type: ignore[arg-type] + type_factory=int, + ), + id="mailings", + hide_on_single_page=True, + height=5, + width=2, + on_page_changed=sync_scroll("scroll_message_mailings"), + ), + BACK, + getter=get_mailings_data, + state=MailingsSG.items, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/menu.py b/inclusive_dance_bot/bot/dialogs/admins/mailings/read/menu.py new file mode 100644 index 0000000..8fbbf64 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/mailings/read/menu.py @@ -0,0 +1,39 @@ +from collections.abc import Callable +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Next, Start +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG, MailingsSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL + + +def next_with_data(data: dict[str, Any]) -> Callable: + async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager + ) -> None: + dialog_manager.dialog_data.update(data) + + return on_click + + +window = Window( + Const("Рассылки"), + Start( + text=Const("Создать рассылку"), state=CreateMailingSG.title, id="create_mailing" + ), + Next( + text=Const("Запланированные рассылки"), + on_click=next_with_data({"is_sent": False}), + id="new_mailing_items", + ), + Next( + text=Const("Архив"), + on_click=next_with_data({"is_sent": True}), + id="archive_mailing_items", + ), + CANCEL, + state=MailingsSG.menu, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/main_menu.py b/inclusive_dance_bot/bot/dialogs/admins/main_menu.py deleted file mode 100644 index c90479b..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/main_menu.py +++ /dev/null @@ -1,9 +0,0 @@ -from aiogram_dialog import Dialog - -from inclusive_dance_bot.bot.dialogs.admins.windows.main_menu import MainMenuWindow -from inclusive_dance_bot.bot.states import MainMenuSG - - -class AdminMainMenuDialog(Dialog): - def __init__(self) -> None: - super().__init__(MainMenuWindow(state=MainMenuSG.main_menu)) diff --git a/inclusive_dance_bot/bot/dialogs/admins/main_menu/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/main_menu/__init__.py new file mode 100644 index 0000000..ea42e07 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/main_menu/__init__.py @@ -0,0 +1,5 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.main_menu.read import menu + +dialog = Dialog(menu.window) diff --git a/inclusive_dance_bot/bot/dialogs/admins/windows/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/main_menu/read/__init__.py similarity index 100% rename from inclusive_dance_bot/bot/dialogs/admins/windows/__init__.py rename to inclusive_dance_bot/bot/dialogs/admins/main_menu/read/__init__.py diff --git a/inclusive_dance_bot/bot/dialogs/admins/main_menu/read/menu.py b/inclusive_dance_bot/bot/dialogs/admins/main_menu/read/menu.py new file mode 100644 index 0000000..16efc1d --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/main_menu/read/menu.py @@ -0,0 +1,53 @@ +from typing import Any + +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Start +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ( + AdminFeedbackSG, + AdminMainMenuSG, + AdminSubmenuSG, + MailingsSG, + ManageAdminSG, + ReadUrlSG, +) +from inclusive_dance_bot.logic.user import MegaUser + + +def when_(data: dict, widget: Any, dialog_manager: DialogManager) -> bool: + user: MegaUser = data["middleware_data"]["user"] + return user.is_superuser + + +window = Window( + Const("Меню администратора"), + Format("*Здесь должна быть статистика по пользователям*"), + Start( + id="feedbacks", + text=Const("Обратная связь от пользователей"), + state=AdminFeedbackSG.items, + ), + Start( + id="mailings", + text=Const("Рассылки"), + state=MailingsSG.menu, + ), + Start( + id="manage_submenu_id", + text=Const("Управление подменю"), + state=AdminSubmenuSG.items, + ), + Start( + id="manage_url_id", + text=Const("Управление ссылками"), + state=ReadUrlSG.items, + ), + Start( + id="manage_admin_id", + text=Const("Управление администраторами"), + state=ManageAdminSG.items, + when=when_, + ), + state=AdminMainMenuSG.menu, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/__init__.py new file mode 100644 index 0000000..5dd57a1 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/__init__.py @@ -0,0 +1,10 @@ +from aiogram import Router + +from inclusive_dance_bot.bot.dialogs.admins.manage_admins import add, delete, read + +router = Router() +router.include_routers( + add.dialog, + delete.dialog, + read.dialog, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/__init__.py new file mode 100644 index 0000000..8109571 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/__init__.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.manage_admins.add import input_username + +dialog = Dialog( + input_username.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/input_username.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/input_username.py new file mode 100644 index 0000000..43a3626 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/input_username.py @@ -0,0 +1,38 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, ShowMode, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import AddAdminSG +from inclusive_dance_bot.bot.dialogs.messages import ADD_ADMIN_MESSAGE +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.exceptions import UserNotFoundError +from inclusive_dance_bot.logic.user import add_user_to_admins + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + dialog_manager.show_mode = ShowMode.SEND + if message.entities is None or message.entities[0].type != "mention": + await message.answer("Вы должны написать имя телеграм пользователя через @") + return + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + username = message.entities[0].extract_from(value)[1:] + try: + await add_user_to_admins(uow=uow, username=username) + await message.answer(f"Пользователь @{username} добавлен в администраторы") + await dialog_manager.done() + except UserNotFoundError: + await message.answer( + "Пользователь с таким ником не найден или он уже администратор. Проверьте!" + ) + + +window = Window( + Const(ADD_ADMIN_MESSAGE), + TextInput(id="input_username", on_success=on_success), # type: ignore[arg-type] + CANCEL, + state=AddAdminSG.input_username, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/__init__.py new file mode 100644 index 0000000..01689c2 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/__init__.py @@ -0,0 +1,5 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.manage_admins.delete import confirm + +dialog = Dialog(confirm.window) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/confirm.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/confirm.py new file mode 100644 index 0000000..01fb1e0 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/confirm.py @@ -0,0 +1,46 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import DeleteAdminSG +from inclusive_dance_bot.bot.dialogs.users.states import MainMenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.user import delete_from_admins + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + user_id = dialog_manager.start_data["user_id"] + await delete_from_admins(uow=uow, user_id=user_id) + dialog_manager.show_mode = ShowMode.SEND + await c.bot.send_message( # type: ignore[union-attr] + c.from_user.id, text="Пользователь был лишен прав администратора" + ) + await dialog_manager.bg(chat_id=user_id).start( + state=MainMenuSG.menu, mode=StartMode.RESET_STACK + ) + await dialog_manager.done() + + +async def get_user( + uow: UnitOfWork, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + user_id = dialog_manager.start_data["user_id"] + return {"user": await uow.users.get_by_id(user_id=user_id)} + + +window = Window( + Format("Вы действительно хотите лишить @{user.username} прав администратора?"), + Row( + CANCEL, + Button(text=Const("Подтвердить"), id="delete_from_admins", on_click=on_click), + ), + state=DeleteAdminSG.confirm, + getter=get_user, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/__init__.py new file mode 100644 index 0000000..b06f298 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/__init__.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.manage_admins.read import items + +dialog = Dialog( + items.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/items.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/items.py new file mode 100644 index 0000000..09ce1db --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/items.py @@ -0,0 +1,51 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select, Start +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ( + AddAdminSG, + DeleteAdminSG, + ManageAdminSG, +) +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.db.uow.main import UnitOfWork + + +async def get_admins(uow: UnitOfWork, **kwargs: Any) -> dict[str, Any]: + admins = await uow.users.get_admin_list() + return {"admins": admins} + + +async def on_click( + c: CallbackQuery, widget: Button, dialog_manager: DialogManager, user_id: int +) -> None: + await dialog_manager.start(state=DeleteAdminSG.confirm, data={"user_id": user_id}) + + +window = Window( + Const("Администраторы"), + ScrollingGroup( + Select( + Format("@{item.username}"), + id="admins_s", + item_id_getter=lambda x: x.id, + items="admins", + on_click=on_click, # type: ignore[arg-type] + type_factory=int, + ), + id="admin_list", + height=10, + hide_on_single_page=True, + ), + Start( + text=Const("Добавить администратора"), + id="add_admin", + state=AddAdminSG.input_username, + ), + CANCEL, + getter=get_admins, + state=ManageAdminSG.items, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/states.py b/inclusive_dance_bot/bot/dialogs/admins/states.py new file mode 100644 index 0000000..12aa3c2 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/states.py @@ -0,0 +1,95 @@ +from aiogram.fsm.state import State, StatesGroup + + +class AdminMainMenuSG(StatesGroup): + menu = State() + + +class AdminFeedbackSG(StatesGroup): + items = State() + + +class AdminSubmenuSG(StatesGroup): + items = State() + info = State() + + +class CreateSubmenuSG(StatesGroup): + type = State() + weight = State() + message = State() + button_text = State() + confirm = State() + + +class ChangeSubmenuSG(StatesGroup): + type = State() + weight = State() + button_text = State() + message = State() + + +class DeleteSubmenuSG(StatesGroup): + confirm = State() + + +class ReadUrlSG(StatesGroup): + items = State() + item = State() + + +class CreateUrlSG(StatesGroup): + slug = State() + value = State() + confirm = State() + + +class ChangeUrlSG(StatesGroup): + slug = State() + value = State() + + +class DeleteUrlSG(StatesGroup): + confirm = State() + + +class ManageAdminSG(StatesGroup): + items = State() + + +class AddAdminSG(StatesGroup): + input_username = State() + + +class DeleteAdminSG(StatesGroup): + confirm = State() + + +class MailingsSG(StatesGroup): + menu = State() + items = State() + item = State() + + +class CancelMailingSG(StatesGroup): + confirm = State() + + +class FeedbackAsnwerSG(StatesGroup): + input_message = State() + confirm = State() + + +class FeedbackItemsSG(StatesGroup): + new = State() + archive = State() + + +class CreateMailingSG(StatesGroup): + title = State() + content = State() + user_types = State() + is_immediately = State() + date = State() + time = State() + confirm = State() diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/__init__.py new file mode 100644 index 0000000..f876ed3 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/__init__.py @@ -0,0 +1,11 @@ +from aiogram import Router + +from inclusive_dance_bot.bot.dialogs.admins.submenu import create, delete, read, update + +router = Router() +router.include_routers( + create.dialog, + read.dialog, + update.dialog, + delete.dialog, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/__init__.py new file mode 100644 index 0000000..7c2a0ab --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/__init__.py @@ -0,0 +1,17 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.submenu.create import ( + confirm, + input_button_text, + input_message, + input_type, + input_weight, +) + +dialog = Dialog( + input_type.window, + input_weight.window, + input_button_text.window, + input_message.window, + confirm.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/confirm.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/confirm.py new file mode 100644 index 0000000..68294fa --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/confirm.py @@ -0,0 +1,45 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ( + AdminMainMenuSG, + CreateSubmenuSG, +) +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.submenu import create_submenu + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + storage: Storage = dialog_manager.middleware_data["storage"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + await create_submenu( + uow=uow, + storage=storage, + type=dialog_manager.dialog_data["type"], + weight=dialog_manager.dialog_data["weight"], + message=dialog_manager.dialog_data["message"], + button_text=dialog_manager.dialog_data["button_text"], + ) + dialog_manager.show_mode = ShowMode.SEND + await c.bot.send_message(c.from_user.id, text="Подменю было сохранено") # type: ignore[union-attr] + await dialog_manager.start(state=AdminMainMenuSG.menu, mode=StartMode.RESET_STACK) + + +window = Window( + Format( + "Сохранить подменю?\n\n" + "Тип: {dialog_data[type]}\nВес: {dialog_data[weight]}\n" + "Текст кнопки: {dialog_data[button_text]}\nСообщение:\n{dialog_data[message]}" + ), + Row( + BACK, + Button(text=Const("Сохранить"), id="save", on_click=on_click), + ), + state=CreateSubmenuSG.confirm, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_button_text.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_button_text.py new file mode 100644 index 0000000..703b3cf --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_button_text.py @@ -0,0 +1,29 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateSubmenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.bot.dialogs.utils.validators import validate_length + +MESSAGE = "Введите текст кнопки.\n\nОграничение - 64 символа" + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + dialog_manager.dialog_data["button_text"] = value + await dialog_manager.next() + + +window = Window( + Const(MESSAGE), + TextInput( + id="input_button_text", + on_success=on_success, # type: ignore[arg-type] + type_factory=validate_length(64), + ), + BACK, + state=CreateSubmenuSG.button_text, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_message.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_message.py new file mode 100644 index 0000000..4383e21 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_message.py @@ -0,0 +1,24 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateSubmenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK + +TEMPLATE_MESSAGE = "Введите шаблон сообщения" + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + dialog_manager.dialog_data["message"] = value + await dialog_manager.next() + + +window = Window( + Format(TEMPLATE_MESSAGE), + TextInput(id="input_message", on_success=on_success), # type: ignore[arg-type] + BACK, + state=CreateSubmenuSG.message, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_type.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_type.py new file mode 100644 index 0000000..95ba905 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_type.py @@ -0,0 +1,45 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Column, Select +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateSubmenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.enums import SubmenuType + + +async def get_submenu_data(**kwargs: Any) -> dict[str, Any]: + return { + "submenu_types": list(SubmenuType), + } + + +async def on_click( + c: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + type_: str, +) -> None: + dialog_manager.dialog_data["type"] = type_ + await dialog_manager.next() + + +TEMPLATE_MESSAGE = "Выберите тип подменю" + +window = Window( + Format(TEMPLATE_MESSAGE), + Column( + Select( + id="submenu_types", + text=Format("{item}"), + item_id_getter=lambda x: x, + items="submenu_types", + on_click=on_click, # type: ignore[arg-type] + ), + ), + CANCEL, + state=CreateSubmenuSG.type, + getter=get_submenu_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_weight.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_weight.py new file mode 100644 index 0000000..ccb3070 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_weight.py @@ -0,0 +1,27 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateSubmenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK + +TEMPLATE_MESSAGE = ( + "Введите вес подменю.\n\n" + "Вес влияет на порядок отображения. Больше вес - выше в списке." +) + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, weight: int +) -> None: + dialog_manager.dialog_data["weight"] = weight + await dialog_manager.next() + + +window = Window( + Format(TEMPLATE_MESSAGE), + TextInput(id="input_weight", on_success=on_success, type_factory=int), # type: ignore[arg-type] + BACK, + state=CreateSubmenuSG.weight, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/__init__.py new file mode 100644 index 0000000..0201ded --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/__init__.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.submenu.delete import confirm + +dialog = Dialog( + confirm.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/confirm.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/confirm.py new file mode 100644 index 0000000..def7fea --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/confirm.py @@ -0,0 +1,37 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ( + AdminMainMenuSG, + DeleteSubmenuSG, +) +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.bot.dialogs.utils.getters import get_submenu_data +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.submenu import delete_submenu_by_id + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + storage: Storage = dialog_manager.middleware_data["storage"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + submenu_id = dialog_manager.start_data["submenu_id"] + await delete_submenu_by_id(uow=uow, storage=storage, submenu_id=submenu_id) + dialog_manager.show_mode = ShowMode.SEND + await c.bot.send_message(c.from_user.id, text="Подменю было удалено") # type: ignore[union-attr] + await dialog_manager.start(state=AdminMainMenuSG.menu, mode=StartMode.RESET_STACK) + + +window = Window( + Format("Вы действительно хотите удалить подменю `{submenu.button_text}`"), + Row( + CANCEL, + Button(text=Const("Удалить"), id="delete_submenu", on_click=on_click), + ), + state=DeleteSubmenuSG.confirm, + getter=get_submenu_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/read/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/read/__init__.py new file mode 100644 index 0000000..ee4827b --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/read/__init__.py @@ -0,0 +1,8 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.submenu.read import item, items + +dialog = Dialog( + items.window, + item.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/read/item.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/read/item.py new file mode 100644 index 0000000..53f9e78 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/read/item.py @@ -0,0 +1,56 @@ +from typing import Any + +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Back, Button +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ( + AdminSubmenuSG, + ChangeSubmenuSG, + DeleteSubmenuSG, +) +from inclusive_dance_bot.bot.dialogs.messages import SUBMENU_TEMPLATE +from inclusive_dance_bot.bot.dialogs.utils import start_with_data +from inclusive_dance_bot.logic.storage import Storage + +SUBMENU_ID = "submenu_id" + + +async def get_data( + storage: Storage, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + s = await storage.get_submenu_by_id(dialog_manager.dialog_data[SUBMENU_ID]) + return {"submenu": s} + + +window = Window( + Format(SUBMENU_TEMPLATE), + Button( + Const("Изменить тип"), + id="change_submenu_type", + on_click=start_with_data(state=ChangeSubmenuSG.type, field=SUBMENU_ID), + ), + Button( + Const("Изменить вес"), + id="change_submenu_weight", + on_click=start_with_data(state=ChangeSubmenuSG.weight, field=SUBMENU_ID), + ), + Button( + Const("Изменить текст кнопки"), + id="change_submenu_button_text", + on_click=start_with_data(state=ChangeSubmenuSG.button_text, field=SUBMENU_ID), + ), + Button( + Const("Изменить значение"), + id="change_submenu_message", + on_click=start_with_data(state=ChangeSubmenuSG.message, field=SUBMENU_ID), + ), + Button( + Const("Удалить подменю"), + id="delete_submenu", + on_click=start_with_data(state=DeleteSubmenuSG.confirm, field=SUBMENU_ID), + ), + Back(Const("Назад")), + state=AdminSubmenuSG.info, + getter=get_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/read/items.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/read/items.py new file mode 100644 index 0000000..102aa2d --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/read/items.py @@ -0,0 +1,62 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select, Start +from aiogram_dialog.widgets.text import Const, Format, List + +from inclusive_dance_bot.bot.dialogs.admins.states import ( + AdminSubmenuSG, + CreateSubmenuSG, +) +from inclusive_dance_bot.bot.dialogs.utils import sync_scroll +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.logic.storage import Storage + +SCROLL_KBD_ID = "submenu_scroll_id" +SCROLL_MESSAGE_ID = "submenu_message_scroll_id" + + +async def get_submenu_list_data(storage: Storage, **kwargs: Any) -> dict[str, Any]: + return {"submenus": list((await storage.get_submenus()).values())} + + +async def open_submenu( + c: CallbackQuery, widget: Button, dialog_manager: DialogManager, submenu_id: int +) -> None: + dialog_manager.dialog_data["submenu_id"] = submenu_id + await dialog_manager.next() + + +window = Window( + Const("Подменю\n"), + List( + Format("[{pos}] {item.button_text} {item.id}\n{item.type}"), + items="submenus", + id=SCROLL_MESSAGE_ID, + page_size=10, + sep="\n\n", + ), + ScrollingGroup( + Select( + Format("{pos}"), + id="s_submenu", + item_id_getter=lambda x: x.id, + items="submenus", + on_click=open_submenu, # type: ignore[arg-type] + type_factory=int, + ), + id=SCROLL_KBD_ID, + width=5, + height=2, + on_page_changed=sync_scroll(SCROLL_MESSAGE_ID), + ), + Start( + text=Const("Добавить подменю"), + id="create_submenu", + state=CreateSubmenuSG.type, + ), + CANCEL, + state=AdminSubmenuSG.items, + getter=get_submenu_list_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/__init__.py new file mode 100644 index 0000000..e0009a4 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/__init__.py @@ -0,0 +1,15 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.submenu.update import ( + change_button_text, + change_message, + change_type, + change_weight, +) + +dialog = Dialog( + change_type.window, + change_weight.window, + change_button_text.window, + change_message.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_button_text.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_button_text.py new file mode 100644 index 0000000..e3c3f37 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_button_text.py @@ -0,0 +1,42 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ChangeSubmenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.bot.dialogs.utils.getters import get_submenu_data +from inclusive_dance_bot.bot.dialogs.utils.validators import validate_length +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.submenu import update_submenu_by_id + +TEMPLATE_MESSAGE = "Введите новый текст кнопки\n\nТекущее: {submenu.button_text}" + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + submenu_id = dialog_manager.start_data["submenu_id"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + storage: Storage = dialog_manager.middleware_data["storage"] + await update_submenu_by_id( + uow=uow, + storage=storage, + submenu_id=submenu_id, + button_text=value, + ) + await dialog_manager.done() + + +window = Window( + Format(TEMPLATE_MESSAGE), + TextInput( + id="input_button_text", + on_success=on_success, # type: ignore[arg-type] + type_factory=validate_length(64), + ), + CANCEL, + state=ChangeSubmenuSG.button_text, + getter=get_submenu_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_message.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_message.py new file mode 100644 index 0000000..46843c1 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_message.py @@ -0,0 +1,37 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ChangeSubmenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.bot.dialogs.utils.getters import get_submenu_data +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.submenu import update_submenu_by_id + +TEMPLATE_MESSAGE = "Введите новый шаблон сообщения\n\nТекущее: {submenu.message}" + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + submenu_id = dialog_manager.start_data["submenu_id"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + storage: Storage = dialog_manager.middleware_data["storage"] + await update_submenu_by_id( + uow=uow, + storage=storage, + submenu_id=submenu_id, + message=value, + ) + await dialog_manager.done() + + +window = Window( + Format(TEMPLATE_MESSAGE), + TextInput(id="input_message", on_success=on_success), # type: ignore[arg-type] + CANCEL, + state=ChangeSubmenuSG.message, + getter=get_submenu_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_type.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_type.py new file mode 100644 index 0000000..a44dde8 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_type.py @@ -0,0 +1,61 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Column, Select +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ChangeSubmenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.enums import SubmenuType +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.submenu import update_submenu_by_id + + +async def get_submenu_data( + storage: Storage, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + return { + "submenu": await storage.get_submenu_by_id( + dialog_manager.start_data["submenu_id"] + ), + "submenu_types": list(SubmenuType), + } + + +async def on_click( + c: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + type_: str, +) -> None: + submenu_id = dialog_manager.start_data["submenu_id"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + storage: Storage = dialog_manager.middleware_data["storage"] + await update_submenu_by_id( + uow=uow, + storage=storage, + submenu_id=submenu_id, + type_=SubmenuType(type_), + ) + await dialog_manager.done() + + +TEMPLATE_MESSAGE = "Выберите новый тип подменю\n\nТекущее значение: {submenu.type}" + +window = Window( + Format(TEMPLATE_MESSAGE), + Column( + Select( + id="submenu_types", + text=Format("{item}"), + item_id_getter=lambda x: x, + items="submenu_types", + on_click=on_click, # type: ignore[arg-type] + ), + ), + CANCEL, + state=ChangeSubmenuSG.type, + getter=get_submenu_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_weight.py b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_weight.py new file mode 100644 index 0000000..45fc423 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_weight.py @@ -0,0 +1,38 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ChangeSubmenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.bot.dialogs.utils.getters import get_submenu_data +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.submenu import update_submenu_by_id + +TEMPLATE_MESSAGE = ( + "Введите новое значение веса. " + "Вес влияет на порядок отображения. Больше вес - выше в списке." + "\n\nТекущее: {submenu.weight}" +) + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, weight: int +) -> None: + submenu_id = dialog_manager.start_data["submenu_id"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + storage: Storage = dialog_manager.middleware_data["storage"] + await update_submenu_by_id( + uow=uow, storage=storage, submenu_id=submenu_id, weight=weight + ) + await dialog_manager.done() + + +window = Window( + Format(TEMPLATE_MESSAGE), + TextInput(id="input_weight", on_success=on_success, type_factory=int), # type: ignore[arg-type] + CANCEL, + state=ChangeSubmenuSG.weight, + getter=get_submenu_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/url/__init__.py new file mode 100644 index 0000000..c375d5a --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/__init__.py @@ -0,0 +1,11 @@ +from aiogram import Router + +from inclusive_dance_bot.bot.dialogs.admins.url import create, delete, read, update + +router = Router() +router.include_routers( + create.dialog, + read.dialog, + update.dialog, + delete.dialog, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/create/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/url/create/__init__.py new file mode 100644 index 0000000..09ea351 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/create/__init__.py @@ -0,0 +1,13 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.url.create import ( + confirm, + input_slug, + input_value, +) + +dialog = Dialog( + input_slug.window, + input_value.window, + confirm.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/create/confirm.py b/inclusive_dance_bot/bot/dialogs/admins/url/create/confirm.py new file mode 100644 index 0000000..10e73b2 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/create/confirm.py @@ -0,0 +1,36 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import AdminMainMenuSG, CreateUrlSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.url import create_url + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + storage: Storage = dialog_manager.middleware_data["storage"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + await create_url( + uow=uow, + storage=storage, + slug=dialog_manager.dialog_data["slug"], + value=dialog_manager.dialog_data["value"], + ) + dialog_manager.show_mode = ShowMode.SEND + await c.bot.send_message(c.from_user.id, text="Ссылка была сохранена") # type: ignore[union-attr] + await dialog_manager.start(state=AdminMainMenuSG.menu, mode=StartMode.RESET_STACK) + + +window = Window( + Format("Слаг: {dialog_data[slug]}\nЗначение: {dialog_data[value]}"), + Row( + BACK, + Button(text=Const("Сохранить"), id="save", on_click=on_click), + ), + state=CreateUrlSG.confirm, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/create/input_slug.py b/inclusive_dance_bot/bot/dialogs/admins/url/create/input_slug.py new file mode 100644 index 0000000..0f11e41 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/create/input_slug.py @@ -0,0 +1,38 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, ShowMode, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateUrlSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.utils import check_slug + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, slug: str +) -> None: + if not check_slug(slug): + dialog_manager.show_mode = ShowMode.SEND + await message.answer(text="Некорректный слаг") + return + + storage: Storage = dialog_manager.middleware_data["storage"] + + try: + await storage.get_url_by_slug(slug) + except KeyError: + dialog_manager.dialog_data["slug"] = slug + await dialog_manager.next() + else: + dialog_manager.show_mode = ShowMode.SEND + await message.answer(text="Такой слаг уже занят. Придумайте другой") + return + + +window = Window( + Const("Введите слаг для новой ссылки"), + TextInput(id="input_slug", on_success=on_success), # type: ignore[arg-type] + CANCEL, + state=CreateUrlSG.slug, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/create/input_value.py b/inclusive_dance_bot/bot/dialogs/admins/url/create/input_value.py new file mode 100644 index 0000000..dd0812e --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/create/input_value.py @@ -0,0 +1,23 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateUrlSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + dialog_manager.dialog_data["value"] = value + await dialog_manager.next() + return + + +window = Window( + Const("Введите значение для новой ссылки"), + TextInput(id="input_value", on_success=on_success), # type: ignore[arg-type] + BACK, + state=CreateUrlSG.value, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/delete/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/url/delete/__init__.py new file mode 100644 index 0000000..b041188 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/delete/__init__.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.url.delete import confirm + +dialog = Dialog( + confirm.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/delete/confirm.py b/inclusive_dance_bot/bot/dialogs/admins/url/delete/confirm.py new file mode 100644 index 0000000..33ba0a4 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/delete/confirm.py @@ -0,0 +1,34 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import AdminMainMenuSG, DeleteUrlSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.bot.dialogs.utils.getters import get_url_data +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.url import delete_url_by_slug + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + storage: Storage = dialog_manager.middleware_data["storage"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + url_slug = dialog_manager.start_data["url_slug"] + await delete_url_by_slug(uow=uow, storage=storage, url_slug=url_slug) + dialog_manager.show_mode = ShowMode.SEND + await c.bot.send_message(c.from_user.id, text="Ссылка была удалена") # type: ignore[union-attr] + await dialog_manager.start(state=AdminMainMenuSG.menu, mode=StartMode.RESET_STACK) + + +window = Window( + Format("Вы действительно хотите удалить ссылку `{url.slug}`?"), + Row( + CANCEL, + Button(text=Const("Удалить"), id="delete_url", on_click=on_click), + ), + state=DeleteUrlSG.confirm, + getter=get_url_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/read/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/url/read/__init__.py new file mode 100644 index 0000000..19f95ac --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/read/__init__.py @@ -0,0 +1,19 @@ +from typing import Any + +from aiogram_dialog import Data, Dialog, DialogManager + +from inclusive_dance_bot.bot.dialogs.admins.url.read import item, items + + +async def on_process_result( + start_data: Data, result: Any, manager: DialogManager +) -> None: + if result: + manager.dialog_data["url_slug"] = result["url_slug"] + + +dialog = Dialog( + items.window, + item.window, + on_process_result=on_process_result, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/read/item.py b/inclusive_dance_bot/bot/dialogs/admins/url/read/item.py new file mode 100644 index 0000000..e978705 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/read/item.py @@ -0,0 +1,45 @@ +from typing import Any + +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Back, Button +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ( + ChangeUrlSG, + DeleteUrlSG, + ReadUrlSG, +) +from inclusive_dance_bot.bot.dialogs.messages import URL_TEMPLATE +from inclusive_dance_bot.bot.dialogs.utils import start_with_data +from inclusive_dance_bot.logic.storage import Storage + +URL_ID = "url_slug" + + +async def get_data( + storage: Storage, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + return {"url": await storage.get_url_by_slug(dialog_manager.dialog_data[URL_ID])} + + +window = Window( + Format(URL_TEMPLATE), + Button( + Const("Изменить слаг"), + id="change_url_slug", + on_click=start_with_data(state=ChangeUrlSG.slug, field=URL_ID), + ), + Button( + Const("Изменить значение"), + id="change_url_value", + on_click=start_with_data(state=ChangeUrlSG.value, field=URL_ID), + ), + Button( + Const("Удалить ссылку"), + id="delete_url", + on_click=start_with_data(state=DeleteUrlSG.confirm, field=URL_ID), + ), + Back(Const("Назад")), + state=ReadUrlSG.item, + getter=get_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/read/items.py b/inclusive_dance_bot/bot/dialogs/admins/url/read/items.py new file mode 100644 index 0000000..3a15c2b --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/read/items.py @@ -0,0 +1,58 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select, Start +from aiogram_dialog.widgets.text import Const, Format, List + +from inclusive_dance_bot.bot.dialogs.admins.states import CreateUrlSG, ReadUrlSG +from inclusive_dance_bot.bot.dialogs.utils import sync_scroll +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.logic.storage import Storage + +SCROLL_KBD_ID = "url_scroll_id" +SCROLL_MESSAGE_ID = "url_message_scroll_id" + + +async def get_urls_list_data(storage: Storage, **kwargs: Any) -> dict[str, Any]: + return {"urls": list((await storage.get_urls()).values())} + + +async def open_url( + c: CallbackQuery, widget: Button, dialog_manager: DialogManager, url_slug: int +) -> None: + dialog_manager.dialog_data["url_slug"] = url_slug + await dialog_manager.next() + + +window = Window( + Const("Ссылки\n"), + List( + Format("[{pos}] {item.slug}"), + items="urls", + id=SCROLL_MESSAGE_ID, + page_size=10, + ), + ScrollingGroup( + Select( + Format("{pos}"), + id="s_url", + item_id_getter=lambda x: x.slug, + items="urls", + on_click=open_url, # type: ignore[arg-type] + ), + id=SCROLL_KBD_ID, + width=5, + height=2, + hide_on_single_page=True, + on_page_changed=sync_scroll(SCROLL_MESSAGE_ID), + ), + Start( + text=Const("Добавить ссылку"), + id="create_url", + state=CreateUrlSG.slug, + ), + CANCEL, + state=ReadUrlSG.items, + getter=get_urls_list_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/update/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/url/update/__init__.py new file mode 100644 index 0000000..475747f --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/update/__init__.py @@ -0,0 +1,10 @@ +from typing import Any + +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.admins.url.update import change_slug, change_value + +dialog = Dialog( + change_slug.window, + change_value.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/update/change_slug.py b/inclusive_dance_bot/bot/dialogs/admins/url/update/change_slug.py new file mode 100644 index 0000000..280fb9e --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/update/change_slug.py @@ -0,0 +1,48 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, ShowMode, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ChangeUrlSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.bot.dialogs.utils.getters import get_url_data +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.exceptions.url import UrlSlugAlreadyExistsError +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.url import update_url_by_slug +from inclusive_dance_bot.utils import check_slug + +TEMPLATE_MESSAGE = ( + "Введите новый слаг\n(слаг может состоять только из латинских букв" + " и символа подчеркивания) \n\nСтарый слаг: {url.slug}" +) + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + if not check_slug(value): + dialog_manager.show_mode = ShowMode.SEND + await message.answer(text="Некорректный слаг") + return + url_slug = dialog_manager.start_data["url_slug"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + storage: Storage = dialog_manager.middleware_data["storage"] + try: + await update_url_by_slug( + uow=uow, storage=storage, url_slug=url_slug, slug=value + ) + except UrlSlugAlreadyExistsError: + dialog_manager.show_mode = ShowMode.SEND + await message.answer(text="Такой слаг уже занят. Придумайте другой") + return + await dialog_manager.done(result={"url_slug": value}) + + +window = Window( + Format(TEMPLATE_MESSAGE), + TextInput(id="input_slug", on_success=on_success), # type: ignore[arg-type] + CANCEL, + state=ChangeUrlSG.slug, + getter=get_url_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/update/change_value.py b/inclusive_dance_bot/bot/dialogs/admins/url/update/change_value.py new file mode 100644 index 0000000..b8f1b43 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/admins/url/update/change_value.py @@ -0,0 +1,33 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.admins.states import ChangeUrlSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.bot.dialogs.utils.getters import get_url_data +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.url import update_url_by_slug + +TEMPLATE_MESSAGE = "Введите новое значение ссылки\n\nТекущее: {url.value}" + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + url_slug = dialog_manager.start_data["url_slug"] + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + storage: Storage = dialog_manager.middleware_data["storage"] + await update_url_by_slug(uow=uow, storage=storage, url_slug=url_slug, value=value) + + await dialog_manager.done(result={"url_slug": url_slug}) + + +window = Window( + Format(TEMPLATE_MESSAGE), + TextInput(id="input_value", on_success=on_success), # type: ignore[arg-type] + CANCEL, + state=ChangeUrlSG.value, + getter=get_url_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/windows/main_menu.py b/inclusive_dance_bot/bot/dialogs/admins/windows/main_menu.py deleted file mode 100644 index b24b4e0..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/windows/main_menu.py +++ /dev/null @@ -1,8 +0,0 @@ -from aiogram.fsm.state import State -from aiogram_dialog import Window -from aiogram_dialog.widgets.text import Const - - -class MainMenuWindow(Window): - def __init__(self, state: State): - super().__init__(Const("Меню администратора"), state=state) diff --git a/inclusive_dance_bot/bot/dialogs/commands.py b/inclusive_dance_bot/bot/dialogs/commands.py index ab68a02..f478f46 100644 --- a/inclusive_dance_bot/bot/dialogs/commands.py +++ b/inclusive_dance_bot/bot/dialogs/commands.py @@ -1,24 +1,25 @@ from aiogram.types import Message from aiogram_dialog import DialogManager, StartMode +from inclusive_dance_bot.bot.dialogs.admins.states import AdminMainMenuSG from inclusive_dance_bot.bot.dialogs.messages import START_MESSAGE -from inclusive_dance_bot.bot.states import MainMenuSG, RegistrationSG -from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.bot.dialogs.users.states import MainMenuSG as UserMainMenuSG +from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG +from inclusive_dance_bot.logic.user import MegaUser async def start_command( message: Message, dialog_manager: DialogManager, - uow: UnitOfWork, + user: MegaUser, ) -> None: - if message.from_user is None: - return - user = await uow.users.get_by_id_or_none(message.from_user.id) - if user is not None: - await dialog_manager.start(MainMenuSG.main_menu, mode=StartMode.RESET_STACK) - else: + if user.is_admin: + await dialog_manager.start(AdminMainMenuSG.menu, mode=StartMode.RESET_STACK) + elif user.is_anonymous: await message.answer(START_MESSAGE) await dialog_manager.start( RegistrationSG.input_name, mode=StartMode.RESET_STACK, ) + else: + await dialog_manager.start(UserMainMenuSG.menu, mode=StartMode.RESET_STACK) diff --git a/inclusive_dance_bot/bot/dialogs/messages.py b/inclusive_dance_bot/bot/dialogs/messages.py index 6a888ff..4637c05 100644 --- a/inclusive_dance_bot/bot/dialogs/messages.py +++ b/inclusive_dance_bot/bot/dialogs/messages.py @@ -10,7 +10,7 @@ INPUT_NAME_MESSAGE = """ А теперь я хочу с вами познакомиться. -Укажите ваши Фамилию, Имя, Отчество +Укажите ваши фамилию, имя и отчество """ CHOOSE_USER_TYPE_MESSAGE = """ @@ -23,7 +23,7 @@ Из какого Вы города, региона? """ -INPUT_PHONE_NUMBER_MESSAGE = """ +INPUT_PHONE_MESSAGE = """ Укажите также свой контактный номер телефона """ @@ -36,3 +36,42 @@ Спасибо за обратную связь! Мы обязательно рассмотрим Ваше обращение. """ +CONFIRM_REGISTRATION_MESSAGE_TEMPLATE = """ +Проверьте Ваши данные + +ФИО: {name} +Регион: {region} +Вы являетесь: {user_types} +Телефон: {phone} +""" + +FEEDBACK_CONFIRM_TEMPLATE = """ +Тема: {title} + +Обращение: + +{text} +""" + +URL_TEMPLATE = """\ +Ссылка {url.id} + +Слаг: {url.slug} +Значение: {url.value} +""" + +SUBMENU_TEMPLATE = """\ +Подменю {submenu.id} + +Тип: {submenu.type} +Текст кнопки: {submenu.button_text} +Вес: {submenu.weight} +Значение: {submenu.message!r} +""" + + +ADD_ADMIN_MESSAGE = """\ +Введите ник пользователя в +Телеграме для добавления в группу адмиинистраторов. +Пользователь обязательно должен быть зарегистрирован в боте. +""" diff --git a/inclusive_dance_bot/bot/dialogs/users/__init__.py b/inclusive_dance_bot/bot/dialogs/users/__init__.py index deab3e1..5438687 100644 --- a/inclusive_dance_bot/bot/dialogs/users/__init__.py +++ b/inclusive_dance_bot/bot/dialogs/users/__init__.py @@ -1,17 +1,16 @@ from aiogram import Router -from inclusive_dance_bot.bot.dialogs.users.entity import EntityDialog -from inclusive_dance_bot.bot.dialogs.users.feedback import FeedbackDialog -from inclusive_dance_bot.bot.dialogs.users.main_menu import MainMenuDialog -from inclusive_dance_bot.bot.dialogs.users.registration import RegistrationDialog +from inclusive_dance_bot.bot.dialogs.users import ( + feedback, + main_menu, + registration, + submenu, +) - -def register_user_dialogs() -> Router: - dialog_router = Router(name="user_router") - - dialog_router.include_router(RegistrationDialog()) - dialog_router.include_router(MainMenuDialog()) - dialog_router.include_router(EntityDialog()) - dialog_router.include_router(FeedbackDialog()) - - return dialog_router +dialog_router = Router(name="user_router") +dialog_router.include_routers( + feedback.dialog, + main_menu.dialog, + registration.dialog, + submenu.dialog, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/entity.py b/inclusive_dance_bot/bot/dialogs/users/entity.py deleted file mode 100644 index b299355..0000000 --- a/inclusive_dance_bot/bot/dialogs/users/entity.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Any - -from aiogram_dialog import Dialog, DialogManager - -from inclusive_dance_bot.bot.dialogs.users.windows.entity import EntityWindow -from inclusive_dance_bot.bot.dialogs.users.windows.entity_list import EntityListWindow -from inclusive_dance_bot.bot.states import EntitySG - - -class EntityDialog(Dialog): - def __init__(self) -> None: - super().__init__( - EntityListWindow(state=EntitySG.list_), - EntityWindow(state=EntitySG.entity), - on_start=self.custom_on_start, - ) - - async def custom_on_start( - self, data: dict[str, Any], dialog_manager: DialogManager - ) -> None: - dialog_manager.dialog_data["message"] = data["message"] - dialog_manager.dialog_data["entity_type"] = data["entity_type"] diff --git a/inclusive_dance_bot/bot/dialogs/users/feedback.py b/inclusive_dance_bot/bot/dialogs/users/feedback.py deleted file mode 100644 index 5b32b44..0000000 --- a/inclusive_dance_bot/bot/dialogs/users/feedback.py +++ /dev/null @@ -1,85 +0,0 @@ -from textwrap import dedent -from typing import Any - -from aiogram.types import CallbackQuery -from aiogram_dialog import Dialog, DialogManager, ShowMode -from aiogram_dialog.widgets.kbd import Button - -from inclusive_dance_bot.bot.dialogs.messages import ANSWER_ON_FEEDBACK_MESSAGE -from inclusive_dance_bot.bot.dialogs.users.windows.confirm import ConfirmWindow -from inclusive_dance_bot.bot.dialogs.utils.input_form_field import InputFormWindow -from inclusive_dance_bot.bot.states import FeedbackSG -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.services.save_data import save_new_feedback - -FEEDBACK_TITLE_FIELD = "title" -FEEDBACK_TEXT_FIELD = "text" -FEEDBACK_TYPE_FIELD = "type" - -FEEDBACK_CONFIRM_TEMPLATE = dedent( - """ -Тема: {title} - -Обращение: - -{text} -""" -) - - -class FeedbackDialog(Dialog): - def __init__(self) -> None: - super().__init__( - InputFormWindow( - state=FeedbackSG.input_title, - message="Укажите тему обращения", - field_name=FEEDBACK_TITLE_FIELD, - is_first=True, - ), - InputFormWindow( - state=FeedbackSG.input_message, - message="Опишите Вашу проблему или предложение. Администраторы обязательно его расcмотрят", - field_name=FEEDBACK_TEXT_FIELD, - ), - ConfirmWindow( - state=FeedbackSG.confirm, - on_click=on_click_confirm_feedback, - confirm_button_text="Отправить", - getter=get_confirm_data, - format_template=FEEDBACK_CONFIRM_TEMPLATE, - ), - on_start=self.custom_on_start, - ) - - async def custom_on_start( - self, data: dict[str, Any], dialog_manager: DialogManager - ) -> None: - dialog_manager.dialog_data[FEEDBACK_TYPE_FIELD] = data["type"] - - -async def on_click_confirm_feedback( - c: CallbackQuery, button: Button, dialog_manager: DialogManager -) -> None: - uow: UnitOfWork = dialog_manager.middleware_data["uow"] - await save_new_feedback( - uow=uow, - type=dialog_manager.dialog_data[FEEDBACK_TYPE_FIELD], - user_id=c.from_user.id, - title=dialog_manager.dialog_data[FEEDBACK_TITLE_FIELD], - text=dialog_manager.dialog_data[FEEDBACK_TEXT_FIELD], - ) - dialog_manager.show_mode = ShowMode.SEND - await c.bot.send_message( # type:ignore[union-attr] - chat_id=c.from_user.id, - text=ANSWER_ON_FEEDBACK_MESSAGE, - ) - await dialog_manager.done() - - -async def get_confirm_data( - dialog_manager: DialogManager, **kwargs: Any -) -> dict[str, Any]: - return { - "title": dialog_manager.dialog_data[FEEDBACK_TITLE_FIELD], - "text": dialog_manager.dialog_data[FEEDBACK_TEXT_FIELD], - } diff --git a/inclusive_dance_bot/bot/dialogs/users/feedback/__init__.py b/inclusive_dance_bot/bot/dialogs/users/feedback/__init__.py new file mode 100644 index 0000000..b8b91be --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/feedback/__init__.py @@ -0,0 +1,22 @@ +from typing import Any + +from aiogram_dialog import Dialog, DialogManager + +from inclusive_dance_bot.bot.dialogs.users.feedback import ( + confirm, + input_text, + input_title, +) +from inclusive_dance_bot.enums import FeedbackField + + +async def on_start(data: dict[str, Any], dialog_manager: DialogManager) -> None: + dialog_manager.dialog_data[FeedbackField.TYPE] = data[FeedbackField.TYPE] + + +dialog = Dialog( + input_title.window, + input_text.window, + confirm.window, + on_start=on_start, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/feedback/confirm.py b/inclusive_dance_bot/bot/dialogs/users/feedback/confirm.py new file mode 100644 index 0000000..17bdd3d --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/feedback/confirm.py @@ -0,0 +1,55 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, ShowMode, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.messages import ( + ANSWER_ON_FEEDBACK_MESSAGE, + FEEDBACK_CONFIRM_TEMPLATE, +) +from inclusive_dance_bot.bot.dialogs.users.states import FeedbackSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.enums import FeedbackField +from inclusive_dance_bot.logic.feedback import create_feedback + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + await create_feedback( + uow=uow, + type=dialog_manager.dialog_data[FeedbackField.TYPE], + user_id=c.from_user.id, + title=dialog_manager.dialog_data[FeedbackField.TITLE], + text=dialog_manager.dialog_data[FeedbackField.TEXT], + ) + dialog_manager.show_mode = ShowMode.SEND + await c.bot.send_message( # type:ignore[union-attr] + chat_id=c.from_user.id, + text=ANSWER_ON_FEEDBACK_MESSAGE, + ) + await dialog_manager.done() + + +async def get_feedback_data( + dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + return { + FeedbackField.TITLE: dialog_manager.dialog_data[FeedbackField.TITLE], + FeedbackField.TEXT: dialog_manager.dialog_data[FeedbackField.TEXT], + } + + +window = Window( + Format(FEEDBACK_CONFIRM_TEMPLATE), + Row( + BACK, + Button(text=Const("Сохранить"), id="save", on_click=on_click), + ), + state=FeedbackSG.confirm, + getter=get_feedback_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/feedback/input_text.py b/inclusive_dance_bot/bot/dialogs/users/feedback/input_text.py new file mode 100644 index 0000000..ed167db --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/feedback/input_text.py @@ -0,0 +1,25 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.users.states import FeedbackSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.enums import FeedbackField + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + dialog_manager.dialog_data[FeedbackField.TEXT] = value + await dialog_manager.next() + + +window = Window( + Const( + "Опишите Вашу проблему или предложение. Администраторы обязательно его рассмотрят" + ), + TextInput("input_message_id", on_success=on_success), # type: ignore[arg-type] + BACK, + state=FeedbackSG.input_text, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/feedback/input_title.py b/inclusive_dance_bot/bot/dialogs/users/feedback/input_title.py new file mode 100644 index 0000000..5ee7d02 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/feedback/input_title.py @@ -0,0 +1,23 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.users.states import FeedbackSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.enums import FeedbackField + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + dialog_manager.dialog_data[FeedbackField.TITLE] = value + await dialog_manager.next() + + +window = Window( + Const("Введите тему обращения"), + TextInput("input_title_id", on_success=on_success), # type: ignore[arg-type] + CANCEL, + state=FeedbackSG.input_title, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/main_menu.py b/inclusive_dance_bot/bot/dialogs/users/main_menu.py deleted file mode 100644 index 0e861a1..0000000 --- a/inclusive_dance_bot/bot/dialogs/users/main_menu.py +++ /dev/null @@ -1,13 +0,0 @@ -from aiogram_dialog import Dialog - -from inclusive_dance_bot.bot.dialogs.users.windows.entity import EntityWindow -from inclusive_dance_bot.bot.dialogs.users.windows.main_menu import MainMenuWindow -from inclusive_dance_bot.bot.states import MainMenuSG - - -class MainMenuDialog(Dialog): - def __init__(self) -> None: - super().__init__( - MainMenuWindow(state=MainMenuSG.main_menu), - EntityWindow(state=MainMenuSG.message), - ) diff --git a/inclusive_dance_bot/bot/dialogs/users/main_menu/__init__.py b/inclusive_dance_bot/bot/dialogs/users/main_menu/__init__.py new file mode 100644 index 0000000..0d00d77 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/main_menu/__init__.py @@ -0,0 +1,10 @@ +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.users.main_menu import menu +from inclusive_dance_bot.bot.dialogs.users.states import MainMenuSG +from inclusive_dance_bot.bot.dialogs.utils.submenu_window import SubmenuWindow + +dialog = Dialog( + menu.window, + SubmenuWindow(state=MainMenuSG.message), +) diff --git a/inclusive_dance_bot/bot/dialogs/users/main_menu/menu.py b/inclusive_dance_bot/bot/dialogs/users/main_menu/menu.py new file mode 100644 index 0000000..54bcaf5 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/main_menu/menu.py @@ -0,0 +1,113 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Column, Select, Start +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.users.states import ( + FeedbackSG, + MainMenuSG, + SubmenuSG, +) +from inclusive_dance_bot.enums import FeedbackType, SubmenuType +from inclusive_dance_bot.logic.storage import Storage + + +async def get_submenus_data(storage: Storage, **kwargs: Any) -> dict[str, Any]: + submenus = await storage.get_submenus() + return { + "submenus": list( + filter(lambda x: x.type == SubmenuType.OTHER, submenus.values()) + ), + } + + +async def open_message( + c: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + submenu_id: int, +) -> None: + storage: Storage = dialog_manager.middleware_data["storage"] + submenus = await storage.get_submenus() + submenu = submenus[submenu_id] + scrolling_text = dialog_manager.find("scroll_text") + scrolling_text.widget.text = Format(submenu.message) # type: ignore[union-attr] + await dialog_manager.next() + + +window = Window( + Const("Главное меню"), + Start( + id="create_ad_id", + text=Const("Разместить объявление"), + state=FeedbackSG.input_title, + data={ + "type": FeedbackType.ADVERTISEMENT, + }, + ), + Start( + id="education_id", + text=Const("Принять участие в мероприятиях"), + state=SubmenuSG.list_, + data={ + "message": "Принять участие в мероприятиях", + "type": SubmenuType.EVENT, + }, + ), + Start( + id="education_id", + text=Const("Пройти обучение"), + state=SubmenuSG.list_, + data={ + "message": "Пройти обучение", + "type": SubmenuType.EDUCATION, + }, + ), + Start( + id="enroll_id", + text=Const("Записаться в студию"), + state=SubmenuSG.list_, + data={ + "message": "Запись в студию", + "type": SubmenuType.ENROLL, + }, + ), + Start( + id="charity_id", + text=Const("Поддержать проект"), + state=SubmenuSG.list_, + data={ + "message": "Поддержать проект", + "type": SubmenuType.CHARITY, + }, + ), + Start( + id="ask_id", + text=Const("Задать вопрос / внести предложение"), + state=FeedbackSG.input_title, + data={"type": FeedbackType.QUESTION}, + ), + Column( + Select( + text=Format("{item.button_text}"), + id="s_submenu", + item_id_getter=lambda x: x.id, + type_factory=int, + items="submenus", + on_click=open_message, # type: ignore[arg-type] + ) + ), + Start( + id="about_id", + text=Const("Информация"), + state=SubmenuSG.list_, + data={ + "message": "Информация о проекте", + "type": SubmenuType.INFORMATION, + }, + ), + state=MainMenuSG.menu, + getter=get_submenus_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/registration.py b/inclusive_dance_bot/bot/dialogs/users/registration.py deleted file mode 100644 index a05e17c..0000000 --- a/inclusive_dance_bot/bot/dialogs/users/registration.py +++ /dev/null @@ -1,100 +0,0 @@ -from textwrap import dedent -from typing import Any - -from aiogram.types import CallbackQuery -from aiogram_dialog import Dialog, DialogManager, ShowMode -from aiogram_dialog.widgets.kbd import Button - -from inclusive_dance_bot.bot.dialogs.messages import ( - INPUT_NAME_MESSAGE, - INPUT_PHONE_NUMBER_MESSAGE, - INPUT_REGION_MESSAGE, - THANK_FOR_REGISTRATION_MESSAGE, -) -from inclusive_dance_bot.bot.dialogs.users.windows.choose_user_types import ( - ChooseUserTypesWindow, -) -from inclusive_dance_bot.bot.dialogs.users.windows.confirm import ConfirmWindow -from inclusive_dance_bot.bot.dialogs.utils.input_form_field import InputFormWindow -from inclusive_dance_bot.bot.states import MainMenuSG, RegistrationSG -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.services.save_data import save_new_user -from inclusive_dance_bot.services.storage import Storage - -CONFIRM_REGISTRATION_MESSAGE_TEMPLATE = dedent( - """ -Проверьте Ваши данные - -ФИО: {name} -Регион: {region} -Вы являетесь: {user_types} -Телефон: {phone_number} -""" -) - - -class RegistrationDialog(Dialog): - def __init__(self) -> None: - super().__init__( - InputFormWindow( - state=RegistrationSG.input_name, - field_name="name", - message=INPUT_NAME_MESSAGE, - is_first=True, - ), - ChooseUserTypesWindow( - state=RegistrationSG.choose_types, - ), - InputFormWindow( - state=RegistrationSG.input_place, - field_name="region", - message=INPUT_REGION_MESSAGE, - ), - InputFormWindow( - state=RegistrationSG.input_phone_number, - field_name="phone_number", - message=INPUT_PHONE_NUMBER_MESSAGE, - ), - ConfirmWindow( - state=RegistrationSG.confirm, - format_template=CONFIRM_REGISTRATION_MESSAGE_TEMPLATE, - on_click=on_click_confirm_registration, - getter=get_confirm_registration_data, - ), - ) - - -async def on_click_confirm_registration( - c: CallbackQuery, button: Button, dialog_manager: DialogManager -) -> None: - uow: UnitOfWork = dialog_manager.middleware_data["uow"] - await save_new_user( - uow=uow, - user_id=c.from_user.id, - name=dialog_manager.dialog_data["name"], - region=dialog_manager.dialog_data["region"], - phone_number=dialog_manager.dialog_data["phone_number"], - user_type_ids=dialog_manager.dialog_data["user_type_ids"], - ) - dialog_manager.show_mode = ShowMode.SEND - await c.bot.send_message( # type: ignore[union-attr] - chat_id=c.from_user.id, - text=THANK_FOR_REGISTRATION_MESSAGE, - ) - await dialog_manager.done() - await dialog_manager.start(state=MainMenuSG.main_menu) - - -async def get_confirm_registration_data( - dialog_manager: DialogManager, storage: Storage, **kwargs: Any -) -> dict[str, Any]: - user_types = filter( - lambda ut: ut.id in dialog_manager.dialog_data["user_type_ids"], - (await storage.get_user_types()).values(), - ) - return { - "name": dialog_manager.dialog_data["name"], - "region": dialog_manager.dialog_data["region"], - "user_types": ", ".join(map(lambda x: x.name, user_types)), - "phone_number": dialog_manager.dialog_data["phone_number"], - } diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/__init__.py b/inclusive_dance_bot/bot/dialogs/users/registration/__init__.py new file mode 100644 index 0000000..8ae7251 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/registration/__init__.py @@ -0,0 +1,19 @@ +from textwrap import dedent + +from aiogram_dialog import Dialog + +from inclusive_dance_bot.bot.dialogs.users.registration import ( + choose_user_types, + confirm, + input_name, + input_phone, + input_region, +) + +dialog = Dialog( + input_name.window, + choose_user_types.window, + input_region.window, + input_phone.window, + confirm.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/choose_user_types.py b/inclusive_dance_bot/bot/dialogs/users/registration/choose_user_types.py new file mode 100644 index 0000000..6ee52a5 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/registration/choose_user_types.py @@ -0,0 +1,53 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Multiselect, Next, ScrollingGroup +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.messages import CHOOSE_USER_TYPE_MESSAGE +from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.enums import RegistrationField +from inclusive_dance_bot.logic.storage import Storage + + +async def get_user_types_data( + dialog_manager: DialogManager, storage: Storage, **kwargs: Any +) -> dict[str, Any]: + user_types = await storage.get_user_types() + return { + "user_types": user_types.values(), + RegistrationField.NAME: dialog_manager.dialog_data[RegistrationField.NAME], + } + + +async def save_data( + c: CallbackQuery, + widget: Button, + manager: DialogManager, +) -> None: + ids: list[int] = manager.find("s_user_types").get_checked() # type: ignore[union-attr] + manager.dialog_data[RegistrationField.USER_TYPE_IDS] = ids + + +window = Window( + Format(CHOOSE_USER_TYPE_MESSAGE), + ScrollingGroup( + Multiselect( + checked_text=Format("\U00002705 {item.name}"), + unchecked_text=Format("{item.name}"), + id="s_user_types", + item_id_getter=lambda x: x.id, + type_factory=int, + items="user_types", + ), + id="user_types", + width=1, + height=10, + ), + BACK, + Next(text=Const("Далее"), on_click=save_data), + state=RegistrationSG.choose_types, + getter=get_user_types_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/confirm.py b/inclusive_dance_bot/bot/dialogs/users/registration/confirm.py new file mode 100644 index 0000000..492e842 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/registration/confirm.py @@ -0,0 +1,73 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, ShowMode, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const, Format + +from inclusive_dance_bot.bot.dialogs.messages import ( + CONFIRM_REGISTRATION_MESSAGE_TEMPLATE, + THANK_FOR_REGISTRATION_MESSAGE, +) +from inclusive_dance_bot.bot.dialogs.users.states import MainMenuSG, RegistrationSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.enums import RegistrationField +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.user import create_user + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + dialog_manager.show_mode = ShowMode.SEND + username = c.from_user.username + if username is None: + await c.bot.send_message( # type: ignore[union-attr] + chat_id=c.from_user.id, + text="У Вас нет username, пожалуйста, придумайте его прежде чем " + " регистрироваться в боте", + ) + return + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + await create_user( + uow=uow, + user_id=c.from_user.id, + username=username, + name=dialog_manager.dialog_data[RegistrationField.NAME], + region=dialog_manager.dialog_data[RegistrationField.REGION], + phone_number=dialog_manager.dialog_data[RegistrationField.PHONE], + user_type_ids=dialog_manager.dialog_data[RegistrationField.USER_TYPE_IDS], + ) + await c.bot.send_message( # type: ignore[union-attr] + chat_id=c.from_user.id, + text=THANK_FOR_REGISTRATION_MESSAGE, + ) + await dialog_manager.done() + await dialog_manager.start(state=MainMenuSG.menu) + + +async def get_user_data( + dialog_manager: DialogManager, storage: Storage, **kwargs: Any +) -> dict[str, Any]: + user_types = filter( + lambda ut: ut.id in dialog_manager.dialog_data[RegistrationField.USER_TYPE_IDS], + (await storage.get_user_types()).values(), + ) + return { + "name": dialog_manager.dialog_data[RegistrationField.NAME], + "region": dialog_manager.dialog_data[RegistrationField.REGION], + "user_types": ", ".join(map(lambda x: x.name, user_types)), + "phone": dialog_manager.dialog_data[RegistrationField.PHONE], + } + + +window = Window( + Format(CONFIRM_REGISTRATION_MESSAGE_TEMPLATE), + Row( + BACK, + Button(text=Const("Сохранить"), id="save", on_click=on_click), + ), + state=RegistrationSG.confirm, + getter=get_user_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/input_name.py b/inclusive_dance_bot/bot/dialogs/users/registration/input_name.py new file mode 100644 index 0000000..d3120dd --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/registration/input_name.py @@ -0,0 +1,24 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.kbd import Cancel +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.messages import INPUT_NAME_MESSAGE +from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG +from inclusive_dance_bot.enums import RegistrationField + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + dialog_manager.dialog_data[RegistrationField.NAME] = value + await dialog_manager.next() + + +window = Window( + Const(INPUT_NAME_MESSAGE), + TextInput("input_name_id", on_success=on_success), # type: ignore[arg-type] + Cancel(text=Const("Отмена")), + state=RegistrationSG.input_name, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/input_phone.py b/inclusive_dance_bot/bot/dialogs/users/registration/input_phone.py new file mode 100644 index 0000000..b60e77e --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/registration/input_phone.py @@ -0,0 +1,23 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.messages import INPUT_PHONE_MESSAGE +from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + dialog_manager.dialog_data["phone"] = value + await dialog_manager.next() + + +window = Window( + Const(INPUT_PHONE_MESSAGE), + TextInput("input_phone_id", on_success=on_success), # type: ignore[arg-type] + BACK, + state=RegistrationSG.input_phone, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/input_region.py b/inclusive_dance_bot/bot/dialogs/users/registration/input_region.py new file mode 100644 index 0000000..3a0228e --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/registration/input_region.py @@ -0,0 +1,23 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import TextInput +from aiogram_dialog.widgets.text import Const + +from inclusive_dance_bot.bot.dialogs.messages import INPUT_REGION_MESSAGE +from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK + + +async def on_success( + message: Message, widget: TextInput, dialog_manager: DialogManager, value: str +) -> None: + dialog_manager.dialog_data["region"] = value + await dialog_manager.next() + + +window = Window( + Const(INPUT_REGION_MESSAGE), + TextInput("input_region_id", on_success=on_success), # type: ignore[arg-type] + BACK, + state=RegistrationSG.input_region, +) diff --git a/inclusive_dance_bot/bot/states.py b/inclusive_dance_bot/bot/dialogs/users/states.py similarity index 66% rename from inclusive_dance_bot/bot/states.py rename to inclusive_dance_bot/bot/dialogs/users/states.py index 3f22f3e..890afb2 100644 --- a/inclusive_dance_bot/bot/states.py +++ b/inclusive_dance_bot/bot/dialogs/users/states.py @@ -4,22 +4,22 @@ class RegistrationSG(StatesGroup): input_name = State() choose_types = State() - input_place = State() - input_phone_number = State() + input_region = State() + input_phone = State() confirm = State() class MainMenuSG(StatesGroup): - main_menu = State() + menu = State() message = State() -class EntitySG(StatesGroup): +class SubmenuSG(StatesGroup): list_ = State() - entity = State() + submenu = State() class FeedbackSG(StatesGroup): input_title = State() - input_message = State() + input_text = State() confirm = State() diff --git a/inclusive_dance_bot/bot/dialogs/users/submenu/__init__.py b/inclusive_dance_bot/bot/dialogs/users/submenu/__init__.py new file mode 100644 index 0000000..022d811 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/submenu/__init__.py @@ -0,0 +1,19 @@ +from typing import Any + +from aiogram_dialog import Dialog, DialogManager + +from inclusive_dance_bot.bot.dialogs.users.states import SubmenuSG +from inclusive_dance_bot.bot.dialogs.users.submenu import submenu_list +from inclusive_dance_bot.bot.dialogs.utils.submenu_window import SubmenuWindow + + +async def on_start(data: dict[str, Any], dialog_manager: DialogManager) -> None: + dialog_manager.dialog_data["message"] = data["message"] + dialog_manager.dialog_data["type"] = data["type"] + + +dialog = Dialog( + submenu_list.window, + SubmenuWindow(SubmenuSG.submenu), + on_start=on_start, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/submenu/submenu_list.py b/inclusive_dance_bot/bot/dialogs/users/submenu/submenu_list.py new file mode 100644 index 0000000..26684d6 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/users/submenu/submenu_list.py @@ -0,0 +1,53 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Column, Select +from aiogram_dialog.widgets.text import Format + +from inclusive_dance_bot.bot.dialogs.users.states import SubmenuSG +from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from inclusive_dance_bot.logic.storage import Storage + + +async def get_submenu_data( + dialog_manager: DialogManager, storage: Storage, **kwargs: Any +) -> dict[str, Any]: + submenu_type = dialog_manager.dialog_data["type"] + submenus = await storage.get_submenus() + return { + "submenus": list(filter(lambda x: x.type == submenu_type, submenus.values())), + "message": dialog_manager.dialog_data["message"], + } + + +async def open_message( + c: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + submenu_id: int, +) -> None: + storage: Storage = dialog_manager.middleware_data["storage"] + submenus = await storage.get_submenus() + submenu = submenus[submenu_id] + scrolling_text = dialog_manager.find("scroll_text") + scrolling_text.widget.text = Format(submenu.message) # type: ignore[union-attr] + await dialog_manager.next() + + +window = Window( + Format("{message}"), + Column( + Select( + text=Format("{item.button_text}"), + id="s_submenus", + item_id_getter=lambda x: x.id, + type_factory=int, + items="submenus", + on_click=open_message, # type: ignore[arg-type] + ), + ), + CANCEL, + state=SubmenuSG.list_, + getter=get_submenu_data, +) diff --git a/inclusive_dance_bot/bot/dialogs/users/windows/choose_user_types.py b/inclusive_dance_bot/bot/dialogs/users/windows/choose_user_types.py deleted file mode 100644 index 3e5a78b..0000000 --- a/inclusive_dance_bot/bot/dialogs/users/windows/choose_user_types.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import Any - -from aiogram.fsm.state import State -from aiogram.types import CallbackQuery -from aiogram_dialog import DialogManager, Window -from aiogram_dialog.widgets.kbd import Back, Button, Multiselect, Next, ScrollingGroup -from aiogram_dialog.widgets.text import Const, Format - -from inclusive_dance_bot.bot.dialogs.messages import CHOOSE_USER_TYPE_MESSAGE -from inclusive_dance_bot.services.storage import Storage - - -class ChooseUserTypesWindow(Window): - def __init__(self, state: State): - user_types_sg = self.get_user_types_kbd() - super().__init__( - Format(CHOOSE_USER_TYPE_MESSAGE), - user_types_sg, - Back(text=Const("Назад")), - Next(text=Const("Сохранить"), on_click=save_user_types), - getter=get_choose_user_types_data, - state=state, - ) - - def get_user_types_kbd(self) -> ScrollingGroup: - ms: Multiselect = Multiselect( - checked_text=Format("\U00002705 {item.name}"), - unchecked_text=Format("{item.name}"), - id="s_user_types", - item_id_getter=lambda x: x.id, - type_factory=int, - items="user_types", - ) - return ScrollingGroup( - ms, - id="user_types", - width=1, - height=10, - ) - - -async def get_choose_user_types_data( - dialog_manager: DialogManager, storage: Storage, **kwargs: Any -) -> dict[str, Any]: - user_types = await storage.get_user_types() - return { - "user_types": user_types.values(), - "name": dialog_manager.dialog_data["name"], - } - - -async def save_user_types( - c: CallbackQuery, widget: Button, manager: DialogManager -) -> None: - manager.dialog_data["user_type_ids"] = manager.find("s_user_types").get_checked() # type: ignore[union-attr] diff --git a/inclusive_dance_bot/bot/dialogs/users/windows/confirm.py b/inclusive_dance_bot/bot/dialogs/users/windows/confirm.py deleted file mode 100644 index 3fb90b7..0000000 --- a/inclusive_dance_bot/bot/dialogs/users/windows/confirm.py +++ /dev/null @@ -1,33 +0,0 @@ -from aiogram.fsm.state import State -from aiogram_dialog import DialogManager, Window -from aiogram_dialog.widgets.kbd import Back, Button, NumberedPager -from aiogram_dialog.widgets.kbd.button import OnClick -from aiogram_dialog.widgets.text import Const, Format, ScrollingText -from aiogram_dialog.widgets.utils import GetterVariant - - -class ConfirmWindow(Window): - def __init__( - self, - state: State, - format_template: str, - on_click: OnClick, - getter: GetterVariant, - confirm_button_text: str | None = None, - ): - if confirm_button_text is None: - confirm_button_text = "Сохранить" - super().__init__( - ScrollingText( - text=Format(text=format_template), id="scroll_text", page_size=1000 - ), - NumberedPager(scroll="scroll_text", when=when_), # type: ignore[arg-type] - Back(Const("Назад")), - Button(Const(confirm_button_text), id="confirm", on_click=on_click), - state=state, - getter=getter, - ) - - -def when_(data: dict, widget: NumberedPager, dialog_manager: DialogManager) -> bool: - return data.get("pages", 1) > 1 diff --git a/inclusive_dance_bot/bot/dialogs/users/windows/entity_list.py b/inclusive_dance_bot/bot/dialogs/users/windows/entity_list.py deleted file mode 100644 index 411e404..0000000 --- a/inclusive_dance_bot/bot/dialogs/users/windows/entity_list.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Any - -from aiogram.fsm.state import State -from aiogram.types import CallbackQuery -from aiogram_dialog import DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Cancel, Column, Select -from aiogram_dialog.widgets.text import Const, Format - -from inclusive_dance_bot.services.storage import Storage - - -class EntityListWindow(Window): - def __init__(self, state: State) -> None: - entities_sg = self.get_entities_kbd() - - super().__init__( - Format("{message}"), - entities_sg, - Cancel(Const("Назад")), - state=state, - getter=self.get_entities_data, - ) - - def get_entities_kbd(self) -> Column: - return Column( - Select( - text=Format("{item.text}"), - id="s_entities", - item_id_getter=lambda x: x.id, - type_factory=int, - items="entities", - on_click=self.open_message, # type: ignore[arg-type] - ) - ) - - async def get_entities_data( - self, dialog_manager: DialogManager, storage: Storage, **kwargs: Any - ) -> dict[str, Any]: - entity_type = dialog_manager.dialog_data["entity_type"] - entities = await storage.get_entities() - return { - "entities": list( - filter(lambda x: x.type == entity_type, entities.values()) - ), - "message": dialog_manager.dialog_data["message"], - } - - async def open_message( - self, - c: CallbackQuery, - widget: Button, - dialog_manager: DialogManager, - entity_id: int, - ) -> None: - storage: Storage = dialog_manager.middleware_data["storage"] - entities = await storage.get_entities() - entity = entities[entity_id] - dialog_manager.find("scroll_text").widget.text = Format(entity.message) # type: ignore[union-attr] - await dialog_manager.next() diff --git a/inclusive_dance_bot/bot/dialogs/users/windows/main_menu.py b/inclusive_dance_bot/bot/dialogs/users/windows/main_menu.py deleted file mode 100644 index fb757ea..0000000 --- a/inclusive_dance_bot/bot/dialogs/users/windows/main_menu.py +++ /dev/null @@ -1,113 +0,0 @@ -from typing import Any - -from aiogram.fsm.state import State -from aiogram.types import CallbackQuery -from aiogram_dialog import DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Column, Select, Start -from aiogram_dialog.widgets.text import Const, Format - -from inclusive_dance_bot.bot.states import EntitySG, FeedbackSG -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import EntityType, FeedbackType -from inclusive_dance_bot.services.storage import Storage - - -class MainMenuWindow(Window): - def __init__(self, state: State): - self.entity_type = EntityType.SUBMENU - super().__init__( - Const("Главное меню"), - Start( - id="place_ad_id", - text=Const("Разместить объявление"), - state=FeedbackSG.input_title, - data={ - "type": FeedbackType.ADVERTISEMENT, - }, - ), - Start( - id="education_id", - text=Const("Принять участие в мероприятиях"), - state=EntitySG.list_, - data={ - "message": "Принять участие в мероприятиях", - "entity_type": EntityType.EVENT, - }, - ), - Start( - id="education_id", - text=Const("Пройти обучение"), - state=EntitySG.list_, - data={ - "message": "Пройти обучение", - "entity_type": EntityType.EDUCATION, - }, - ), - Start( - id="enroll_id", - text=Const("Записаться в студию"), - state=EntitySG.list_, - data={ - "message": "Запись в студию", - "entity_type": EntityType.ENROLL, - }, - ), - Start( - id="charity_id", - text=Const("Поддержать проект"), - state=EntitySG.list_, - data={ - "message": "Поддержать проект", - "entity_type": EntityType.CHARITY, - }, - ), - Start( - id="ask_id", - text=Const("Задать вопрос / внести предложение"), - state=FeedbackSG.input_title, - data={"type": FeedbackType.QUESTION}, - ), - Column( - Select( - text=Format("{item.text}"), - id="s_entities", - item_id_getter=lambda x: x.id, - type_factory=int, - items="entities", - on_click=self.open_message, # type: ignore[arg-type] - ) - ), - Start( - id="about_id", - text=Const("Информация"), - state=EntitySG.list_, - data={ - "message": "Информация о проекте", - "entity_type": EntityType.INFORMATION, - }, - ), - state=state, - getter=self.get_entities_data, - ) - - async def get_entities_data( - self, storage: Storage, **kwargs: Any - ) -> dict[str, Any]: - entities = await storage.get_entities() - return { - "entities": list( - filter(lambda x: x.type == self.entity_type, entities.values()) - ), - } - - async def open_message( - self, - c: CallbackQuery, - widget: Button, - dialog_manager: DialogManager, - entity_id: int, - ) -> None: - uow: UnitOfWork = dialog_manager.middleware_data["uow"] - entity = await uow.entities.get_entity_by_id(entity_id=entity_id) - dialog_manager.find("scroll_text").widget.text = Format(entity.message) # type: ignore[union-attr] - await dialog_manager.next() diff --git a/inclusive_dance_bot/bot/dialogs/utils/__init__.py b/inclusive_dance_bot/bot/dialogs/utils/__init__.py index e69de29..b0d7c68 100644 --- a/inclusive_dance_bot/bot/dialogs/utils/__init__.py +++ b/inclusive_dance_bot/bot/dialogs/utils/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["sync_scroll", "start_with_data"] + +from inclusive_dance_bot.bot.dialogs.utils.start_with_data import start_with_data +from inclusive_dance_bot.bot.dialogs.utils.sync_scroll import sync_scroll diff --git a/inclusive_dance_bot/bot/dialogs/utils/buttons.py b/inclusive_dance_bot/bot/dialogs/utils/buttons.py new file mode 100644 index 0000000..575a322 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/utils/buttons.py @@ -0,0 +1,5 @@ +from aiogram_dialog.widgets.kbd import Back, Cancel +from aiogram_dialog.widgets.text import Const + +BACK = Back(text=Const("Назад")) +CANCEL = Cancel(text=Const("Назад")) diff --git a/inclusive_dance_bot/bot/dialogs/utils/getters.py b/inclusive_dance_bot/bot/dialogs/utils/getters.py new file mode 100644 index 0000000..f380914 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/utils/getters.py @@ -0,0 +1,21 @@ +from typing import Any + +from aiogram_dialog import DialogManager + +from inclusive_dance_bot.logic.storage import Storage + + +async def get_url_data( + storage: Storage, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + return {"url": await storage.get_url_by_slug(dialog_manager.start_data["url_slug"])} + + +async def get_submenu_data( + storage: Storage, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + return { + "submenu": await storage.get_submenu_by_id( + dialog_manager.start_data["submenu_id"] + ) + } diff --git a/inclusive_dance_bot/bot/dialogs/utils/input_form_field.py b/inclusive_dance_bot/bot/dialogs/utils/input_form_field.py index 67a1bdd..0b6ea90 100644 --- a/inclusive_dance_bot/bot/dialogs/utils/input_form_field.py +++ b/inclusive_dance_bot/bot/dialogs/utils/input_form_field.py @@ -26,7 +26,9 @@ def __init__( super().__init__( Format("{message}"), TextInput( - id="text_input", on_success=self.on_success, type_factory=type_factory # type: ignore[arg-type] + id="text_input", + on_success=self.on_success, # type: ignore[arg-type] + type_factory=type_factory, ), Cancel(text=Const("Отмена"), when=F["is_first"]), Back(text=Const("Назад"), when=~F["is_first"]), diff --git a/inclusive_dance_bot/bot/dialogs/utils/start_with_data.py b/inclusive_dance_bot/bot/dialogs/utils/start_with_data.py new file mode 100644 index 0000000..eb272aa --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/utils/start_with_data.py @@ -0,0 +1,19 @@ +from collections.abc import Awaitable, Callable + +from aiogram.fsm.state import State +from aiogram.types import CallbackQuery +from aiogram_dialog.api.entities import StartMode +from aiogram_dialog.api.protocols import DialogManager +from aiogram_dialog.widgets.kbd.button import Button + + +def start_with_data( + state: State, field: str, mode: StartMode = StartMode.NORMAL +) -> Callable[[CallbackQuery, Button, DialogManager], Awaitable[None]]: + async def on_click( + callback: CallbackQuery, button: Button, manager: DialogManager + ) -> None: + data = {field: manager.dialog_data.get(field)} + await manager.start(state, data, mode=StartMode.NORMAL) + + return on_click diff --git a/inclusive_dance_bot/bot/dialogs/users/windows/entity.py b/inclusive_dance_bot/bot/dialogs/utils/submenu_window.py similarity index 65% rename from inclusive_dance_bot/bot/dialogs/users/windows/entity.py rename to inclusive_dance_bot/bot/dialogs/utils/submenu_window.py index 80f72c3..498c919 100644 --- a/inclusive_dance_bot/bot/dialogs/users/windows/entity.py +++ b/inclusive_dance_bot/bot/dialogs/utils/submenu_window.py @@ -2,18 +2,19 @@ from aiogram.fsm.state import State from aiogram_dialog import DialogManager, Window -from aiogram_dialog.widgets.kbd import Back, NumberedPager -from aiogram_dialog.widgets.text import Const, Format, ScrollingText +from aiogram_dialog.widgets.kbd import NumberedPager +from aiogram_dialog.widgets.text import Format, ScrollingText -from inclusive_dance_bot.services.storage import Storage +from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from inclusive_dance_bot.logic.storage import Storage -class EntityWindow(Window): - def __init__(self, state: State): +class SubmenuWindow(Window): + def __init__(self, state: State) -> None: super().__init__( ScrollingText(text=Format("default"), id="scroll_text", page_size=1000), NumberedPager(scroll="scroll_text", when=when_), # type: ignore[arg-type] - Back(text=Const("Назад")), + BACK, state=state, getter=self.get_urls, ) diff --git a/inclusive_dance_bot/bot/dialogs/utils/sync_scroll.py b/inclusive_dance_bot/bot/dialogs/utils/sync_scroll.py new file mode 100644 index 0000000..fdd1c50 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/utils/sync_scroll.py @@ -0,0 +1,21 @@ +from collections.abc import Awaitable, Callable + +from aiogram_dialog import ChatEvent, DialogManager +from aiogram_dialog.widgets.common.scroll import ManagedScroll + + +def sync_scroll( + scroll_id: str, +) -> Callable[[ChatEvent, ManagedScroll, DialogManager], Awaitable[None],]: + async def on_page_changed( + event: ChatEvent, + widget: ManagedScroll, + dialog_manager: DialogManager, + ) -> None: + page = await widget.get_page() + other_scroll: ManagedScroll = dialog_manager.find( + scroll_id + ) # type: ignore[assignment] + await other_scroll.set_page(page=page) + + return on_page_changed diff --git a/inclusive_dance_bot/bot/dialogs/utils/validators.py b/inclusive_dance_bot/bot/dialogs/utils/validators.py new file mode 100644 index 0000000..719c232 --- /dev/null +++ b/inclusive_dance_bot/bot/dialogs/utils/validators.py @@ -0,0 +1,25 @@ +from collections.abc import Callable +from datetime import date, datetime, time + + +def validate_length(length: int) -> Callable[[str], str]: + def validator(value: str) -> str: + if len(value) > length: + raise ValueError + return value + + return validator + + +def validate_date(date_format: str = "%d.%m.%Y") -> Callable[[str], date]: + def validator(value: str) -> date: + return datetime.strptime(value, date_format).date() + + return validator + + +def validate_time(time_format: str = "%H:%M") -> Callable[[str], time]: + def validator(value: str) -> time: + return datetime.strptime(value, time_format).time() + + return validator diff --git a/inclusive_dance_bot/bot/factory.py b/inclusive_dance_bot/bot/factory.py index 0e1fc71..ee0364c 100644 --- a/inclusive_dance_bot/bot/factory.py +++ b/inclusive_dance_bot/bot/factory.py @@ -7,14 +7,14 @@ from inclusive_dance_bot.config import Settings -def create_bot(settings: Settings) -> Bot: +def get_bot(settings: Settings) -> Bot: return Bot( token=settings.TELEGRAM_BOT_TOKEN.get_secret_value(), parse_mode=ParseMode.HTML, ) -def create_storage(settings: Settings) -> BaseStorage: +def get_storage(settings: Settings) -> BaseStorage: if settings.DEBUG: return MemoryStorage() return RedisStorage.from_url( diff --git a/inclusive_dance_bot/bot/middlewares/storage.py b/inclusive_dance_bot/bot/middlewares/storage.py index 68227b6..b802215 100644 --- a/inclusive_dance_bot/bot/middlewares/storage.py +++ b/inclusive_dance_bot/bot/middlewares/storage.py @@ -4,7 +4,7 @@ from aiogram import BaseMiddleware from aiogram.types import TelegramObject, Update -from inclusive_dance_bot.services.storage import Storage +from inclusive_dance_bot.logic.storage import Storage class StorageMiddleware(BaseMiddleware): diff --git a/inclusive_dance_bot/bot/middlewares/user.py b/inclusive_dance_bot/bot/middlewares/user.py new file mode 100644 index 0000000..3ae5ad4 --- /dev/null +++ b/inclusive_dance_bot/bot/middlewares/user.py @@ -0,0 +1,28 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Update +from aiogram.types import User as AiogramUser + +from inclusive_dance_bot.config import Settings +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.user import MegaUser + + +class UserMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: Update, # type: ignore[override] + data: dict[str, Any], + ) -> Any: + aiogram_user: AiogramUser = data["event_from_user"] + uow: UnitOfWork = data["uow"] + settings: Settings = data["settings"] + data["user"] = MegaUser( + aiogram_user=aiogram_user, + user=await uow.users.get_by_id(aiogram_user.id), + superuser_ids=settings.TELEGRAM_BOT_ADMIN_IDS, + ) + return await handler(event, data) diff --git a/inclusive_dance_bot/config.py b/inclusive_dance_bot/config.py index 9d70911..754f574 100644 --- a/inclusive_dance_bot/config.py +++ b/inclusive_dance_bot/config.py @@ -5,7 +5,10 @@ class Settings(BaseSettings): DEBUG: bool = False TELEGRAM_BOT_TOKEN: SecretStr = SecretStr("default") - TELEGRAM_BOT_ADMIN_IDS: list[int] + TELEGRAM_BOT_ADMIN_IDS: list[int] = [] + + PERIODIC_INTERVAL: int = 5 * 60 # 5 minutes + MAILING_GAP: int = 2 * 60 # 2 minutes POSTGRES_HOST: str POSTGRES_PORT: int diff --git a/inclusive_dance_bot/db/factory.py b/inclusive_dance_bot/db/factory.py index 68200de..60da829 100644 --- a/inclusive_dance_bot/db/factory.py +++ b/inclusive_dance_bot/db/factory.py @@ -9,7 +9,7 @@ def create_engine(connection_uri: str, **engine_kwargs: Any) -> AsyncEngine: - return create_async_engine(url=connection_uri, **engine_kwargs) + return create_async_engine(url=connection_uri, **engine_kwargs, pool_pre_ping=True) def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: diff --git a/inclusive_dance_bot/db/migrations/versions/2023_10_21_daabaf35a8a3_initial_commit.py b/inclusive_dance_bot/db/migrations/versions/2023_11_14_0228a02c3bb6_initial_commit.py similarity index 69% rename from inclusive_dance_bot/db/migrations/versions/2023_10_21_daabaf35a8a3_initial_commit.py rename to inclusive_dance_bot/db/migrations/versions/2023_11_14_0228a02c3bb6_initial_commit.py index b8adf76..4348467 100644 --- a/inclusive_dance_bot/db/migrations/versions/2023_10_21_daabaf35a8a3_initial_commit.py +++ b/inclusive_dance_bot/db/migrations/versions/2023_11_14_0228a02c3bb6_initial_commit.py @@ -1,15 +1,15 @@ """Initial commit -Revision ID: daabaf35a8a3 +Revision ID: 0228a02c3bb6 Revises: -Create Date: 2023-10-21 22:37:41.839949 +Create Date: 2023-11-14 18:36:06.158969 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. -revision = "daabaf35a8a3" +revision = "0228a02c3bb6" down_revision = None branch_labels = None depends_on = None @@ -18,11 +18,37 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "entity", + "mailing", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("title", sa.String(length=512), nullable=False), + sa.Column("content", sa.String(length=3072), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', now())"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', now())"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk__mailing")), + ) + op.create_index( + op.f("ix__mailing__scheduled_at"), "mailing", ["scheduled_at"], unique=False + ) + op.create_table( + "submenu", sa.Column("id", sa.Integer(), nullable=False), sa.Column("type", sa.String(length=32), nullable=False), sa.Column("weight", sa.Integer(), nullable=False), - sa.Column("text", sa.String(length=64), nullable=False), + sa.Column("button_text", sa.String(length=64), nullable=False), sa.Column("message", sa.String(length=4000), nullable=False), sa.Column( "created_at", @@ -36,7 +62,7 @@ def upgrade() -> None: server_default=sa.text("TIMEZONE('utc', now())"), nullable=False, ), - sa.PrimaryKeyConstraint("id", name=op.f("pk__entity")), + sa.PrimaryKeyConstraint("id", name=op.f("pk__submenu")), ) op.create_table( "url", @@ -68,6 +94,7 @@ def upgrade() -> None: op.create_table( "users", sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("username", sa.String(length=256), nullable=False), sa.Column("name", sa.String(length=256), nullable=False), sa.Column("region", sa.String(length=256), nullable=False), sa.Column("phone_number", sa.String(length=16), nullable=False), @@ -85,6 +112,7 @@ def upgrade() -> None: nullable=False, ), sa.PrimaryKeyConstraint("id", name=op.f("pk__users")), + sa.UniqueConstraint("username", name=op.f("uq__users__username")), ) op.create_table( "feedback", @@ -117,6 +145,24 @@ def upgrade() -> None: op.create_index( op.f("ix__feedback__user_id"), "feedback", ["user_id"], unique=False ) + op.create_table( + "mailing_user_type", + sa.Column("mailing_id", sa.Integer(), nullable=False), + sa.Column("user_type_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["mailing_id"], + ["mailing.id"], + name=op.f("fk__mailing_user_type__mailing_id__mailing"), + ), + sa.ForeignKeyConstraint( + ["user_type_id"], + ["user_type.id"], + name=op.f("fk__mailing_user_type__user_type_id__user_type"), + ), + sa.PrimaryKeyConstraint( + "mailing_id", "user_type_id", name=op.f("pk__mailing_user_type") + ), + ) op.create_table( "user_type_user", sa.Column("user_id", sa.BigInteger(), nullable=False), @@ -139,11 +185,14 @@ def upgrade() -> None: def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.drop_table("user_type_user") + op.drop_table("mailing_user_type") op.drop_index(op.f("ix__feedback__user_id"), table_name="feedback") op.drop_table("feedback") op.drop_table("users") op.drop_index(op.f("ix__user_type__name"), table_name="user_type") op.drop_table("user_type") op.drop_table("url") - op.drop_table("entity") + op.drop_table("submenu") + op.drop_index(op.f("ix__mailing__scheduled_at"), table_name="mailing") + op.drop_table("mailing") # ### end Alembic commands ### diff --git a/inclusive_dance_bot/db/models.py b/inclusive_dance_bot/db/models.py index 5ddac69..db4e2f8 100644 --- a/inclusive_dance_bot/db/models.py +++ b/inclusive_dance_bot/db/models.py @@ -6,12 +6,13 @@ from inclusive_dance_bot.db.base import Base from inclusive_dance_bot.db.mixins import TimestampMixin -from inclusive_dance_bot.enums import EntityType, FeedbackType +from inclusive_dance_bot.enums import FeedbackType, MailingStatus, SubmenuType class User(TimestampMixin, Base): __tablename__ = "users" # type: ignore[assignment] id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + username: Mapped[str] = mapped_column(String(256), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(256), nullable=False, default="") region: Mapped[str] = mapped_column(String(256), nullable=False, default="") phone_number: Mapped[str] = mapped_column(String(16), nullable=False, default="") @@ -40,13 +41,13 @@ class UserTypeUser(Base): ) -class Entity(TimestampMixin, Base): +class Submenu(TimestampMixin, Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) - type: Mapped[EntityType] = mapped_column( - ChoiceType(choices=EntityType, impl=String(32)), nullable=False + type: Mapped[SubmenuType] = mapped_column( + ChoiceType(choices=SubmenuType, impl=String(32)), nullable=False ) weight: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - text: Mapped[str] = mapped_column(String(64), nullable=False) + button_text: Mapped[str] = mapped_column(String(64), nullable=False) message: Mapped[str] = mapped_column(String(4000), nullable=False) @@ -74,3 +75,36 @@ class Feedback(TimestampMixin, Base): answered_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) + + +class Mailing(TimestampMixin, Base): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + scheduled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + status: Mapped[MailingStatus] = mapped_column( + ChoiceType(choices=MailingStatus, impl=String(16)), + nullable=False, + default=MailingStatus.SCHEDULED, + ) + sent_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + cancelled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + title: Mapped[str] = mapped_column(String(512), nullable=False, default="") + content: Mapped[str] = mapped_column(String(3072), nullable=False, default="") + + user_types: Mapped[list["UserType"]] = relationship( + "UserType", secondary="mailing_user_type" + ) + + +class MailingUserType(Base): + mailing_id: Mapped[int] = mapped_column( + Integer, ForeignKey("mailing.id"), primary_key=True + ) + user_type_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user_type.id"), primary_key=True + ) diff --git a/inclusive_dance_bot/db/repositories/base.py b/inclusive_dance_bot/db/repositories/base.py index 7ce45b8..23b73e7 100644 --- a/inclusive_dance_bot/db/repositories/base.py +++ b/inclusive_dance_bot/db/repositories/base.py @@ -20,17 +20,14 @@ async def _get_by_id_or_none(self, obj_id: int) -> Model | None: return await self._session.get(self._model, obj_id) async def _get_by_id(self, obj_id: int) -> Model: - obj = await self._get_by_id_or_none(obj_id=obj_id) - if obj is None: - raise EntityNotFoundError - return obj + return await self._session.get_one(self._model, obj_id) async def _update(self, *args: Any, **kwargs: Any) -> Model: query = update(self._model).where(*args).values(**kwargs).returning(self._model) result = await self._session.scalars(select(self._model).from_statement(query)) - await self._session.commit() try: obj = result.one() + await self._session.flush(obj) except NoResultFound as e: raise EntityNotFoundError from e await self._session.refresh(obj) diff --git a/inclusive_dance_bot/db/repositories/entity.py b/inclusive_dance_bot/db/repositories/entity.py deleted file mode 100644 index 413d7a1..0000000 --- a/inclusive_dance_bot/db/repositories/entity.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import NoReturn - -from sqlalchemy import ScalarResult, desc, insert, select -from sqlalchemy.exc import DBAPIError, IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import Entity -from inclusive_dance_bot.db.repositories.base import Repository -from inclusive_dance_bot.dto import EntityDto -from inclusive_dance_bot.enums import EntityType -from inclusive_dance_bot.exceptions import EntityAlreadyExistsError, InclusiveDanceError - - -class EntityRepository(Repository[Entity]): - def __init__(self, session: AsyncSession) -> None: - super().__init__(model=Entity, session=session) - - async def create( - self, - type: EntityType, - text: str, - message: str, - weight: int = 0, - id: int | None = None, - ) -> EntityDto: - data = dict(type=type, text=text, message=message, weight=weight) - if id is not None: - data["id"] = id - stmt = insert(Entity).values(**data).returning(Entity) - try: - result: ScalarResult[Entity] = await self._session.scalars(stmt) - except IntegrityError as e: - self._raise_error(e) - else: - await self._session.flush() - return EntityDto.from_orm(result.one()) - - async def get_entity_by_id(self, entity_id: int) -> EntityDto: - obj = await self._get_by_id(entity_id) - return EntityDto.from_orm(obj) - - async def get_all_entities(self) -> tuple[EntityDto, ...]: - query = select(Entity).order_by(desc(Entity.weight), Entity.id) - objs = (await self._session.scalars(query)).all() - return tuple(EntityDto.from_orm(obj) for obj in objs) - - async def get_entities_by_type(self, entity_type: EntityType) -> list[EntityDto]: - stmt = ( - select(Entity) - .where(Entity.type == entity_type) - .order_by(desc(Entity.weight), Entity.id) - ) - - objs = (await self._session.scalars(stmt)).all() - return [EntityDto.from_orm(obj) for obj in objs] - - def _raise_error(self, e: DBAPIError) -> NoReturn: - constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] - if constraint == "pk__entity": - raise EntityAlreadyExistsError from e - raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/feedback.py b/inclusive_dance_bot/db/repositories/feedback.py index b44954c..fe265aa 100644 --- a/inclusive_dance_bot/db/repositories/feedback.py +++ b/inclusive_dance_bot/db/repositories/feedback.py @@ -38,6 +38,6 @@ async def create( def _raise_error(self, e: DBAPIError) -> NoReturn: constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] - if constraint == "fk__feedback__user_id_users": + if constraint == "fk__feedback__user_id__users": raise InvalidUserIDError from e raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/mailing.py b/inclusive_dance_bot/db/repositories/mailing.py new file mode 100644 index 0000000..8d93b9d --- /dev/null +++ b/inclusive_dance_bot/db/repositories/mailing.py @@ -0,0 +1,126 @@ +from datetime import datetime, timedelta +from typing import Any, NoReturn + +from sqlalchemy import insert, select, update +from sqlalchemy.exc import DBAPIError, IntegrityError, NoResultFound +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from inclusive_dance_bot.db.models import Mailing, MailingUserType +from inclusive_dance_bot.db.repositories.base import Repository +from inclusive_dance_bot.dto import MailingDto +from inclusive_dance_bot.enums import MailingStatus +from inclusive_dance_bot.exceptions import ( + EntityNotFoundError, + InclusiveDanceError, + MailingNotFoundError, +) + + +class MailingRepository(Repository[Mailing]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(model=Mailing, session=session) + + async def get_by_id(self, mailing_id: int) -> MailingDto: + try: + obj = await self._session.get_one( + Mailing, mailing_id, options=(selectinload(Mailing.user_types),) + ) + return MailingDto.from_orm(obj) + except NoResultFound as e: + raise MailingNotFoundError from e + + async def create( + self, + *, + title: str, + content: str, + scheduled_at: datetime | None, + status: MailingStatus, + sent_at: datetime | None, + ) -> MailingDto: + stmt = ( + insert(Mailing) + .values( + title=title, + content=content, + scheduled_at=scheduled_at, + status=status, + sent_at=sent_at, + ) + .returning(Mailing) + .options(selectinload(Mailing.user_types)) + ) + try: + result = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + else: + await self._session.flush() + return MailingDto.from_orm(result.one()) + + async def create_mailing_user_type( + self, *, mailing_id: int, user_type_id: int + ) -> MailingUserType: + stmt = ( + insert(MailingUserType) + .values(mailing_id=mailing_id, user_type_id=user_type_id) + .returning(MailingUserType) + ) + try: + result = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + else: + await self._session.flush() + return result.one() + + async def get_new_mailings( + self, now: datetime | None = None, gap: int | None = None + ) -> list[MailingDto]: + return await self.get_mailings( + now, gap, Mailing.status == MailingStatus.SCHEDULED + ) + + async def get_archive_mailings(self) -> list[MailingDto]: + return await self.get_mailings( + None, None, Mailing.status != MailingStatus.SCHEDULED + ) + + async def get_mailings( + self, now: datetime | None = None, gap: int | None = None, *args: Any + ) -> list[MailingDto]: + stmt = ( + select(Mailing) + .options(selectinload(Mailing.user_types)) + .order_by(Mailing.created_at) + ) + for arg in args: + stmt = stmt.where(arg) + if gap is not None and now is not None: + stmt = stmt.where(Mailing.scheduled_at < now + timedelta(seconds=gap)) + + result = await self._session.scalars(stmt) + return [MailingDto.from_orm(obj) for obj in result] + + async def update_by_id(self, mailing_id: int, **kwargs: Any) -> MailingDto: + obj = await self._update(Mailing.id == mailing_id, **kwargs) + return MailingDto.from_orm(obj) + + async def _update(self, *args: Any, **kwargs: Any) -> Mailing: + query = update(self._model).where(*args).values(**kwargs).returning(self._model) + result = await self._session.scalars( + select(self._model) + .from_statement(query) + .options(selectinload(Mailing.user_types)) + ) + try: + obj = result.one() + await self._session.flush(obj) + except NoResultFound as e: + raise EntityNotFoundError from e + await self._session.refresh(obj) + return obj + + def _raise_error(self, e: DBAPIError) -> NoReturn: + raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/submenu.py b/inclusive_dance_bot/db/repositories/submenu.py new file mode 100644 index 0000000..03ce2f2 --- /dev/null +++ b/inclusive_dance_bot/db/repositories/submenu.py @@ -0,0 +1,82 @@ +from typing import Any, NoReturn + +from sqlalchemy import ScalarResult, delete, desc, insert, select +from sqlalchemy.exc import DBAPIError, IntegrityError, NoResultFound +from sqlalchemy.ext.asyncio import AsyncSession + +from inclusive_dance_bot.db.models import Submenu +from inclusive_dance_bot.db.repositories.base import Repository +from inclusive_dance_bot.dto import SubmenuDto +from inclusive_dance_bot.enums import SubmenuType +from inclusive_dance_bot.exceptions import ( + InclusiveDanceError, + SubmenuAlreadyExistsError, + SubmenuNotFoundError, +) +from inclusive_dance_bot.exceptions.base import EntityNotFoundError + + +class SubmenuRepository(Repository[Submenu]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(model=Submenu, session=session) + + async def create( + self, + type: SubmenuType, + button_text: str, + message: str, + weight: int = 0, + id: int | None = None, + ) -> SubmenuDto: + data = dict(type=type, button_text=button_text, message=message, weight=weight) + if id is not None: + data["id"] = id + stmt = insert(Submenu).values(**data).returning(Submenu) + try: + result: ScalarResult[Submenu] = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + else: + await self._session.flush() + return SubmenuDto.from_orm(result.one()) + + async def get_by_id(self, submenu_id: int) -> SubmenuDto: + try: + obj = await self._get_by_id(submenu_id) + return SubmenuDto.from_orm(obj) + except NoResultFound as e: + raise SubmenuNotFoundError from e + + async def update_by_id(self, submenu_id: int, **kwargs: Any) -> SubmenuDto: + try: + submenu = await self._update(Submenu.id == submenu_id, **kwargs) + except EntityNotFoundError as e: + raise SubmenuNotFoundError from e + except IntegrityError as e: + self._raise_error(e) + return SubmenuDto.from_orm(submenu) + + async def delete_by_id(self, submenu_id: int) -> None: + stmt = delete(Submenu).where(Submenu.id == submenu_id) + await self._session.execute(stmt) + + async def get_list(self) -> tuple[SubmenuDto, ...]: + query = select(Submenu).order_by(desc(Submenu.weight), Submenu.id) + objs = (await self._session.scalars(query)).all() + return tuple(SubmenuDto.from_orm(obj) for obj in objs) + + async def get_list_by_type(self, submenu_type: SubmenuType) -> list[SubmenuDto]: + stmt = ( + select(Submenu) + .where(Submenu.type == submenu_type) + .order_by(desc(Submenu.weight), Submenu.id) + ) + + objs = (await self._session.scalars(stmt)).all() + return [SubmenuDto.from_orm(obj) for obj in objs] + + def _raise_error(self, e: DBAPIError) -> NoReturn: + constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] + if constraint == "pk__submenu": + raise SubmenuAlreadyExistsError from e + raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/url.py b/inclusive_dance_bot/db/repositories/url.py index 1647c17..823b6c4 100644 --- a/inclusive_dance_bot/db/repositories/url.py +++ b/inclusive_dance_bot/db/repositories/url.py @@ -1,6 +1,6 @@ -from typing import NoReturn +from typing import Any, NoReturn -from sqlalchemy import ScalarResult, insert, select +from sqlalchemy import ScalarResult, delete, insert, select from sqlalchemy.exc import DBAPIError, IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -12,6 +12,8 @@ UrlAlreadyExistsError, UrlSlugAlreadyExistsError, ) +from inclusive_dance_bot.exceptions.base import EntityNotFoundError +from inclusive_dance_bot.exceptions.url import UrlNotFoundError class UrlRepository(Repository[Url]): @@ -31,7 +33,20 @@ async def create(self, slug: str, value: str, id: int | None = None) -> UrlDto: await self._session.flush() return UrlDto.from_orm(result.one()) - async def get_all_urls(self) -> tuple[UrlDto, ...]: + async def update_by_slug(self, url_slug: str, **kwargs: Any) -> UrlDto: + try: + url = await self._update(Url.slug == url_slug, **kwargs) + except EntityNotFoundError as e: + raise UrlNotFoundError from e + except IntegrityError as e: + self._raise_error(e) + return UrlDto.from_orm(url) + + async def delete_by_slug(self, url_slug: str) -> None: + stmt = delete(Url).where(Url.slug == url_slug) + await self._session.execute(stmt) + + async def get_list(self) -> tuple[UrlDto, ...]: stmt = select(Url).order_by(Url.slug) objs = (await self._session.scalars(stmt)).all() return tuple(UrlDto.from_orm(obj) for obj in objs) diff --git a/inclusive_dance_bot/db/repositories/user.py b/inclusive_dance_bot/db/repositories/user.py index fec40ff..3a7eb27 100644 --- a/inclusive_dance_bot/db/repositories/user.py +++ b/inclusive_dance_bot/db/repositories/user.py @@ -1,13 +1,19 @@ +from collections.abc import Sequence from typing import NoReturn -from sqlalchemy import ScalarResult, insert +from sqlalchemy import ScalarResult, insert, select from sqlalchemy.exc import DBAPIError, IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.models import User +from inclusive_dance_bot.db.models import User, UserType, UserTypeUser from inclusive_dance_bot.db.repositories.base import Repository -from inclusive_dance_bot.dto import UserDto -from inclusive_dance_bot.exceptions import InclusiveDanceError, UserAlreadyExistsError +from inclusive_dance_bot.dto import ANONYMOUS_USER, UserDto, UserTypeDto +from inclusive_dance_bot.exceptions import ( + EntityNotFoundError, + InclusiveDanceError, + UserAlreadyExistsError, + UserNotFoundError, +) class UserRepository(Repository[User]): @@ -15,11 +21,17 @@ def __init__(self, session: AsyncSession) -> None: super().__init__(model=User, session=session) async def create( - self, *, user_id: int, name: str, region: str, phone_number: str + self, *, user_id: int, username: str, name: str, region: str, phone_number: str ) -> UserDto: stmt = ( insert(User) - .values(id=user_id, name=name, region=region, phone_number=phone_number) + .values( + id=user_id, + username=username, + name=name, + region=region, + phone_number=phone_number, + ) .returning(User) ) try: @@ -30,9 +42,48 @@ async def create( await self._session.flush() return UserDto.from_orm(result.one()) - async def get_by_id_or_none(self, user_id: int) -> UserDto | None: + async def get_by_id(self, user_id: int) -> UserDto: obj = await self._get_by_id_or_none(obj_id=user_id) - return UserDto.from_orm(obj) if obj else None + return UserDto.from_orm(obj) if obj else ANONYMOUS_USER + + async def get_admin_list(self) -> list[UserDto]: + stmt = select(User).where(User.is_admin.is_(True)) + return [UserDto.from_orm(obj) for obj in await self._session.scalars(stmt)] + + async def add_to_admins(self, username: str) -> UserDto: + try: + user = await self._update( + User.username == username, User.is_admin.is_(False), is_admin=True + ) + return UserDto.from_orm(user) + except EntityNotFoundError as e: + raise UserNotFoundError from e + + async def delete_from_admins(self, user_id: int) -> UserDto: + try: + user = await self._update( + User.id == user_id, User.is_admin.is_(True), is_admin=False + ) + return UserDto.from_orm(user) + except EntityNotFoundError as e: + raise UserNotFoundError from e + + async def get_list_by_user_types( + self, user_types: Sequence[UserTypeDto], ignore_admins: bool = True + ) -> list[UserDto]: + stmt = select(User) + if len(user_types) != 0: + stmt = ( + stmt.distinct(User.id) + .join(UserTypeUser, User.id == UserTypeUser.user_id) + .join(UserType, UserTypeUser.user_type_id == UserType.id) + .where(UserType.name.in_([ut.name for ut in user_types])) + ) + + if ignore_admins: + stmt = stmt.where(User.is_admin.is_(False)) + result = await self._session.scalars(stmt) + return [UserDto.from_orm(obj) for obj in result] def _raise_error(self, e: DBAPIError) -> NoReturn: constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] diff --git a/inclusive_dance_bot/db/repositories/user_type.py b/inclusive_dance_bot/db/repositories/user_type.py index 497bf7d..3007363 100644 --- a/inclusive_dance_bot/db/repositories/user_type.py +++ b/inclusive_dance_bot/db/repositories/user_type.py @@ -30,7 +30,7 @@ async def create(self, *, name: str, id: int | None = None) -> UserTypeDto: await self._session.flush() return UserTypeDto.from_orm(result.one()) - async def get_all_user_types(self) -> tuple[UserTypeDto, ...]: + async def get_list(self) -> tuple[UserTypeDto, ...]: stmt = select(UserType).order_by(UserType.id) return tuple( UserTypeDto.from_orm(obj) diff --git a/inclusive_dance_bot/db/uow/main.py b/inclusive_dance_bot/db/uow/main.py index 0e9d37c..102ed95 100644 --- a/inclusive_dance_bot/db/uow/main.py +++ b/inclusive_dance_bot/db/uow/main.py @@ -1,10 +1,12 @@ +import asyncio from types import TracebackType from typing import Self from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from inclusive_dance_bot.db.repositories.entity import EntityRepository from inclusive_dance_bot.db.repositories.feedback import FeedbackRepository +from inclusive_dance_bot.db.repositories.mailing import MailingRepository +from inclusive_dance_bot.db.repositories.submenu import SubmenuRepository from inclusive_dance_bot.db.repositories.url import UrlRepository from inclusive_dance_bot.db.repositories.user import UserRepository from inclusive_dance_bot.db.repositories.user_type import UserTypeRepository @@ -18,8 +20,9 @@ def __init__(self, sessionmaker: async_sessionmaker[AsyncSession]) -> None: async def __aenter__(self) -> Self: self._session = self._sessionmaker() - self.entities = EntityRepository(self._session) + self.submenus = SubmenuRepository(self._session) self.feedbacks = FeedbackRepository(self._session) + self.mailings = MailingRepository(self._session) self.urls = UrlRepository(self._session) self.users = UserRepository(self._session) self.user_types = UserTypeRepository(self._session) @@ -33,7 +36,8 @@ async def __aexit__( traceback: TracebackType, ) -> None: await self._session.rollback() - await self._session.close() + task = asyncio.create_task(self._session.close()) + await asyncio.shield(task) async def commit(self) -> None: await self._session.commit() diff --git a/inclusive_dance_bot/deps.py b/inclusive_dance_bot/deps.py new file mode 100644 index 0000000..01c4215 --- /dev/null +++ b/inclusive_dance_bot/deps.py @@ -0,0 +1,39 @@ +from collections.abc import AsyncGenerator + +from aiogram import Bot +from aiomisc_dependency import dependency +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from inclusive_dance_bot.bot.factory import get_bot +from inclusive_dance_bot.config import Settings +from inclusive_dance_bot.db.factory import create_engine, create_session_factory +from inclusive_dance_bot.db.uow.main import UnitOfWork + + +def config_deps(app_settings: Settings) -> None: + @dependency + async def settings() -> Settings: + return app_settings + + @dependency + async def bot(settings: Settings) -> Bot: + return get_bot(settings=settings) + + @dependency + async def engine(settings: Settings) -> AsyncGenerator[AsyncEngine, None]: + engine = create_engine( + connection_uri=settings.build_db_connection_uri(), + echo=settings.DEBUG, + ) + yield engine + await engine.dispose() + + @dependency + async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return create_session_factory(engine=engine) + + @dependency + async def uow(session_factory: async_sessionmaker[AsyncSession]) -> UnitOfWork: + return UnitOfWork(sessionmaker=session_factory) + + return diff --git a/inclusive_dance_bot/dto.py b/inclusive_dance_bot/dto.py index 8955d7a..cfbd0eb 100644 --- a/inclusive_dance_bot/dto.py +++ b/inclusive_dance_bot/dto.py @@ -1,19 +1,29 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING -from inclusive_dance_bot.enums import EntityType, FeedbackType +from inclusive_dance_bot.db.models import Mailing +from inclusive_dance_bot.enums import FeedbackType, MailingStatus, SubmenuType if TYPE_CHECKING: - from inclusive_dance_bot.db.models import Entity, Feedback, Url, User, UserType + from inclusive_dance_bot.db.models import ( + Feedback, + Mailing, + Submenu, + Url, + User, + UserType, + ) -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class UserDto: id: int name: str + username: str region: str phone_number: str is_admin: bool @@ -23,13 +33,24 @@ def from_orm(cls, obj: User) -> UserDto: return cls( id=obj.id, name=obj.name, + username=obj.username, region=obj.name, phone_number=obj.phone_number, is_admin=obj.is_admin, ) -@dataclass(frozen=True) +ANONYMOUS_USER = UserDto( + id=0, + name="Anonymous", + region="Earth", + phone_number="", + is_admin=False, + username="anonymous", +) + + +@dataclass(frozen=True, slots=True) class UserTypeDto: id: int name: str @@ -39,26 +60,26 @@ def from_orm(cls, obj: UserType) -> UserTypeDto: return cls(id=obj.id, name=obj.name) -@dataclass(frozen=True) -class EntityDto: +@dataclass(frozen=True, slots=True) +class SubmenuDto: id: int - type: EntityType + type: SubmenuType weight: int - text: str + button_text: str message: str @classmethod - def from_orm(cls, obj: Entity) -> EntityDto: + def from_orm(cls, obj: Submenu) -> SubmenuDto: return cls( id=obj.id, type=obj.type, weight=obj.weight, - text=obj.text, + button_text=obj.button_text, message=obj.message, ) -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class UrlDto: id: int slug: str @@ -72,7 +93,7 @@ def __str__(self) -> str: return self.value -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class FeedbackDto: id: int user_id: int @@ -97,3 +118,32 @@ def from_orm(cls, obj: Feedback) -> FeedbackDto: is_answered=obj.is_answered, answered_at=obj.answered_at, ) + + +@dataclass(frozen=True, slots=True) +class MailingDto: + created_at: datetime + updated_at: datetime + id: int + scheduled_at: datetime | None + sent_at: datetime | None + cancelled_at: datetime | None + status: MailingStatus + title: str + content: str + user_types: Sequence[UserTypeDto] + + @classmethod + def from_orm(cls, obj: Mailing) -> MailingDto: + return cls( + created_at=obj.created_at, + updated_at=obj.updated_at, + id=obj.id, + scheduled_at=obj.scheduled_at, + cancelled_at=obj.cancelled_at, + status=obj.status, + title=obj.title, + content=obj.content, + user_types=tuple(UserTypeDto.from_orm(ut) for ut in obj.user_types), + sent_at=obj.sent_at, + ) diff --git a/inclusive_dance_bot/enums.py b/inclusive_dance_bot/enums.py index c41667c..2cb8e72 100644 --- a/inclusive_dance_bot/enums.py +++ b/inclusive_dance_bot/enums.py @@ -2,13 +2,13 @@ @unique -class EntityType(StrEnum): +class SubmenuType(StrEnum): CHARITY = "CHARITY" EDUCATION = "EDUCATION" ENROLL = "ENROLL" EVENT = "EVENT" INFORMATION = "INFORMATION" - SUBMENU = "SUBMENU" + OTHER = "OTHER" @unique @@ -21,4 +21,26 @@ class FeedbackType(StrEnum): class StorageType(StrEnum): URL = "URL" USER_TYPE = "USER_TYPE" - ENTITY = "ENTITY" + SUBMENU = "SUBMENU" + + +@unique +class FeedbackField(StrEnum): + TITLE = "title" + TEXT = "text" + TYPE = "type" + + +@unique +class RegistrationField(StrEnum): + NAME = "name" + REGION = "region" + PHONE = "phone" + USER_TYPE_IDS = "user_type_ids" + + +@unique +class MailingStatus(StrEnum): + SCHEDULED = "SCHEDULED" + SENT = "SENT" + CANCELLED = "CANCELLED" diff --git a/inclusive_dance_bot/exceptions.py b/inclusive_dance_bot/exceptions.py deleted file mode 100644 index f0345fb..0000000 --- a/inclusive_dance_bot/exceptions.py +++ /dev/null @@ -1,38 +0,0 @@ -class InclusiveDanceError(Exception): - pass - - -class EntityNotFoundError(InclusiveDanceError): - pass - - -class UrlAlreadyExistsError(InclusiveDanceError): - pass - - -class UserTypeAlreadyExistsError(InclusiveDanceError): - pass - - -class UrlSlugAlreadyExistsError(InclusiveDanceError): - pass - - -class EntityAlreadyExistsError(InclusiveDanceError): - pass - - -class UserAlreadyExistsError(InclusiveDanceError): - pass - - -class UserTypeUserAlreadyExistsError(InclusiveDanceError): - pass - - -class InvalidUserIDError(InclusiveDanceError): - pass - - -class InvalidUserTypeIDError(InclusiveDanceError): - pass diff --git a/inclusive_dance_bot/exceptions/__init__.py b/inclusive_dance_bot/exceptions/__init__.py new file mode 100644 index 0000000..32ac10d --- /dev/null +++ b/inclusive_dance_bot/exceptions/__init__.py @@ -0,0 +1,41 @@ +from inclusive_dance_bot.exceptions.base import ( + EntityAlreadyExistsError, + EntityNotFoundError, + InclusiveDanceError, +) +from inclusive_dance_bot.exceptions.mailing import MailingNotFoundError +from inclusive_dance_bot.exceptions.submenu import ( + SubmenuAlreadyExistsError, + SubmenuNotFoundError, +) +from inclusive_dance_bot.exceptions.url import ( + UrlAlreadyExistsError, + UrlNotFoundError, + UrlSlugAlreadyExistsError, +) +from inclusive_dance_bot.exceptions.user import ( + InvalidUserIDError, + InvalidUserTypeIDError, + UserAlreadyExistsError, + UserNotFoundError, + UserTypeAlreadyExistsError, + UserTypeUserAlreadyExistsError, +) + +__all__ = ( + "InclusiveDanceError", + "EntityAlreadyExistsError", + "EntityNotFoundError", + "MailingNotFoundError", + "SubmenuAlreadyExistsError", + "SubmenuNotFoundError", + "UrlAlreadyExistsError", + "UrlSlugAlreadyExistsError", + "UrlNotFoundError", + "UserAlreadyExistsError", + "UserTypeAlreadyExistsError", + "UserNotFoundError", + "UserTypeUserAlreadyExistsError", + "InvalidUserIDError", + "InvalidUserTypeIDError", +) diff --git a/inclusive_dance_bot/exceptions/base.py b/inclusive_dance_bot/exceptions/base.py new file mode 100644 index 0000000..28acb08 --- /dev/null +++ b/inclusive_dance_bot/exceptions/base.py @@ -0,0 +1,10 @@ +class InclusiveDanceError(Exception): + pass + + +class EntityNotFoundError(InclusiveDanceError): + pass + + +class EntityAlreadyExistsError(InclusiveDanceError): + pass diff --git a/inclusive_dance_bot/exceptions/mailing.py b/inclusive_dance_bot/exceptions/mailing.py new file mode 100644 index 0000000..1539855 --- /dev/null +++ b/inclusive_dance_bot/exceptions/mailing.py @@ -0,0 +1,5 @@ +from inclusive_dance_bot.exceptions.base import EntityNotFoundError + + +class MailingNotFoundError(EntityNotFoundError): + pass diff --git a/inclusive_dance_bot/exceptions/submenu.py b/inclusive_dance_bot/exceptions/submenu.py new file mode 100644 index 0000000..9855261 --- /dev/null +++ b/inclusive_dance_bot/exceptions/submenu.py @@ -0,0 +1,12 @@ +from inclusive_dance_bot.exceptions.base import ( + EntityAlreadyExistsError, + EntityNotFoundError, +) + + +class SubmenuNotFoundError(EntityNotFoundError): + pass + + +class SubmenuAlreadyExistsError(EntityAlreadyExistsError): + pass diff --git a/inclusive_dance_bot/exceptions/url.py b/inclusive_dance_bot/exceptions/url.py new file mode 100644 index 0000000..f226023 --- /dev/null +++ b/inclusive_dance_bot/exceptions/url.py @@ -0,0 +1,16 @@ +from inclusive_dance_bot.exceptions.base import ( + EntityAlreadyExistsError, + EntityNotFoundError, +) + + +class UrlAlreadyExistsError(EntityAlreadyExistsError): + pass + + +class UrlSlugAlreadyExistsError(EntityAlreadyExistsError): + pass + + +class UrlNotFoundError(EntityNotFoundError): + pass diff --git a/inclusive_dance_bot/exceptions/user.py b/inclusive_dance_bot/exceptions/user.py new file mode 100644 index 0000000..4212207 --- /dev/null +++ b/inclusive_dance_bot/exceptions/user.py @@ -0,0 +1,29 @@ +from inclusive_dance_bot.exceptions.base import ( + EntityAlreadyExistsError, + EntityNotFoundError, + InclusiveDanceError, +) + + +class UserAlreadyExistsError(EntityAlreadyExistsError): + pass + + +class UserNotFoundError(EntityNotFoundError): + pass + + +class UserTypeUserAlreadyExistsError(EntityAlreadyExistsError): + pass + + +class InvalidUserIDError(InclusiveDanceError): + pass + + +class InvalidUserTypeIDError(InclusiveDanceError): + pass + + +class UserTypeAlreadyExistsError(EntityAlreadyExistsError): + pass diff --git a/inclusive_dance_bot/init_data.py b/inclusive_dance_bot/init_data.py index bc5fbd9..5273f37 100644 --- a/inclusive_dance_bot/init_data.py +++ b/inclusive_dance_bot/init_data.py @@ -4,9 +4,9 @@ from inclusive_dance_bot.config import Settings from inclusive_dance_bot.db.factory import create_engine, create_session_factory from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import EntityType +from inclusive_dance_bot.enums import SubmenuType from inclusive_dance_bot.exceptions import ( - EntityAlreadyExistsError, + SubmenuAlreadyExistsError, UrlAlreadyExistsError, UserTypeAlreadyExistsError, ) @@ -45,77 +45,84 @@ (11, "buy_form_url_7", "https://example.com"), (12, "buy_form_url_8", "https://example.com"), ) -ENTITIES = ( - (1, EntityType.EVENT, "Клуб професионалов Inclusive Dance", "message"), +SUBMENUS = ( + (1, SubmenuType.EVENT, "Клуб професионалов Inclusive Dance", "message"), ( 2, - EntityType.EVENT, + SubmenuType.EVENT, "Фестиваль Inclusive Dance в Москве - октябрь 2023", "message", ), - (3, EntityType.EVENT, "Социальное исследование", "message"), - (4, EntityType.EDUCATION, "Клуб профессионалов Inclusive Dance", "message"), - (5, EntityType.EDUCATION, "Семинары по инклюзивному танцу", "message"), - (6, EntityType.EDUCATION, "Онлайн-курсы по инклюзивному танцу", "message"), - (7, EntityType.ENROLL, 'Студия м. "Авимоторная" (Москва)', "message"), - (8, EntityType.ENROLL, 'Студия м. "Войковская" (Москва)', "message"), - (9, EntityType.CHARITY, "Сделать пожертвование", "message"), - (10, EntityType.CHARITY, "Стать волонтером проекта", "message"), - (11, EntityType.CHARITY, "Стать партнером проекта", "message"), - (12, EntityType.CHARITY, "Рассказать о проекте", "message"), - (13, EntityType.CHARITY, "Организовать показ фильма", "message"), - (14, EntityType.INFORMATION, "Что такое инклюзивный танец?", "message"), - (15, EntityType.INFORMATION, "О проекте Inclusive Dance?", "message"), - (16, EntityType.INFORMATION, "Новости проекта", "message"), + (3, SubmenuType.EVENT, "Социальное исследование", "message"), + (4, SubmenuType.EDUCATION, "Клуб профессионалов Inclusive Dance", "message"), + (5, SubmenuType.EDUCATION, "Семинары по инклюзивному танцу", "message"), + (6, SubmenuType.EDUCATION, "Онлайн-курсы по инклюзивному танцу", "message"), + (7, SubmenuType.ENROLL, 'Студия м. "Авимоторная" (Москва)', "message"), + (8, SubmenuType.ENROLL, 'Студия м. "Войковская" (Москва)', "message"), + (9, SubmenuType.CHARITY, "Сделать пожертвование", "message"), + (10, SubmenuType.CHARITY, "Стать волонтером проекта", "message"), + (11, SubmenuType.CHARITY, "Стать партнером проекта", "message"), + (12, SubmenuType.CHARITY, "Рассказать о проекте", "message"), + (13, SubmenuType.CHARITY, "Организовать показ фильма", "message"), + (14, SubmenuType.INFORMATION, "Что такое инклюзивный танец?", "message"), + (15, SubmenuType.INFORMATION, "О проекте Inclusive Dance?", "message"), + (16, SubmenuType.INFORMATION, "Новости проекта", "message"), ( 17, - EntityType.INFORMATION, + SubmenuType.INFORMATION, 'Документальный фильм "Танцевать под дождем"', - 'Здесь должна быть очень важная информация о фильме и ссылка', + "Здесь должна быть очень важная информация о фильме" + ' и ссылка', ), - (18, EntityType.INFORMATION, "Ссылки на наши ресурсы", "message"), - (19, EntityType.INFORMATION, "Задать вопрос команде", "message"), - (20, EntityType.SUBMENU, "Стать волонтером", "message"), - (21, EntityType.SUBMENU, "Купить билет", "message"), + (18, SubmenuType.INFORMATION, "Ссылки на наши ресурсы", "message"), + (19, SubmenuType.INFORMATION, "Задать вопрос команде", "message"), + (20, SubmenuType.OTHER, "Стать волонтером", "message"), + (21, SubmenuType.OTHER, "Купить билет", "message"), ) -async def init_data() -> None: +async def init_data(uow: UnitOfWork) -> None: log.info("Run init data") - settings = Settings() - engine = create_engine(connection_uri=settings.build_db_connection_uri()) - session_factory = create_session_factory(engine=engine) - uow = UnitOfWork(sessionmaker=session_factory) try: async with uow: for url in URLS: await uow.urls.create(slug=url[1], value=url[2], id=url[0]) await uow.commit() + log.info("Urls successfully initialized") except UrlAlreadyExistsError: - log.info("Urls already in database") + log.warning("Urls already in database") try: async with uow: for user_type in USER_TYPES: await uow.user_types.create(id=user_type[0], name=user_type[1]) await uow.commit() + log.info("UserTypes successfully initialized") except UserTypeAlreadyExistsError: - log.info("UserTypes already in database") + log.warning("UserTypes already in database") try: async with uow: - for entity in ENTITIES: - await uow.entities.create( - id=entity[0], type=entity[1], text=entity[2], message=entity[3] + for submenu in SUBMENUS: + await uow.submenus.create( + id=submenu[0], + type=submenu[1], + button_text=submenu[2], + message=submenu[3], ) await uow.commit() - except EntityAlreadyExistsError: - log.info("Entities already in database") + log.info("Submenu successfully initialized") + except SubmenuAlreadyExistsError: + log.warning("Submenu already in database") log.info("Finish init data") def main() -> None: - asyncio.run(init_data()) + settings = Settings() + engine = create_engine(connection_uri=settings.build_db_connection_uri()) + session_factory = create_session_factory(engine=engine) + uow = UnitOfWork(sessionmaker=session_factory) + asyncio.run(init_data(uow=uow)) if __name__ == "__main__": diff --git a/tests/test_unit/test_services/test_save_feedback.py b/inclusive_dance_bot/logic/__init__.py similarity index 100% rename from tests/test_unit/test_services/test_save_feedback.py rename to inclusive_dance_bot/logic/__init__.py diff --git a/inclusive_dance_bot/logic/feedback.py b/inclusive_dance_bot/logic/feedback.py new file mode 100644 index 0000000..6ca7db3 --- /dev/null +++ b/inclusive_dance_bot/logic/feedback.py @@ -0,0 +1,20 @@ +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.enums import FeedbackType +from inclusive_dance_bot.exceptions.base import InclusiveDanceError + + +async def create_feedback( + uow: UnitOfWork, user_id: int, type: FeedbackType, title: str, text: str +) -> None: + """Создает новую обратную связь от пользователя""" + try: + await uow.feedbacks.create( + user_id=user_id, + type=type, + title=title, + text=text, + ) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e diff --git a/inclusive_dance_bot/logic/mailing.py b/inclusive_dance_bot/logic/mailing.py new file mode 100644 index 0000000..5d00727 --- /dev/null +++ b/inclusive_dance_bot/logic/mailing.py @@ -0,0 +1,77 @@ +import logging +from datetime import datetime, timezone +from typing import Any + +import pytz +from aiogram import Bot +from aiogram.enums import ParseMode + +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.dto import MailingDto +from inclusive_dance_bot.enums import MailingStatus + +log = logging.getLogger(__name__) + + +async def send_mailings( + uow: UnitOfWork, + bot: Bot, + gap: int, +) -> None: + new_mailings = await uow.mailings.get_new_mailings( + gap=gap, now=datetime.now(tz=timezone.utc) + ) + log.info("Found %d new mailings", len(new_mailings)) + for mailing in new_mailings: + await process_new_mailing(uow=uow, bot=bot, mailing=mailing) + + +async def process_new_mailing(uow: UnitOfWork, bot: Bot, mailing: MailingDto) -> None: + log.info("Start process mailing_id=%s", mailing.id) + users = await uow.users.get_list_by_user_types(user_types=mailing.user_types) + for user in users: + try: + await bot.send_message( + chat_id=user.id, + text=f"{mailing.title}\n\n{mailing.content}", + parse_mode=ParseMode.HTML, + ) + except Exception: + log.exception("Occured something") + now = datetime.now(tz=pytz.utc) + await uow.mailings.update_by_id(mailing_id=mailing.id, is_sent=True, sent_at=now) + await uow.commit() + + +async def save_mailing( + uow: UnitOfWork, + bot: Bot, + title: str, + content: str, + scheduled_at: datetime | None, + user_type_ids: list[int], +) -> None: + mailing = await uow.mailings.create( + title=title, + content=content, + scheduled_at=scheduled_at, + status=MailingStatus.SCHEDULED, + sent_at=None, + ) + for user_type_id in user_type_ids: + await uow.mailings.create_mailing_user_type( + mailing_id=mailing.id, user_type_id=user_type_id + ) + await uow.commit() + if scheduled_at is not None: + return + mailing = await uow.mailings.get_by_id(mailing_id=mailing.id) + await process_new_mailing(uow=uow, bot=bot, mailing=mailing) + + +async def update_mailing_by_id( + uow: UnitOfWork, mailing_id: int, **kwargs: Any +) -> MailingDto: + mailing = await uow.mailings.update_by_id(mailing_id=mailing_id, **kwargs) + await uow.commit() + return mailing diff --git a/inclusive_dance_bot/logic/storage.py b/inclusive_dance_bot/logic/storage.py new file mode 100644 index 0000000..b293068 --- /dev/null +++ b/inclusive_dance_bot/logic/storage.py @@ -0,0 +1,86 @@ +from collections.abc import Iterator, MutableMapping +from typing import Any + +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.dto import SubmenuDto, UrlDto, UserTypeDto +from inclusive_dance_bot.enums import StorageType + + +class CacheStorage(MutableMapping): + def __init__(self) -> None: + self.__storage: dict[str, Any] = {} + + def __getitem__(self, key: str) -> Any: + return self.__storage[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.__storage[key] = value + + def __delitem__(self, key: str) -> None: + del self.__storage[key] + + def __len__(self) -> int: + return len(self.__storage) + + def __iter__(self) -> Iterator: + return iter(self.__storage) + + def __repr__(self) -> str: + return repr(self.__storage) + + def clear(self) -> None: + self.__storage.clear() + + +class Storage: + _uow: UnitOfWork + _cache: CacheStorage + + def __init__(self, uow: UnitOfWork) -> None: + self._uow = uow + self._cache = CacheStorage() + + async def get_urls(self) -> dict[str, UrlDto]: + if StorageType.URL not in self._cache: + self._cache[StorageType.URL] = { + url.slug: url for url in await self._uow.urls.get_list() + } + return self._cache[StorageType.URL] + + async def get_url_by_slug(self, slug: str) -> UrlDto: + urls = await self.get_urls() + return urls[slug] + + async def get_user_types(self) -> dict[int, UserTypeDto]: + if StorageType.USER_TYPE not in self._cache: + self._cache[StorageType.USER_TYPE] = { + ut.id: ut for ut in await self._uow.user_types.get_list() + } + return self._cache[StorageType.USER_TYPE] + + async def get_submenus(self) -> dict[int, SubmenuDto]: + if StorageType.SUBMENU not in self._cache: + self._cache[StorageType.SUBMENU] = { + e.id: e for e in await self._uow.submenus.get_list() + } + return self._cache[StorageType.SUBMENU] + + async def get_submenu_by_id(self, submenu_id: int) -> SubmenuDto: + submenus = await self.get_submenus() + return submenus[submenu_id] + + async def refresh_all(self) -> None: + self._cache.clear() + await self.get_urls() + await self.get_submenus() + await self.get_user_types() + + async def refresh_urls(self) -> None: + if StorageType.URL in self._cache: + del self._cache[StorageType.URL] + await self.get_urls() + + async def refresh_submenus(self) -> None: + if StorageType.SUBMENU in self._cache: + del self._cache[StorageType.SUBMENU] + await self.get_submenus() diff --git a/inclusive_dance_bot/logic/submenu.py b/inclusive_dance_bot/logic/submenu.py new file mode 100644 index 0000000..2d40f3c --- /dev/null +++ b/inclusive_dance_bot/logic/submenu.py @@ -0,0 +1,67 @@ +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.dto import SubmenuDto +from inclusive_dance_bot.enums import SubmenuType +from inclusive_dance_bot.exceptions.base import InclusiveDanceError +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.utils import NOT_SET, NotSet + + +async def create_submenu( + uow: UnitOfWork, + storage: Storage, + type: SubmenuType, + message: str, + button_text: str, + weight: int, +) -> SubmenuDto: + try: + submenu = await uow.submenus.create( + type=type, + button_text=button_text, + message=message, + weight=weight, + ) + except InclusiveDanceError as e: + await uow.rollback() + raise e + await uow.commit() + await storage.refresh_submenus() + return submenu + + +async def delete_submenu_by_id( + uow: UnitOfWork, storage: Storage, submenu_id: int +) -> None: + await uow.submenus.delete_by_id(submenu_id=submenu_id) + await uow.commit() + await storage.refresh_submenus() + + +async def update_submenu_by_id( + uow: UnitOfWork, + storage: Storage, + submenu_id: int, + weight: int | NotSet = NOT_SET, + type_: SubmenuType | NotSet = NOT_SET, + button_text: str | NotSet = NOT_SET, + message: str | NotSet = NOT_SET, +) -> SubmenuDto: + data: dict[str, str | int | SubmenuType] = {} + if not isinstance(weight, NotSet): + data["weight"] = weight + if not isinstance(type_, NotSet): + data["type"] = type_ + if not isinstance(button_text, NotSet): + data["button_text"] = button_text + if not isinstance(message, NotSet): + data["message"] = message + if not data: + raise ValueError("Update data is empty!") + try: + submenu = await uow.submenus.update_by_id(submenu_id=submenu_id, **data) + except InclusiveDanceError as e: + await uow.rollback() + raise e + await uow.commit() + await storage.refresh_submenus() + return submenu diff --git a/inclusive_dance_bot/logic/url.py b/inclusive_dance_bot/logic/url.py new file mode 100644 index 0000000..54b483e --- /dev/null +++ b/inclusive_dance_bot/logic/url.py @@ -0,0 +1,43 @@ +from typing import Any + +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.dto import UrlDto +from inclusive_dance_bot.exceptions.base import InclusiveDanceError +from inclusive_dance_bot.exceptions.url import UrlSlugAlreadyExistsError +from inclusive_dance_bot.logic.storage import Storage + + +async def create_url( + uow: UnitOfWork, storage: Storage, slug: str, value: str +) -> UrlDto: + """Создает новую ссылку""" + try: + url = await uow.urls.create(slug=slug, value=value) + except UrlSlugAlreadyExistsError as e: + await uow.rollback() + raise e + await uow.commit() + await storage.refresh_urls() + return url + + +async def update_url_by_slug( + uow: UnitOfWork, + storage: Storage, + url_slug: str, + **kwargs: Any, +) -> UrlDto: + try: + url = await uow.urls.update_by_slug(url_slug=url_slug, **kwargs) + except InclusiveDanceError as e: + await uow.rollback() + raise e + await uow.commit() + await storage.refresh_urls() + return url + + +async def delete_url_by_slug(uow: UnitOfWork, storage: Storage, url_slug: str) -> None: + await uow.urls.delete_by_slug(url_slug=url_slug) + await uow.commit() + await storage.refresh_urls() diff --git a/inclusive_dance_bot/logic/user.py b/inclusive_dance_bot/logic/user.py new file mode 100644 index 0000000..da92f98 --- /dev/null +++ b/inclusive_dance_bot/logic/user.py @@ -0,0 +1,75 @@ +from collections.abc import Iterable + +from aiogram.types import User as AiogramUser + +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.dto import UserDto +from inclusive_dance_bot.exceptions.base import InclusiveDanceError + + +class MegaUser: + def __init__( + self, aiogram_user: AiogramUser, user: UserDto, superuser_ids: list[int] + ) -> None: + self._aiogram_user = aiogram_user + self._user = user + self._superuser_ids = superuser_ids + + def __repr__(self) -> str: + return f"MegaUser(user={self._user},auser={self._aiogram_user})" + + @property + def is_superuser(self) -> bool: + return self._aiogram_user.id in self._superuser_ids + + @property + def is_admin(self) -> bool: + return self._user.is_admin or self.is_superuser + + @property + def is_anonymous(self) -> bool: + return self._user.id == 0 + + +async def create_user( + uow: UnitOfWork, + user_id: int, + username: str, + name: str, + region: str, + phone_number: str, + user_type_ids: Iterable[int], +) -> None: + """Создает нового пользователя""" + try: + await uow.users.create( + user_id=user_id, + username=username, + name=name, + region=region, + phone_number=phone_number, + ) + for user_type_id in user_type_ids: + await uow.user_type_users.create(user_id=user_id, user_type_id=user_type_id) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e + + +async def add_user_to_admins(uow: UnitOfWork, username: str) -> None: + try: + await uow.users.add_to_admins(username=username) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e + + +async def delete_from_admins(uow: UnitOfWork, user_id: int) -> None: + try: + await uow.users.delete_from_admins(user_id=user_id) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e diff --git a/inclusive_dance_bot/services/bot.py b/inclusive_dance_bot/services/bot.py new file mode 100644 index 0000000..aecbef0 --- /dev/null +++ b/inclusive_dance_bot/services/bot.py @@ -0,0 +1,55 @@ +import logging + +from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import SimpleEventIsolation +from aiogram_dialog import setup_dialogs +from aiomisc import Service + +from inclusive_dance_bot.bot.dialogs import register_dialogs +from inclusive_dance_bot.bot.factory import get_storage +from inclusive_dance_bot.bot.middlewares.settings import SettingsMiddleware +from inclusive_dance_bot.bot.middlewares.storage import StorageMiddleware +from inclusive_dance_bot.bot.middlewares.uow import UowMiddleware +from inclusive_dance_bot.bot.middlewares.user import UserMiddleware +from inclusive_dance_bot.bot.ui_commands import set_ui_commands +from inclusive_dance_bot.config import Settings +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage + +log = logging.getLogger(__name__) + + +class AiogramBotService(Service): + __dependencies__ = ("settings", "uow", "bot") + + settings: Settings + uow: UnitOfWork + bot: Bot + + async def start(self) -> None: + if self.settings.TELEGRAM_BOT_TOKEN == "default": + raise ValueError("You should set env TELEGRAM_BOT_TOKEN") + + log.info("Initialize bot") + await set_ui_commands(self.bot) + await self.bot.delete_webhook(drop_pending_updates=True) + + storage = Storage(uow=self.uow) + + async with self.uow: + await storage.refresh_all() + + dp = Dispatcher( + storage=get_storage(settings=self.settings), + events_isolation=SimpleEventIsolation(), + ) + dp.update.outer_middleware(SettingsMiddleware(settings=self.settings)) + dp.update.outer_middleware(UowMiddleware(uow=self.uow)) + dp.update.outer_middleware(StorageMiddleware(storage=storage)) + dp.update.outer_middleware(UserMiddleware()) + register_dialogs(dp) + setup_dialogs(dp) + + self.start_event.set() + log.info("Start polling") + await dp.start_polling(self.bot) diff --git a/inclusive_dance_bot/services/periodic.py b/inclusive_dance_bot/services/periodic.py new file mode 100644 index 0000000..4b18f5c --- /dev/null +++ b/inclusive_dance_bot/services/periodic.py @@ -0,0 +1,20 @@ +from typing import Any + +from aiogram import Bot +from aiomisc.service.periodic import PeriodicService + +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.mailing import send_mailings + + +class PeriodicMailingService(PeriodicService): + __required__ = ("gap",) + __dependencies__ = ("bot", "uow") + + bot: Bot + uow: UnitOfWork + gap: int + + async def callback(self) -> Any: + async with self.uow: + await send_mailings(uow=self.uow, bot=self.bot, gap=self.gap) diff --git a/inclusive_dance_bot/services/save_data.py b/inclusive_dance_bot/services/save_data.py deleted file mode 100644 index 7f52b07..0000000 --- a/inclusive_dance_bot/services/save_data.py +++ /dev/null @@ -1,46 +0,0 @@ -from collections.abc import Iterable - -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import FeedbackType -from inclusive_dance_bot.exceptions import InclusiveDanceError - - -async def save_new_user( - uow: UnitOfWork, - user_id: int, - name: str, - region: str, - phone_number: str, - user_type_ids: Iterable[int], -) -> None: - """Сохраняет нового пользователя""" - try: - await uow.users.create( - user_id=user_id, - name=name, - region=region, - phone_number=phone_number, - ) - for user_type_id in user_type_ids: - await uow.user_type_users.create(user_id=user_id, user_type_id=user_type_id) - await uow.commit() - except InclusiveDanceError as e: - await uow.rollback() - raise e - - -async def save_new_feedback( - uow: UnitOfWork, user_id: int, type: FeedbackType, title: str, text: str -) -> None: - """Сохраняет новую обратную связь от пользователя""" - try: - await uow.feedbacks.create( - user_id=user_id, - type=type, - title=title, - text=text, - ) - await uow.commit() - except InclusiveDanceError as e: - await uow.rollback() - raise e diff --git a/inclusive_dance_bot/services/storage.py b/inclusive_dance_bot/services/storage.py deleted file mode 100644 index 9a4b099..0000000 --- a/inclusive_dance_bot/services/storage.py +++ /dev/null @@ -1,65 +0,0 @@ -from collections.abc import Iterator, MutableMapping -from typing import Any - -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.dto import EntityDto, UrlDto, UserTypeDto -from inclusive_dance_bot.enums import StorageType - - -class CacheStorage(MutableMapping): - def __init__(self) -> None: - self.__storage: dict[str, Any] = {} - - def __getitem__(self, key: str) -> Any: - return self.__storage[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.__storage[key] = value - - def __delitem__(self, key: str) -> None: - del self.__storage[key] - - def __len__(self) -> int: - return len(self.__storage) - - def __iter__(self) -> Iterator: - return iter(self.__storage) - - def __repr__(self) -> str: - return repr(self.__storage) - - def clear(self) -> None: - self.__storage.clear() - - -class Storage: - def __init__(self, uow: UnitOfWork) -> None: - self.uow = uow - self.cache = CacheStorage() - - async def get_urls(self) -> dict[str, UrlDto]: - if StorageType.URL not in self.cache: - self.cache[StorageType.URL] = { - url.slug: url for url in await self.uow.urls.get_all_urls() - } - return self.cache[StorageType.URL] - - async def get_user_types(self) -> dict[int, UserTypeDto]: - if StorageType.USER_TYPE not in self.cache: - self.cache[StorageType.USER_TYPE] = { - ut.id: ut for ut in await self.uow.user_types.get_all_user_types() - } - return self.cache[StorageType.USER_TYPE] - - async def get_entities(self) -> dict[int, EntityDto]: - if StorageType.ENTITY not in self.cache: - self.cache[StorageType.ENTITY] = { - e.id: e for e in await self.uow.entities.get_all_entities() - } - return self.cache[StorageType.ENTITY] - - async def refresh_all(self) -> None: - self.cache.clear() - await self.get_urls() - await self.get_entities() - await self.get_user_types() diff --git a/inclusive_dance_bot/utils.py b/inclusive_dance_bot/utils.py new file mode 100644 index 0000000..2ace252 --- /dev/null +++ b/inclusive_dance_bot/utils.py @@ -0,0 +1,13 @@ +import string + + +def check_slug(s: str) -> bool: + alphabet = string.ascii_lowercase + string.digits + "_" + return all(l in alphabet for l in s) + + +class NotSet: + pass + + +NOT_SET = NotSet() diff --git a/poetry.lock b/poetry.lock index 6e66d27..3ff6d66 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +[[package]] +name = "aiodine" +version = "1.2.9" +description = "Async-first dependency injection library for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "aiodine-1.2.9-py3-none-any.whl", hash = "sha256:a33a3809d9bf273ad576aff3231e29f532be4b63de9784e59f5b442a35f53709"}, + {file = "aiodine-1.2.9.tar.gz", hash = "sha256:f4151ea1c4c3261041700720b1c5988425c5178d352f3934423ea5869debd1ea"}, +] + [[package]] name = "aiofiles" version = "23.1.0" @@ -42,13 +53,13 @@ test = ["aresponses (>=2.1.6,<2.2.0)", "pycryptodomex (>=3.18,<4.0)", "pytest (> [[package]] name = "aiogram-dialog" -version = "2.0.0" +version = "2.1.0b2" description = "Telegram bot UI framework on top of aiogram" optional = false python-versions = ">=3.8" files = [ - {file = "aiogram_dialog-2.0.0-py3-none-any.whl", hash = "sha256:bd91a59b2353b623b53f53570144b4204f52600fc47faf8e7fca9b08ec3b9459"}, - {file = "aiogram_dialog-2.0.0.tar.gz", hash = "sha256:ea2611049957fbf6a30fafe13d03dc5ae874c7456ccb3bd5fe745d05c67b0fff"}, + {file = "aiogram_dialog-2.1.0b2-py3-none-any.whl", hash = "sha256:aaf5b1c5387a44093c66ae106007c7523ea792564a806d61016cc83080d10b2a"}, + {file = "aiogram_dialog-2.1.0b2.tar.gz", hash = "sha256:bfe2517a1b25a881b7c32c0b89a4130355234ef203edc91153fc8107da289789"}, ] [package.dependencies] @@ -168,6 +179,65 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["Brotli", "aiodns", "cchardet"] +[[package]] +name = "aiomisc" +version = "17.3.23" +description = "aiomisc - miscellaneous utils for asyncio" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "aiomisc-17.3.23-py3-none-any.whl", hash = "sha256:17f5a6b45c03689c547fc5f5d33f3d8f0a0ce977ab7a0edb939422fcae0c3f42"}, + {file = "aiomisc-17.3.23.tar.gz", hash = "sha256:f437ff7863275c576fdd1521e0b9a53e6ed24e551c541c387e57c7f9b67aabd4"}, +] + +[package.dependencies] +colorlog = ">=6.0,<7.0" +logging-journald = {version = "*", markers = "sys_platform == \"linux\""} +uvloop = {version = ">=0.14,<1", optional = true, markers = "extra == \"uvloop\""} + +[package.extras] +aiohttp = ["aiohttp (>3)"] +asgi = ["aiohttp-asgi (>=0.5.2,<0.6.0)"] +carbon = ["aiocarbon (>=0.15,<0.16)"] +cron = ["croniter (>=1.3.8,<2.0.0)"] +grpc = ["grpcio (>=1.56.0,<2.0.0)", "grpcio-tools (>=1.56.0,<2.0.0)"] +raven = ["aiohttp (>3)", "raven"] +rich = ["rich"] +uvloop = ["uvloop (>=0.14,<1)"] + +[[package]] +name = "aiomisc-dependency" +version = "0.1.20" +description = "aiomisc-dependency - dependency injection in aiomisc" +optional = false +python-versions = "*" +files = [ + {file = "aiomisc_dependency-0.1.20-py3-none-any.whl", hash = "sha256:353609b871c7b69641300d9069b2033a76b1bd8806e6049696729b64b9e5b87a"}, + {file = "aiomisc_dependency-0.1.20.tar.gz", hash = "sha256:ce40db771f8a02855307797af0058bbf4d978c6a66cddde8a033735509bb9262"}, +] + +[package.dependencies] +aiodine = ">=1.2.9,<1.3.0" +aiomisc = ">=7.1" + +[package.extras] +develop = ["coverage (>=4.5.3,<4.6.0)", "coveralls", "pylava", "pytest", "pytest-cov (>=2.5.1,<2.6.0)", "tox (>=2.4)"] + +[[package]] +name = "aiomisc-pytest" +version = "1.1.1" +description = "pytest integration for aiomisc" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "aiomisc_pytest-1.1.1-py3-none-any.whl", hash = "sha256:c07251f79c936c85c7589429f43c728cf1a34b80c0089b268f2cfa6186e77020"}, + {file = "aiomisc_pytest-1.1.1.tar.gz", hash = "sha256:2c378c41b078c0576027de6bf7fbc537a7e69285d23eaf4d45738d5d0de56dd3"}, +] + +[package.dependencies] +aiomisc = ">=17" +pytest = ">=7.2.1,<8.0.0" + [[package]] name = "aiosignal" version = "1.3.1" @@ -184,13 +254,13 @@ frozenlist = ">=1.1.0" [[package]] name = "alembic" -version = "1.12.0" +version = "1.12.1" description = "A database migration tool for SQLAlchemy." optional = false python-versions = ">=3.7" files = [ - {file = "alembic-1.12.0-py3-none-any.whl", hash = "sha256:03226222f1cf943deee6c85d9464261a6c710cd19b4fe867a3ad1f25afda610f"}, - {file = "alembic-1.12.0.tar.gz", hash = "sha256:8e7645c32e4f200675e69f0745415335eb59a3663f5feb487abfa0b30c45888b"}, + {file = "alembic-1.12.1-py3-none-any.whl", hash = "sha256:47d52e3dfb03666ed945becb723d6482e52190917fdb47071440cfdba05d92cb"}, + {file = "alembic-1.12.1.tar.gz", hash = "sha256:bca5877e9678b454706347bc10b97cb7d67f300320fa5c3a94423e8266e2823f"}, ] [package.dependencies] @@ -319,29 +389,29 @@ yaml = ["PyYAML"] [[package]] name = "black" -version = "23.10.0" +version = "23.10.1" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-23.10.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:f8dc7d50d94063cdfd13c82368afd8588bac4ce360e4224ac399e769d6704e98"}, - {file = "black-23.10.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:f20ff03f3fdd2fd4460b4f631663813e57dc277e37fb216463f3b907aa5a9bdd"}, - {file = "black-23.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3d9129ce05b0829730323bdcb00f928a448a124af5acf90aa94d9aba6969604"}, - {file = "black-23.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:960c21555be135c4b37b7018d63d6248bdae8514e5c55b71e994ad37407f45b8"}, - {file = "black-23.10.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:30b78ac9b54cf87bcb9910ee3d499d2bc893afd52495066c49d9ee6b21eee06e"}, - {file = "black-23.10.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:0e232f24a337fed7a82c1185ae46c56c4a6167fb0fe37411b43e876892c76699"}, - {file = "black-23.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31946ec6f9c54ed7ba431c38bc81d758970dd734b96b8e8c2b17a367d7908171"}, - {file = "black-23.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:c870bee76ad5f7a5ea7bd01dc646028d05568d33b0b09b7ecfc8ec0da3f3f39c"}, - {file = "black-23.10.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:6901631b937acbee93c75537e74f69463adaf34379a04eef32425b88aca88a23"}, - {file = "black-23.10.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:481167c60cd3e6b1cb8ef2aac0f76165843a374346aeeaa9d86765fe0dd0318b"}, - {file = "black-23.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f74892b4b836e5162aa0452393112a574dac85e13902c57dfbaaf388e4eda37c"}, - {file = "black-23.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:47c4510f70ec2e8f9135ba490811c071419c115e46f143e4dce2ac45afdcf4c9"}, - {file = "black-23.10.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:76baba9281e5e5b230c9b7f83a96daf67a95e919c2dfc240d9e6295eab7b9204"}, - {file = "black-23.10.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:a3c2ddb35f71976a4cfeca558848c2f2f89abc86b06e8dd89b5a65c1e6c0f22a"}, - {file = "black-23.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db451a3363b1e765c172c3fd86213a4ce63fb8524c938ebd82919bf2a6e28c6a"}, - {file = "black-23.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:7fb5fc36bb65160df21498d5a3dd330af8b6401be3f25af60c6ebfe23753f747"}, - {file = "black-23.10.0-py3-none-any.whl", hash = "sha256:e223b731a0e025f8ef427dd79d8cd69c167da807f5710add30cdf131f13dd62e"}, - {file = "black-23.10.0.tar.gz", hash = "sha256:31b9f87b277a68d0e99d2905edae08807c007973eaa609da5f0c62def6b7c0bd"}, + {file = "black-23.10.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:ec3f8e6234c4e46ff9e16d9ae96f4ef69fa328bb4ad08198c8cee45bb1f08c69"}, + {file = "black-23.10.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:1b917a2aa020ca600483a7b340c165970b26e9029067f019e3755b56e8dd5916"}, + {file = "black-23.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c74de4c77b849e6359c6f01987e94873c707098322b91490d24296f66d067dc"}, + {file = "black-23.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:7b4d10b0f016616a0d93d24a448100adf1699712fb7a4efd0e2c32bbb219b173"}, + {file = "black-23.10.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b15b75fc53a2fbcac8a87d3e20f69874d161beef13954747e053bca7a1ce53a0"}, + {file = "black-23.10.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:e293e4c2f4a992b980032bbd62df07c1bcff82d6964d6c9496f2cd726e246ace"}, + {file = "black-23.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d56124b7a61d092cb52cce34182a5280e160e6aff3137172a68c2c2c4b76bcb"}, + {file = "black-23.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3f157a8945a7b2d424da3335f7ace89c14a3b0625e6593d21139c2d8214d55ce"}, + {file = "black-23.10.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:cfcce6f0a384d0da692119f2d72d79ed07c7159879d0bb1bb32d2e443382bf3a"}, + {file = "black-23.10.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:33d40f5b06be80c1bbce17b173cda17994fbad096ce60eb22054da021bf933d1"}, + {file = "black-23.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:840015166dbdfbc47992871325799fd2dc0dcf9395e401ada6d88fe11498abad"}, + {file = "black-23.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:037e9b4664cafda5f025a1728c50a9e9aedb99a759c89f760bd83730e76ba884"}, + {file = "black-23.10.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:7cb5936e686e782fddb1c73f8aa6f459e1ad38a6a7b0e54b403f1f05a1507ee9"}, + {file = "black-23.10.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:7670242e90dc129c539e9ca17665e39a146a761e681805c54fbd86015c7c84f7"}, + {file = "black-23.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed45ac9a613fb52dad3b61c8dea2ec9510bf3108d4db88422bacc7d1ba1243d"}, + {file = "black-23.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:6d23d7822140e3fef190734216cefb262521789367fbdc0b3f22af6744058982"}, + {file = "black-23.10.1-py3-none-any.whl", hash = "sha256:d431e6739f727bb2e0495df64a6c7a5310758e87505f5f8cde9ff6c0f2d7e4fe"}, + {file = "black-23.10.1.tar.gz", hash = "sha256:1f8ce316753428ff68749c65a5f7844631aa18c8679dfd3ca9dc1a289979c258"}, ] [package.dependencies] @@ -359,13 +429,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "cachetools" -version = "5.3.1" +version = "5.3.2" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" files = [ - {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, - {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, + {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"}, + {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"}, ] [[package]] @@ -392,101 +462,101 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.3.0" +version = "3.3.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.3.0.tar.gz", hash = "sha256:63563193aec44bce707e0c5ca64ff69fa72ed7cf34ce6e11d5127555756fd2f6"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:effe5406c9bd748a871dbcaf3ac69167c38d72db8c9baf3ff954c344f31c4cbe"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4162918ef3098851fcd8a628bf9b6a98d10c380725df9e04caf5ca6dd48c847a"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0570d21da019941634a531444364f2482e8db0b3425fcd5ac0c36565a64142c8"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5707a746c6083a3a74b46b3a631d78d129edab06195a92a8ece755aac25a3f3d"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:278c296c6f96fa686d74eb449ea1697f3c03dc28b75f873b65b5201806346a69"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a4b71f4d1765639372a3b32d2638197f5cd5221b19531f9245fcc9ee62d38f56"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5969baeaea61c97efa706b9b107dcba02784b1601c74ac84f2a532ea079403e"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3f93dab657839dfa61025056606600a11d0b696d79386f974e459a3fbc568ec"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:db756e48f9c5c607b5e33dd36b1d5872d0422e960145b08ab0ec7fd420e9d649"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:232ac332403e37e4a03d209a3f92ed9071f7d3dbda70e2a5e9cff1c4ba9f0678"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e5c1502d4ace69a179305abb3f0bb6141cbe4714bc9b31d427329a95acfc8bdd"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:2502dd2a736c879c0f0d3e2161e74d9907231e25d35794584b1ca5284e43f596"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23e8565ab7ff33218530bc817922fae827420f143479b753104ab801145b1d5b"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-win32.whl", hash = "sha256:1872d01ac8c618a8da634e232f24793883d6e456a66593135aeafe3784b0848d"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:557b21a44ceac6c6b9773bc65aa1b4cc3e248a5ad2f5b914b91579a32e22204d"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d7eff0f27edc5afa9e405f7165f85a6d782d308f3b6b9d96016c010597958e63"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a685067d05e46641d5d1623d7c7fdf15a357546cbb2f71b0ebde91b175ffc3e"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d3d5b7db9ed8a2b11a774db2bbea7ba1884430a205dbd54a32d61d7c2a190fa"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2935ffc78db9645cb2086c2f8f4cfd23d9b73cc0dc80334bc30aac6f03f68f8c"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fe359b2e3a7729010060fbca442ca225280c16e923b37db0e955ac2a2b72a05"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380c4bde80bce25c6e4f77b19386f5ec9db230df9f2f2ac1e5ad7af2caa70459"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0d1e3732768fecb052d90d62b220af62ead5748ac51ef61e7b32c266cac9293"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b2919306936ac6efb3aed1fbf81039f7087ddadb3160882a57ee2ff74fd2382"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f8888e31e3a85943743f8fc15e71536bda1c81d5aa36d014a3c0c44481d7db6e"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:82eb849f085624f6a607538ee7b83a6d8126df6d2f7d3b319cb837b289123078"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7b8b8bf1189b3ba9b8de5c8db4d541b406611a71a955bbbd7385bbc45fcb786c"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5adf257bd58c1b8632046bbe43ee38c04e1038e9d37de9c57a94d6bd6ce5da34"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c350354efb159b8767a6244c166f66e67506e06c8924ed74669b2c70bc8735b1"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-win32.whl", hash = "sha256:02af06682e3590ab952599fbadac535ede5d60d78848e555aa58d0c0abbde786"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:86d1f65ac145e2c9ed71d8ffb1905e9bba3a91ae29ba55b4c46ae6fc31d7c0d4"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3b447982ad46348c02cb90d230b75ac34e9886273df3a93eec0539308a6296d7"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:abf0d9f45ea5fb95051c8bfe43cb40cda383772f7e5023a83cc481ca2604d74e"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b09719a17a2301178fac4470d54b1680b18a5048b481cb8890e1ef820cb80455"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3d9b48ee6e3967b7901c052b670c7dda6deb812c309439adaffdec55c6d7b78"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:edfe077ab09442d4ef3c52cb1f9dab89bff02f4524afc0acf2d46be17dc479f5"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3debd1150027933210c2fc321527c2299118aa929c2f5a0a80ab6953e3bd1908"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86f63face3a527284f7bb8a9d4f78988e3c06823f7bea2bd6f0e0e9298ca0403"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24817cb02cbef7cd499f7c9a2735286b4782bd47a5b3516a0e84c50eab44b98e"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c71f16da1ed8949774ef79f4a0260d28b83b3a50c6576f8f4f0288d109777989"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9cf3126b85822c4e53aa28c7ec9869b924d6fcfb76e77a45c44b83d91afd74f9"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:b3b2316b25644b23b54a6f6401074cebcecd1244c0b8e80111c9a3f1c8e83d65"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:03680bb39035fbcffe828eae9c3f8afc0428c91d38e7d61aa992ef7a59fb120e"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cc152c5dd831641e995764f9f0b6589519f6f5123258ccaca8c6d34572fefa8"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-win32.whl", hash = "sha256:b8f3307af845803fb0b060ab76cf6dd3a13adc15b6b451f54281d25911eb92df"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8eaf82f0eccd1505cf39a45a6bd0a8cf1c70dcfc30dba338207a969d91b965c0"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dc45229747b67ffc441b3de2f3ae5e62877a282ea828a5bdb67883c4ee4a8810"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f4a0033ce9a76e391542c182f0d48d084855b5fcba5010f707c8e8c34663d77"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ada214c6fa40f8d800e575de6b91a40d0548139e5dc457d2ebb61470abf50186"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1121de0e9d6e6ca08289583d7491e7fcb18a439305b34a30b20d8215922d43c"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1063da2c85b95f2d1a430f1c33b55c9c17ffaf5e612e10aeaad641c55a9e2b9d"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70f1d09c0d7748b73290b29219e854b3207aea922f839437870d8cc2168e31cc"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:250c9eb0f4600361dd80d46112213dff2286231d92d3e52af1e5a6083d10cad9"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:750b446b2ffce1739e8578576092179160f6d26bd5e23eb1789c4d64d5af7dc7"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:fc52b79d83a3fe3a360902d3f5d79073a993597d48114c29485e9431092905d8"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:588245972aca710b5b68802c8cad9edaa98589b1b42ad2b53accd6910dad3545"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e39c7eb31e3f5b1f88caff88bcff1b7f8334975b46f6ac6e9fc725d829bc35d4"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-win32.whl", hash = "sha256:abecce40dfebbfa6abf8e324e1860092eeca6f7375c8c4e655a8afb61af58f2c"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a91a981f185721542a0b7c92e9054b7ab4fea0508a795846bc5b0abf8118d4"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:67b8cc9574bb518ec76dc8e705d4c39ae78bb96237cb533edac149352c1f39fe"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac71b2977fb90c35d41c9453116e283fac47bb9096ad917b8819ca8b943abecd"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3ae38d325b512f63f8da31f826e6cb6c367336f95e418137286ba362925c877e"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:542da1178c1c6af8873e143910e2269add130a299c9106eef2594e15dae5e482"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30a85aed0b864ac88309b7d94be09f6046c834ef60762a8833b660139cfbad13"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aae32c93e0f64469f74ccc730a7cb21c7610af3a775157e50bbd38f816536b38"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b26ddf78d57f1d143bdf32e820fd8935d36abe8a25eb9ec0b5a71c82eb3895"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f5d10bae5d78e4551b7be7a9b29643a95aded9d0f602aa2ba584f0388e7a557"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:249c6470a2b60935bafd1d1d13cd613f8cd8388d53461c67397ee6a0f5dce741"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c5a74c359b2d47d26cdbbc7845e9662d6b08a1e915eb015d044729e92e7050b7"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:b5bcf60a228acae568e9911f410f9d9e0d43197d030ae5799e20dca8df588287"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:187d18082694a29005ba2944c882344b6748d5be69e3a89bf3cc9d878e548d5a"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:81bf654678e575403736b85ba3a7867e31c2c30a69bc57fe88e3ace52fb17b89"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-win32.whl", hash = "sha256:85a32721ddde63c9df9ebb0d2045b9691d9750cb139c161c80e500d210f5e26e"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:468d2a840567b13a590e67dd276c570f8de00ed767ecc611994c301d0f8c014f"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e0fc42822278451bc13a2e8626cf2218ba570f27856b536e00cfa53099724828"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:09c77f964f351a7369cc343911e0df63e762e42bac24cd7d18525961c81754f4"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:12ebea541c44fdc88ccb794a13fe861cc5e35d64ed689513a5c03d05b53b7c82"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:805dfea4ca10411a5296bcc75638017215a93ffb584c9e344731eef0dcfb026a"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96c2b49eb6a72c0e4991d62406e365d87067ca14c1a729a870d22354e6f68115"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaf7b34c5bc56b38c931a54f7952f1ff0ae77a2e82496583b247f7c969eb1479"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:619d1c96099be5823db34fe89e2582b336b5b074a7f47f819d6b3a57ff7bdb86"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0ac5e7015a5920cfce654c06618ec40c33e12801711da6b4258af59a8eff00a"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:93aa7eef6ee71c629b51ef873991d6911b906d7312c6e8e99790c0f33c576f89"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7966951325782121e67c81299a031f4c115615e68046f79b85856b86ebffc4cd"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:02673e456dc5ab13659f85196c534dc596d4ef260e4d86e856c3b2773ce09843"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:c2af80fb58f0f24b3f3adcb9148e6203fa67dd3f61c4af146ecad033024dde43"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:153e7b6e724761741e0974fc4dcd406d35ba70b92bfe3fedcb497226c93b9da7"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-win32.whl", hash = "sha256:d47ecf253780c90ee181d4d871cd655a789da937454045b17b5798da9393901a"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:d97d85fa63f315a8bdaba2af9a6a686e0eceab77b3089af45133252618e70884"}, - {file = "charset_normalizer-3.3.0-py3-none-any.whl", hash = "sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2"}, + {file = "charset-normalizer-3.3.1.tar.gz", hash = "sha256:d9137a876020661972ca6eec0766d81aef8a5627df628b664b234b73396e727e"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8aee051c89e13565c6bd366813c386939f8e928af93c29fda4af86d25b73d8f8"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:352a88c3df0d1fa886562384b86f9a9e27563d4704ee0e9d56ec6fcd270ea690"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:223b4d54561c01048f657fa6ce41461d5ad8ff128b9678cfe8b2ecd951e3f8a2"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f861d94c2a450b974b86093c6c027888627b8082f1299dfd5a4bae8e2292821"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1171ef1fc5ab4693c5d151ae0fdad7f7349920eabbaca6271f95969fa0756c2d"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28f512b9a33235545fbbdac6a330a510b63be278a50071a336afc1b78781b147"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0e842112fe3f1a4ffcf64b06dc4c61a88441c2f02f373367f7b4c1aa9be2ad5"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f9bc2ce123637a60ebe819f9fccc614da1bcc05798bbbaf2dd4ec91f3e08846"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f194cce575e59ffe442c10a360182a986535fd90b57f7debfaa5c845c409ecc3"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9a74041ba0bfa9bc9b9bb2cd3238a6ab3b7618e759b41bd15b5f6ad958d17605"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b578cbe580e3b41ad17b1c428f382c814b32a6ce90f2d8e39e2e635d49e498d1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6db3cfb9b4fcecb4390db154e75b49578c87a3b9979b40cdf90d7e4b945656e1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:debb633f3f7856f95ad957d9b9c781f8e2c6303ef21724ec94bea2ce2fcbd056"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win32.whl", hash = "sha256:87071618d3d8ec8b186d53cb6e66955ef2a0e4fa63ccd3709c0c90ac5a43520f"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:e372d7dfd154009142631de2d316adad3cc1c36c32a38b16a4751ba78da2a397"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae4070f741f8d809075ef697877fd350ecf0b7c5837ed68738607ee0a2c572cf"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58e875eb7016fd014c0eea46c6fa92b87b62c0cb31b9feae25cbbe62c919f54d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbd95e300367aa0827496fe75a1766d198d34385a58f97683fe6e07f89ca3e3c"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de0b4caa1c8a21394e8ce971997614a17648f94e1cd0640fbd6b4d14cab13a72"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:985c7965f62f6f32bf432e2681173db41336a9c2611693247069288bcb0c7f8b"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a15c1fe6d26e83fd2e5972425a772cca158eae58b05d4a25a4e474c221053e2d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae55d592b02c4349525b6ed8f74c692509e5adffa842e582c0f861751701a673"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4d9c2770044a59715eb57c1144dedea7c5d5ae80c68fb9959515037cde2008"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:851cf693fb3aaef71031237cd68699dded198657ec1e76a76eb8be58c03a5d1f"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:31bbaba7218904d2eabecf4feec0d07469284e952a27400f23b6628439439fa7"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:871d045d6ccc181fd863a3cd66ee8e395523ebfbc57f85f91f035f50cee8e3d4"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:501adc5eb6cd5f40a6f77fbd90e5ab915c8fd6e8c614af2db5561e16c600d6f3"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f5fb672c396d826ca16a022ac04c9dce74e00a1c344f6ad1a0fdc1ba1f332213"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win32.whl", hash = "sha256:bb06098d019766ca16fc915ecaa455c1f1cd594204e7f840cd6258237b5079a8"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:8af5a8917b8af42295e86b64903156b4f110a30dca5f3b5aedea123fbd638bff"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7ae8e5142dcc7a49168f4055255dbcced01dc1714a90a21f87448dc8d90617d1"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5b70bab78accbc672f50e878a5b73ca692f45f5b5e25c8066d748c09405e6a55"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ceca5876032362ae73b83347be8b5dbd2d1faf3358deb38c9c88776779b2e2f"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d95638ff3613849f473afc33f65c401a89f3b9528d0d213c7037c398a51296"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9edbe6a5bf8b56a4a84533ba2b2f489d0046e755c29616ef8830f9e7d9cf5728"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6a02a3c7950cafaadcd46a226ad9e12fc9744652cc69f9e5534f98b47f3bbcf"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10b8dd31e10f32410751b3430996f9807fc4d1587ca69772e2aa940a82ab571a"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edc0202099ea1d82844316604e17d2b175044f9bcb6b398aab781eba957224bd"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b891a2f68e09c5ef989007fac11476ed33c5c9994449a4e2c3386529d703dc8b"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:71ef3b9be10070360f289aea4838c784f8b851be3ba58cf796262b57775c2f14"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:55602981b2dbf8184c098bc10287e8c245e351cd4fdcad050bd7199d5a8bf514"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:46fb9970aa5eeca547d7aa0de5d4b124a288b42eaefac677bde805013c95725c"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:520b7a142d2524f999447b3a0cf95115df81c4f33003c51a6ab637cbda9d0bf4"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win32.whl", hash = "sha256:8ec8ef42c6cd5856a7613dcd1eaf21e5573b2185263d87d27c8edcae33b62a61"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:baec8148d6b8bd5cee1ae138ba658c71f5b03e0d69d5907703e3e1df96db5e41"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63a6f59e2d01310f754c270e4a257426fe5a591dc487f1983b3bbe793cf6bac6"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d6bfc32a68bc0933819cfdfe45f9abc3cae3877e1d90aac7259d57e6e0f85b1"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f3100d86dcd03c03f7e9c3fdb23d92e32abbca07e7c13ebd7ddfbcb06f5991f"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39b70a6f88eebe239fa775190796d55a33cfb6d36b9ffdd37843f7c4c1b5dc67"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e12f8ee80aa35e746230a2af83e81bd6b52daa92a8afaef4fea4a2ce9b9f4fa"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b6cefa579e1237ce198619b76eaa148b71894fb0d6bcf9024460f9bf30fd228"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:61f1e3fb621f5420523abb71f5771a204b33c21d31e7d9d86881b2cffe92c47c"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4f6e2a839f83a6a76854d12dbebde50e4b1afa63e27761549d006fa53e9aa80e"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:1ec937546cad86d0dce5396748bf392bb7b62a9eeb8c66efac60e947697f0e58"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:82ca51ff0fc5b641a2d4e1cc8c5ff108699b7a56d7f3ad6f6da9dbb6f0145b48"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:633968254f8d421e70f91c6ebe71ed0ab140220469cf87a9857e21c16687c034"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win32.whl", hash = "sha256:c0c72d34e7de5604df0fde3644cc079feee5e55464967d10b24b1de268deceb9"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:63accd11149c0f9a99e3bc095bbdb5a464862d77a7e309ad5938fbc8721235ae"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5a3580a4fdc4ac05f9e53c57f965e3594b2f99796231380adb2baaab96e22761"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2465aa50c9299d615d757c1c888bc6fef384b7c4aec81c05a0172b4400f98557"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb7cd68814308aade9d0c93c5bd2ade9f9441666f8ba5aa9c2d4b389cb5e2a45"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e43805ccafa0a91831f9cd5443aa34528c0c3f2cc48c4cb3d9a7721053874b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:854cc74367180beb327ab9d00f964f6d91da06450b0855cbbb09187bcdb02de5"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c15070ebf11b8b7fd1bfff7217e9324963c82dbdf6182ff7050519e350e7ad9f"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4c99f98fc3a1835af8179dcc9013f93594d0670e2fa80c83aa36346ee763d2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb765362688821404ad6cf86772fc54993ec11577cd5a92ac44b4c2ba52155b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dced27917823df984fe0c80a5c4ad75cf58df0fbfae890bc08004cd3888922a2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a66bcdf19c1a523e41b8e9d53d0cedbfbac2e93c649a2e9502cb26c014d0980c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ecd26be9f112c4f96718290c10f4caea6cc798459a3a76636b817a0ed7874e42"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3f70fd716855cd3b855316b226a1ac8bdb3caf4f7ea96edcccc6f484217c9597"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:17a866d61259c7de1bdadef418a37755050ddb4b922df8b356503234fff7932c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win32.whl", hash = "sha256:548eefad783ed787b38cb6f9a574bd8664468cc76d1538215d510a3cd41406cb"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:45f053a0ece92c734d874861ffe6e3cc92150e32136dd59ab1fb070575189c97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bc791ec3fd0c4309a753f95bb6c749ef0d8ea3aea91f07ee1cf06b7b02118f2f"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0c8c61fb505c7dad1d251c284e712d4e0372cef3b067f7ddf82a7fa82e1e9a93"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2c092be3885a1b7899cd85ce24acedc1034199d6fca1483fa2c3a35c86e43041"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2000c54c395d9e5e44c99dc7c20a64dc371f777faf8bae4919ad3e99ce5253e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cb50a0335382aac15c31b61d8531bc9bb657cfd848b1d7158009472189f3d62"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c30187840d36d0ba2893bc3271a36a517a717f9fd383a98e2697ee890a37c273"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe81b35c33772e56f4b6cf62cf4aedc1762ef7162a31e6ac7fe5e40d0149eb67"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0bf89afcbcf4d1bb2652f6580e5e55a840fdf87384f6063c4a4f0c95e378656"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:06cf46bdff72f58645434d467bf5228080801298fbba19fe268a01b4534467f5"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3c66df3f41abee950d6638adc7eac4730a306b022570f71dd0bd6ba53503ab57"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd805513198304026bd379d1d516afbf6c3c13f4382134a2c526b8b854da1c2e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:9505dc359edb6a330efcd2be825fdb73ee3e628d9010597aa1aee5aa63442e97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:31445f38053476a0c4e6d12b047b08ced81e2c7c712e5a1ad97bc913256f91b2"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win32.whl", hash = "sha256:bd28b31730f0e982ace8663d108e01199098432a30a4c410d06fe08fdb9e93f4"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:555fe186da0068d3354cdf4bbcbc609b0ecae4d04c921cc13e209eece7720727"}, + {file = "charset_normalizer-3.3.1-py3-none-any.whl", hash = "sha256:800561453acdecedaac137bf09cd719c7a440b6800ec182f077bb8e7025fb708"}, ] [[package]] @@ -514,6 +584,23 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "colorlog" +version = "6.7.0" +description = "Add colours to the output of Python's logging module." +optional = false +python-versions = ">=3.6" +files = [ + {file = "colorlog-6.7.0-py2.py3-none-any.whl", hash = "sha256:0d33ca236784a1ba3ff9c532d4964126d8a2c44f1f0cb1d2b0728196f512f662"}, + {file = "colorlog-6.7.0.tar.gz", hash = "sha256:bd94bd21c1e13fac7bd3153f4bc3a7dc0eb0974b8bc2fdf1a989e474f6e582e5"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +development = ["black", "flake8", "mypy", "pytest", "types-colorama"] + [[package]] name = "coverage" version = "7.3.2" @@ -591,13 +678,13 @@ files = [ [[package]] name = "faker" -version = "19.11.0" +version = "19.12.0" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.8" files = [ - {file = "Faker-19.11.0-py3-none-any.whl", hash = "sha256:e28090068293c5a83e7f4d636417d45fae1031ca8a8136cc2415549ebc2111e2"}, - {file = "Faker-19.11.0.tar.gz", hash = "sha256:a62a3fd3bfa3122d4f57dfa26a1cc37d76751a76c8ddd63cf9d24078c57913a4"}, + {file = "Faker-19.12.0-py3-none-any.whl", hash = "sha256:5990380a8ee81cf189d6b85d5fdc1fb43772cb136aca0385a08ff24ef01b9fdf"}, + {file = "Faker-19.12.0.tar.gz", hash = "sha256:91438f6b1713274ec3f24970ba303617be86ce5caf6f6a0776f1d04777b6ff5f"}, ] [package.dependencies] @@ -605,19 +692,19 @@ python-dateutil = ">=2.4" [[package]] name = "filelock" -version = "3.12.4" +version = "3.13.0" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"}, - {file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"}, + {file = "filelock-3.13.0-py3-none-any.whl", hash = "sha256:a552f4fde758f4eab33191e9548f671970f8b06d436d31388c9aa1e5861a710f"}, + {file = "filelock-3.13.0.tar.gz", hash = "sha256:63c6052c82a1a24c873a549fbd39a26982e8f35a3016da231ead11a5be9dad44"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] -typing = ["typing-extensions (>=4.7.1)"] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] [[package]] name = "flake8" @@ -738,73 +825,68 @@ test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre [[package]] name = "greenlet" -version = "3.0.0" +version = "3.0.1" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.7" files = [ - {file = "greenlet-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e09dea87cc91aea5500262993cbd484b41edf8af74f976719dd83fe724644cd6"}, - {file = "greenlet-3.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47932c434a3c8d3c86d865443fadc1fbf574e9b11d6650b656e602b1797908a"}, - {file = "greenlet-3.0.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdfaeecf8cc705d35d8e6de324bf58427d7eafb55f67050d8f28053a3d57118c"}, - {file = "greenlet-3.0.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a68d670c8f89ff65c82b936275369e532772eebc027c3be68c6b87ad05ca695"}, - {file = "greenlet-3.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ad562a104cd41e9d4644f46ea37167b93190c6d5e4048fcc4b80d34ecb278f"}, - {file = "greenlet-3.0.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a807b2a58d5cdebb07050efe3d7deaf915468d112dfcf5e426d0564aa3aa4a"}, - {file = "greenlet-3.0.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b1660a15a446206c8545edc292ab5c48b91ff732f91b3d3b30d9a915d5ec4779"}, - {file = "greenlet-3.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:813720bd57e193391dfe26f4871186cf460848b83df7e23e6bef698a7624b4c9"}, - {file = "greenlet-3.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:aa15a2ec737cb609ed48902b45c5e4ff6044feb5dcdfcf6fa8482379190330d7"}, - {file = "greenlet-3.0.0-cp310-universal2-macosx_11_0_x86_64.whl", hash = "sha256:7709fd7bb02b31908dc8fd35bfd0a29fc24681d5cc9ac1d64ad07f8d2b7db62f"}, - {file = "greenlet-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:211ef8d174601b80e01436f4e6905aca341b15a566f35a10dd8d1e93f5dbb3b7"}, - {file = "greenlet-3.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6512592cc49b2c6d9b19fbaa0312124cd4c4c8a90d28473f86f92685cc5fef8e"}, - {file = "greenlet-3.0.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:871b0a8835f9e9d461b7fdaa1b57e3492dd45398e87324c047469ce2fc9f516c"}, - {file = "greenlet-3.0.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b505fcfc26f4148551826a96f7317e02c400665fa0883fe505d4fcaab1dabfdd"}, - {file = "greenlet-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123910c58234a8d40eaab595bc56a5ae49bdd90122dde5bdc012c20595a94c14"}, - {file = "greenlet-3.0.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96d9ea57292f636ec851a9bb961a5cc0f9976900e16e5d5647f19aa36ba6366b"}, - {file = "greenlet-3.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b72b802496cccbd9b31acea72b6f87e7771ccfd7f7927437d592e5c92ed703c"}, - {file = "greenlet-3.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:527cd90ba3d8d7ae7dceb06fda619895768a46a1b4e423bdb24c1969823b8362"}, - {file = "greenlet-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:37f60b3a42d8b5499be910d1267b24355c495064f271cfe74bf28b17b099133c"}, - {file = "greenlet-3.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1482fba7fbed96ea7842b5a7fc11d61727e8be75a077e603e8ab49d24e234383"}, - {file = "greenlet-3.0.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:be557119bf467d37a8099d91fbf11b2de5eb1fd5fc5b91598407574848dc910f"}, - {file = "greenlet-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b2f1922a39d5d59cc0e597987300df3396b148a9bd10b76a058a2f2772fc04"}, - {file = "greenlet-3.0.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1e22c22f7826096ad503e9bb681b05b8c1f5a8138469b255eb91f26a76634f2"}, - {file = "greenlet-3.0.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d363666acc21d2c204dd8705c0e0457d7b2ee7a76cb16ffc099d6799744ac99"}, - {file = "greenlet-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:334ef6ed8337bd0b58bb0ae4f7f2dcc84c9f116e474bb4ec250a8bb9bd797a66"}, - {file = "greenlet-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6672fdde0fd1a60b44fb1751a7779c6db487e42b0cc65e7caa6aa686874e79fb"}, - {file = "greenlet-3.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:952256c2bc5b4ee8df8dfc54fc4de330970bf5d79253c863fb5e6761f00dda35"}, - {file = "greenlet-3.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:269d06fa0f9624455ce08ae0179430eea61085e3cf6457f05982b37fd2cefe17"}, - {file = "greenlet-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9adbd8ecf097e34ada8efde9b6fec4dd2a903b1e98037adf72d12993a1c80b51"}, - {file = "greenlet-3.0.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6b5ce7f40f0e2f8b88c28e6691ca6806814157ff05e794cdd161be928550f4c"}, - {file = "greenlet-3.0.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf94aa539e97a8411b5ea52fc6ccd8371be9550c4041011a091eb8b3ca1d810"}, - {file = "greenlet-3.0.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80dcd3c938cbcac986c5c92779db8e8ce51a89a849c135172c88ecbdc8c056b7"}, - {file = "greenlet-3.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52a712c38e5fb4fd68e00dc3caf00b60cb65634d50e32281a9d6431b33b4af1"}, - {file = "greenlet-3.0.0-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5539f6da3418c3dc002739cb2bb8d169056aa66e0c83f6bacae0cd3ac26b423"}, - {file = "greenlet-3.0.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:343675e0da2f3c69d3fb1e894ba0a1acf58f481f3b9372ce1eb465ef93cf6fed"}, - {file = "greenlet-3.0.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:abe1ef3d780de56defd0c77c5ba95e152f4e4c4e12d7e11dd8447d338b85a625"}, - {file = "greenlet-3.0.0-cp37-cp37m-win32.whl", hash = "sha256:e693e759e172fa1c2c90d35dea4acbdd1d609b6936115d3739148d5e4cd11947"}, - {file = "greenlet-3.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bdd696947cd695924aecb3870660b7545a19851f93b9d327ef8236bfc49be705"}, - {file = "greenlet-3.0.0-cp37-universal2-macosx_11_0_x86_64.whl", hash = "sha256:cc3e2679ea13b4de79bdc44b25a0c4fcd5e94e21b8f290791744ac42d34a0353"}, - {file = "greenlet-3.0.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:63acdc34c9cde42a6534518e32ce55c30f932b473c62c235a466469a710bfbf9"}, - {file = "greenlet-3.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a1a6244ff96343e9994e37e5b4839f09a0207d35ef6134dce5c20d260d0302c"}, - {file = "greenlet-3.0.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b822fab253ac0f330ee807e7485769e3ac85d5eef827ca224feaaefa462dc0d0"}, - {file = "greenlet-3.0.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8060b32d8586e912a7b7dac2d15b28dbbd63a174ab32f5bc6d107a1c4143f40b"}, - {file = "greenlet-3.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:621fcb346141ae08cb95424ebfc5b014361621b8132c48e538e34c3c93ac7365"}, - {file = "greenlet-3.0.0-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bb36985f606a7c49916eff74ab99399cdfd09241c375d5a820bb855dfb4af9f"}, - {file = "greenlet-3.0.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10b5582744abd9858947d163843d323d0b67be9432db50f8bf83031032bc218d"}, - {file = "greenlet-3.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f351479a6914fd81a55c8e68963609f792d9b067fb8a60a042c585a621e0de4f"}, - {file = "greenlet-3.0.0-cp38-cp38-win32.whl", hash = "sha256:9de687479faec7db5b198cc365bc34addd256b0028956501f4d4d5e9ca2e240a"}, - {file = "greenlet-3.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:3fd2b18432e7298fcbec3d39e1a0aa91ae9ea1c93356ec089421fabc3651572b"}, - {file = "greenlet-3.0.0-cp38-universal2-macosx_11_0_x86_64.whl", hash = "sha256:3c0d36f5adc6e6100aedbc976d7428a9f7194ea79911aa4bf471f44ee13a9464"}, - {file = "greenlet-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4cd83fb8d8e17633ad534d9ac93719ef8937568d730ef07ac3a98cb520fd93e4"}, - {file = "greenlet-3.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a5b2d4cdaf1c71057ff823a19d850ed5c6c2d3686cb71f73ae4d6382aaa7a06"}, - {file = "greenlet-3.0.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e7dcdfad252f2ca83c685b0fa9fba00e4d8f243b73839229d56ee3d9d219314"}, - {file = "greenlet-3.0.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c94e4e924d09b5a3e37b853fe5924a95eac058cb6f6fb437ebb588b7eda79870"}, - {file = "greenlet-3.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad6fb737e46b8bd63156b8f59ba6cdef46fe2b7db0c5804388a2d0519b8ddb99"}, - {file = "greenlet-3.0.0-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d55db1db455c59b46f794346efce896e754b8942817f46a1bada2d29446e305a"}, - {file = "greenlet-3.0.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:56867a3b3cf26dc8a0beecdb4459c59f4c47cdd5424618c08515f682e1d46692"}, - {file = "greenlet-3.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9a812224a5fb17a538207e8cf8e86f517df2080c8ee0f8c1ed2bdaccd18f38f4"}, - {file = "greenlet-3.0.0-cp39-cp39-win32.whl", hash = "sha256:0d3f83ffb18dc57243e0151331e3c383b05e5b6c5029ac29f754745c800f8ed9"}, - {file = "greenlet-3.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:831d6f35037cf18ca5e80a737a27d822d87cd922521d18ed3dbc8a6967be50ce"}, - {file = "greenlet-3.0.0-cp39-universal2-macosx_11_0_x86_64.whl", hash = "sha256:a048293392d4e058298710a54dfaefcefdf49d287cd33fb1f7d63d55426e4355"}, - {file = "greenlet-3.0.0.tar.gz", hash = "sha256:19834e3f91f485442adc1ee440171ec5d9a4840a1f7bd5ed97833544719ce10b"}, + {file = "greenlet-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f89e21afe925fcfa655965ca8ea10f24773a1791400989ff32f467badfe4a064"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28e89e232c7593d33cac35425b58950789962011cc274aa43ef8865f2e11f46d"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8ba29306c5de7717b5761b9ea74f9c72b9e2b834e24aa984da99cbfc70157fd"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19bbdf1cce0346ef7341705d71e2ecf6f41a35c311137f29b8a2dc2341374565"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:599daf06ea59bfedbec564b1692b0166a0045f32b6f0933b0dd4df59a854caf2"}, + {file = "greenlet-3.0.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b641161c302efbb860ae6b081f406839a8b7d5573f20a455539823802c655f63"}, + {file = "greenlet-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d57e20ba591727da0c230ab2c3f200ac9d6d333860d85348816e1dca4cc4792e"}, + {file = "greenlet-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5805e71e5b570d490938d55552f5a9e10f477c19400c38bf1d5190d760691846"}, + {file = "greenlet-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:52e93b28db27ae7d208748f45d2db8a7b6a380e0d703f099c949d0f0d80b70e9"}, + {file = "greenlet-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f7bfb769f7efa0eefcd039dd19d843a4fbfbac52f1878b1da2ed5793ec9b1a65"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e6c7db42638dc45cf2e13c73be16bf83179f7859b07cfc139518941320be96"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1757936efea16e3f03db20efd0cd50a1c86b06734f9f7338a90c4ba85ec2ad5a"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19075157a10055759066854a973b3d1325d964d498a805bb68a1f9af4aaef8ec"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9d21aaa84557d64209af04ff48e0ad5e28c5cca67ce43444e939579d085da72"}, + {file = "greenlet-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2847e5d7beedb8d614186962c3d774d40d3374d580d2cbdab7f184580a39d234"}, + {file = "greenlet-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:97e7ac860d64e2dcba5c5944cfc8fa9ea185cd84061c623536154d5a89237884"}, + {file = "greenlet-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b2c02d2ad98116e914d4f3155ffc905fd0c025d901ead3f6ed07385e19122c94"}, + {file = "greenlet-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:22f79120a24aeeae2b4471c711dcf4f8c736a2bb2fabad2a67ac9a55ea72523c"}, + {file = "greenlet-3.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:100f78a29707ca1525ea47388cec8a049405147719f47ebf3895e7509c6446aa"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60d5772e8195f4e9ebf74046a9121bbb90090f6550f81d8956a05387ba139353"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:daa7197b43c707462f06d2c693ffdbb5991cbb8b80b5b984007de431493a319c"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea6b8aa9e08eea388c5f7a276fabb1d4b6b9d6e4ceb12cc477c3d352001768a9"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d11ebbd679e927593978aa44c10fc2092bc454b7d13fdc958d3e9d508aba7d0"}, + {file = "greenlet-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbd4c177afb8a8d9ba348d925b0b67246147af806f0b104af4d24f144d461cd5"}, + {file = "greenlet-3.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20107edf7c2c3644c67c12205dc60b1bb11d26b2610b276f97d666110d1b511d"}, + {file = "greenlet-3.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8bef097455dea90ffe855286926ae02d8faa335ed8e4067326257cb571fc1445"}, + {file = "greenlet-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:b2d3337dcfaa99698aa2377c81c9ca72fcd89c07e7eb62ece3f23a3fe89b2ce4"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80ac992f25d10aaebe1ee15df45ca0d7571d0f70b645c08ec68733fb7a020206"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:337322096d92808f76ad26061a8f5fccb22b0809bea39212cd6c406f6a7060d2"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9934adbd0f6e476f0ecff3c94626529f344f57b38c9a541f87098710b18af0a"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc4d815b794fd8868c4d67602692c21bf5293a75e4b607bb92a11e821e2b859a"}, + {file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41bdeeb552d814bcd7fb52172b304898a35818107cc8778b5101423c9017b3de"}, + {file = "greenlet-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6e6061bf1e9565c29002e3c601cf68569c450be7fc3f7336671af7ddb4657166"}, + {file = "greenlet-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:fa24255ae3c0ab67e613556375a4341af04a084bd58764731972bcbc8baeba36"}, + {file = "greenlet-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:b489c36d1327868d207002391f662a1d163bdc8daf10ab2e5f6e41b9b96de3b1"}, + {file = "greenlet-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f33f3258aae89da191c6ebaa3bc517c6c4cbc9b9f689e5d8452f7aedbb913fa8"}, + {file = "greenlet-3.0.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:d2905ce1df400360463c772b55d8e2518d0e488a87cdea13dd2c71dcb2a1fa16"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a02d259510b3630f330c86557331a3b0e0c79dac3d166e449a39363beaae174"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55d62807f1c5a1682075c62436702aaba941daa316e9161e4b6ccebbbf38bda3"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fcc780ae8edbb1d050d920ab44790201f027d59fdbd21362340a85c79066a74"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eddd98afc726f8aee1948858aed9e6feeb1758889dfd869072d4465973f6bfd"}, + {file = "greenlet-3.0.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eabe7090db68c981fca689299c2d116400b553f4b713266b130cfc9e2aa9c5a9"}, + {file = "greenlet-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f2f6d303f3dee132b322a14cd8765287b8f86cdc10d2cb6a6fae234ea488888e"}, + {file = "greenlet-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d923ff276f1c1f9680d32832f8d6c040fe9306cbfb5d161b0911e9634be9ef0a"}, + {file = "greenlet-3.0.1-cp38-cp38-win32.whl", hash = "sha256:0b6f9f8ca7093fd4433472fd99b5650f8a26dcd8ba410e14094c1e44cd3ceddd"}, + {file = "greenlet-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:990066bff27c4fcf3b69382b86f4c99b3652bab2a7e685d968cd4d0cfc6f67c6"}, + {file = "greenlet-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ce85c43ae54845272f6f9cd8320d034d7a946e9773c693b27d620edec825e376"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ee2e967bd7ff85d84a2de09df10e021c9b38c7d91dead95b406ed6350c6997"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87c8ceb0cf8a5a51b8008b643844b7f4a8264a2c13fcbcd8a8316161725383fe"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6a8c9d4f8692917a3dc7eb25a6fb337bff86909febe2f793ec1928cd97bedfc"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fbc5b8f3dfe24784cee8ce0be3da2d8a79e46a276593db6868382d9c50d97b1"}, + {file = "greenlet-3.0.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85d2b77e7c9382f004b41d9c72c85537fac834fb141b0296942d52bf03fe4a3d"}, + {file = "greenlet-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:696d8e7d82398e810f2b3622b24e87906763b6ebfd90e361e88eb85b0e554dc8"}, + {file = "greenlet-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:329c5a2e5a0ee942f2992c5e3ff40be03e75f745f48847f118a3cfece7a28546"}, + {file = "greenlet-3.0.1-cp39-cp39-win32.whl", hash = "sha256:cf868e08690cb89360eebc73ba4be7fb461cfbc6168dd88e2fbbe6f31812cd57"}, + {file = "greenlet-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:ac4a39d1abae48184d420aa8e5e63efd1b75c8444dd95daa3e03f6c6310e9619"}, + {file = "greenlet-3.0.1.tar.gz", hash = "sha256:816bd9488a94cba78d93e1abb58000e8266fa9cc2aa9ccdd6eb0696acb24005b"}, ] [package.extras] @@ -813,13 +895,13 @@ test = ["objgraph", "psutil"] [[package]] name = "identify" -version = "2.5.30" +version = "2.5.31" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.30-py2.py3-none-any.whl", hash = "sha256:afe67f26ae29bab007ec21b03d4114f41316ab9dd15aa8736a167481e108da54"}, - {file = "identify-2.5.30.tar.gz", hash = "sha256:f302a4256a15c849b91cfcdcec052a8ce914634b2f77ae87dad29cd749f2d88d"}, + {file = "identify-2.5.31-py2.py3-none-any.whl", hash = "sha256:90199cb9e7bd3c5407a9b7e81b4abec4bb9d249991c79439ec8af740afc6293d"}, + {file = "identify-2.5.31.tar.gz", hash = "sha256:7736b3c7a28233637e3c36550646fc6389bedd74ae84cb788200cc8e2dd60b75"}, ] [package.extras] @@ -864,6 +946,17 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "logging-journald" +version = "0.6.6" +description = "Pure python logging handler for writing logs to the journald using native protocol" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "logging_journald-0.6.6-py3-none-any.whl", hash = "sha256:afcedadcd82c668794a68aa1243fbe0f6e0ecfec1f24c79b3baa064a5d18af14"}, + {file = "logging_journald-0.6.6.tar.gz", hash = "sha256:ba71558cd10d07fe06a9713342026215a7ac792a55658d078d8f4d88964b350b"}, +] + [[package]] name = "magic-filter" version = "1.0.12" @@ -1230,13 +1323,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "polyfactory" -version = "2.10.0" +version = "2.11.0" description = "Mock data generation factories" optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "polyfactory-2.10.0-py3-none-any.whl", hash = "sha256:5ddb8a8b67a0f17722537266baeeb8a39932ca493dd757b96dac513fb090cb02"}, - {file = "polyfactory-2.10.0.tar.gz", hash = "sha256:ca4f8acbb308567ee429b2f99967cecf880fa481a0788a80ad676017bb083ceb"}, + {file = "polyfactory-2.11.0-py3-none-any.whl", hash = "sha256:f1146ee171b55575a50c93cb36d0d68c5ab69b85ab747d7791bcf986a1be1253"}, + {file = "polyfactory-2.11.0.tar.gz", hash = "sha256:b31b4997cc8128568f73f641b6858ed64be0fd3fd3627071c027b619c5b14d88"}, ] [package.dependencies] @@ -1538,13 +1631,13 @@ plugins = ["importlib-metadata"] [[package]] name = "pytest" -version = "7.4.2" +version = "7.4.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, - {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, ] [package.dependencies] @@ -1592,6 +1685,21 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +[[package]] +name = "pytest-subtests" +version = "0.11.0" +description = "unittest subTest() support and subtests fixture" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-subtests-0.11.0.tar.gz", hash = "sha256:51865c88457545f51fb72011942f0a3c6901ee9e24cbfb6d1b9dc1348bafbe37"}, + {file = "pytest_subtests-0.11.0-py3-none-any.whl", hash = "sha256:453389984952eec85ab0ce0c4f026337153df79587048271c7fd0f49119c07e4"}, +] + +[package.dependencies] +attrs = ">=19.2.0" +pytest = ">=7.0" + [[package]] name = "python-dateutil" version = "2.8.2" @@ -1914,15 +2022,59 @@ files = [ {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] +[[package]] +name = "uvloop" +version = "0.19.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, + {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, +] + +[package.extras] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + [[package]] name = "virtualenv" -version = "20.24.5" +version = "20.24.6" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.24.5-py3-none-any.whl", hash = "sha256:b80039f280f4919c77b30f1c23294ae357c4c8701042086e3fc005963e4e537b"}, - {file = "virtualenv-20.24.5.tar.gz", hash = "sha256:e8361967f6da6fbdf1426483bfe9fca8287c242ac0bc30429905721cefbff752"}, + {file = "virtualenv-20.24.6-py3-none-any.whl", hash = "sha256:520d056652454c5098a00c0f073611ccbea4c79089331f60bf9d7ba247bb7381"}, + {file = "virtualenv-20.24.6.tar.gz", hash = "sha256:02ece4f56fbf939dbbc33c0715159951d6bf14aaf5457b092e4548e1382455af"}, ] [package.dependencies] @@ -2024,4 +2176,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "a3b087d6ad2bbd25192c3481d51c8140736bb220cc2c7ca3d525bb2634afa64e" +content-hash = "3e754d55279130bbdc03190a89c21b98cbb4b7fd298f81aafc6cdc217e652501" diff --git a/pyproject.toml b/pyproject.toml index a9427d6..b2cfe0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.11" aiogram = "^3.0.0" -aiogram-dialog = "^2.0.0" +aiogram-dialog = "2.1.0b2" SQLAlchemy = "^2.0.21" SQLAlchemy-Utils = "^0.41.1" alembic = "^1.12.0" @@ -20,6 +20,9 @@ psycopg2-binary = "^2.9.7" redis = "^5.0.1" pytz = "^2023.3.post1" greenlet = "^3.0.0" +aiomisc = {extras = ["uvloop"], version = "^17.3.23"} +aiomisc-dependency = "^0.1.20" +aiomisc-pytest = "^1.1.1" [tool.poetry.group.dev.dependencies] mypy = "^1.5.1" @@ -32,15 +35,23 @@ polyfactory = "^2.10.0" pytest = "^7.4.2" pytest-asyncio = "^0.21.1" pytest-cov = "^4.1.0" +pytest-subtests = "^0.11.0" [tool.poetry.scripts] -bot = "inclusive_dance_bot.bot.__main__:main" +bot = "inclusive_dance_bot.__main__:main" init_data = "inclusive_dance_bot.init_data:main" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" +[tool.pytest.ini_options] +asyncio_mode = "auto" +python_files = "test_*" +python_functions = "test_*" +python_classes = "TestSuite*" +addopts = "-p no:cacheprovider" + [tool.black] target-version = ["py311"] @@ -65,6 +76,10 @@ ignore_missing_imports = true module = "sqlalchemy_utils.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "aiomisc_dependency.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "celery.*" ignore_missing_imports = true diff --git a/tests/conftest.py b/tests/conftest.py index 19852aa..a249ae9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ from pathlib import Path import pytest -import pytest_asyncio from alembic.config import Config as AlembicConfig from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker @@ -14,7 +13,7 @@ from tests.utils import prepare_new_database, run_async_migrations TABLES_FOR_TRUNCATE = ( - "entity", + "submenu", "url", "users", "user_type", @@ -35,7 +34,13 @@ def event_loop(): @pytest.fixture(scope="session") def settings() -> Settings: - settings = Settings() + settings = Settings( + TELEGRAM_BOT_TOKEN="1234567890:ABCDEFGHIGHLMNOPQRST", + REDIS_HOST="localhost", + REDIS_PORT=6379, + REDIS_PASSWORD="secret", + REDIS_DB="1", + ) settings.POSTGRES_DB = "test_" + settings.POSTGRES_DB return settings @@ -47,7 +52,7 @@ def alembic_config(settings: Settings) -> AlembicConfig: return alembic_cfg -@pytest_asyncio.fixture(scope="session") +@pytest.fixture(scope="session") async def async_engine( settings: Settings, alembic_config: AlembicConfig ) -> AsyncEngine: @@ -65,7 +70,7 @@ def sessionmaker(async_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: yield create_session_factory(engine=async_engine) -@pytest_asyncio.fixture(autouse=True) +@pytest.fixture(autouse=True) async def session( sessionmaker: async_sessionmaker[AsyncSession], async_engine: AsyncEngine ) -> AsyncSession: @@ -77,7 +82,6 @@ async def session( finally: await session.close() await _clear_db(async_engine) - print("in fixture") async def _clear_db(engine: AsyncEngine) -> None: diff --git a/tests/factories.py b/tests/factories.py index c3b9517..5f39ab7 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -4,8 +4,8 @@ handle_constrained_string_or_bytes, ) -from inclusive_dance_bot.db.models import Entity, Feedback, Url, User, UserType -from inclusive_dance_bot.enums import EntityType +from inclusive_dance_bot.db.models import Feedback, Submenu, Url, User, UserType +from inclusive_dance_bot.enums import SubmenuType class UserFactory(SQLAlchemyFactory[User]): @@ -24,12 +24,12 @@ class UserTypeFactory(SQLAlchemyFactory[UserType]): __set_relationships__ = True -class EntityFactory(SQLAlchemyFactory[Entity]): - __model__ = Entity +class SubmenuFactory(SQLAlchemyFactory[Submenu]): + __model__ = Submenu __set_foreign_keys__ = False __set_relationships__ = True - type = Use(SQLAlchemyFactory.__random__.choice, list(EntityType)) + type = Use(SQLAlchemyFactory.__random__.choice, list(SubmenuType)) class UrlFactory(SQLAlchemyFactory[Url]): @@ -47,7 +47,7 @@ class FeedbackFactory(SQLAlchemyFactory[Feedback]): FACTORIES: tuple[SQLAlchemyFactory, ...] = ( UserFactory, UserTypeFactory, - EntityFactory, + SubmenuFactory, UrlFactory, FeedbackFactory, ) diff --git a/tests/test_database/test_migrations_up_to_date.py b/tests/test_database/test_migrations_up_to_date.py index 7e7cf7a..1e20091 100644 --- a/tests/test_database/test_migrations_up_to_date.py +++ b/tests/test_database/test_migrations_up_to_date.py @@ -1,11 +1,9 @@ -import pytest from sqlalchemy.ext.asyncio import AsyncEngine from inclusive_dance_bot.db.models import Base from tests.utils import get_diff_db_metadata -@pytest.mark.asyncio async def test_migrations_up_to_date(async_engine: AsyncEngine) -> None: async with async_engine.connect() as connection: diff = await connection.run_sync( diff --git a/tests/test_database/test_stairway.py b/tests/test_database/test_stairway.py new file mode 100644 index 0000000..3d9b3c8 --- /dev/null +++ b/tests/test_database/test_stairway.py @@ -0,0 +1,18 @@ +from alembic.command import downgrade, upgrade +from alembic.config import Config +from alembic.script import Script, ScriptDirectory + + +def get_revisions(alembic_config: Config) -> list[Script]: + revisions_dir = ScriptDirectory.from_config(alembic_config) + revisions = list(revisions_dir.walk_revisions("base", "heads")) + revisions.reverse() + return revisions + + +def test_migrations_stairway(alembic_config, subtests): + for revision in get_revisions(alembic_config): + with subtests.test(msg="revision", revision=revision): + upgrade(alembic_config, revision.revision) + downgrade(alembic_config, revision.down_revision or "-1") + upgrade(alembic_config, revision.revision) diff --git a/tests/test_unit/test_services/conftest.py b/tests/test_unit/conftest.py similarity index 74% rename from tests/test_unit/test_services/conftest.py rename to tests/test_unit/conftest.py index aa74edf..ce0ae9a 100644 --- a/tests/test_unit/test_services/conftest.py +++ b/tests/test_unit/conftest.py @@ -1,17 +1,17 @@ -import pytest_asyncio +import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.services.storage import Storage +from inclusive_dance_bot.logic.storage import Storage -@pytest_asyncio.fixture +@pytest.fixture async def uow(sessionmaker: async_sessionmaker[AsyncSession]) -> UnitOfWork: uow = UnitOfWork(sessionmaker=sessionmaker) async with uow: yield uow -@pytest_asyncio.fixture +@pytest.fixture async def storage(uow: UnitOfWork) -> Storage: return Storage(uow=uow) diff --git a/tests/test_unit/test_init_data.py b/tests/test_unit/test_init_data.py new file mode 100644 index 0000000..1c7d1c7 --- /dev/null +++ b/tests/test_unit/test_init_data.py @@ -0,0 +1,12 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.init_data import SUBMENUS, URLS, USER_TYPES, init_data + + +async def test_init_data_from_scratch(uow: UnitOfWork, session: AsyncSession) -> None: + await init_data(uow=uow) + + assert len(await uow.urls.get_list()) == len(URLS) + assert len(await uow.submenus.get_list()) == len(SUBMENUS) + assert len(await uow.user_types.get_list()) == len(USER_TYPES) diff --git a/tests/test_unit/test_repositories/conftest.py b/tests/test_unit/test_repositories/conftest.py index d4d25ea..83794cc 100644 --- a/tests/test_unit/test_repositories/conftest.py +++ b/tests/test_unit/test_repositories/conftest.py @@ -1,33 +1,39 @@ -import pytest_asyncio +import pytest from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.repositories.entity import EntityRepository +from inclusive_dance_bot.db.repositories.feedback import FeedbackRepository +from inclusive_dance_bot.db.repositories.submenu import SubmenuRepository from inclusive_dance_bot.db.repositories.url import UrlRepository from inclusive_dance_bot.db.repositories.user import UserRepository from inclusive_dance_bot.db.repositories.user_type import UserTypeRepository from inclusive_dance_bot.db.repositories.user_type_user import UserTypeUserRepository -@pytest_asyncio.fixture -def entity_repo(session: AsyncSession) -> EntityRepository: - return EntityRepository(session=session) +@pytest.fixture +def submenu_repo(session: AsyncSession) -> SubmenuRepository: + return SubmenuRepository(session=session) -@pytest_asyncio.fixture +@pytest.fixture def url_repo(session: AsyncSession) -> UrlRepository: return UrlRepository(session=session) -@pytest_asyncio.fixture +@pytest.fixture def user_type_repo(session: AsyncSession) -> UserTypeRepository: return UserTypeRepository(session=session) -@pytest_asyncio.fixture +@pytest.fixture def user_repo(session: AsyncSession) -> UserRepository: return UserRepository(session=session) -@pytest_asyncio.fixture +@pytest.fixture def user_type_user_repo(session: AsyncSession) -> UserTypeUserRepository: return UserTypeUserRepository(session=session) + + +@pytest.fixture +def feedback_repo(session: AsyncSession) -> FeedbackRepository: + return FeedbackRepository(session=session) diff --git a/tests/test_unit/test_repositories/test_entity.py b/tests/test_unit/test_repositories/test_entity.py deleted file mode 100644 index 90b613b..0000000 --- a/tests/test_unit/test_repositories/test_entity.py +++ /dev/null @@ -1,91 +0,0 @@ -import pytest -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import Entity -from inclusive_dance_bot.db.repositories.entity import EntityRepository -from inclusive_dance_bot.dto import EntityDto -from inclusive_dance_bot.enums import EntityType -from inclusive_dance_bot.exceptions import EntityAlreadyExistsError, EntityNotFoundError -from tests.factories import EntityFactory - -pytestmark = [pytest.mark.asyncio] - - -async def test_create_entity( - entity_repo: EntityRepository, session: AsyncSession -) -> None: - entity = await entity_repo.create( - type=EntityType.INFORMATION, - text="some entity", - message="Very long message message", - ) - await session.commit() - saved_entity = await session.get(Entity, entity.id) - assert entity == EntityDto.from_orm(saved_entity) - - -async def test_invalid_double_create( - entity_repo: EntityRepository, session: AsyncSession -) -> None: - await entity_repo.create( - id=1, - type=EntityType.INFORMATION, - text="some entity", - message="Very long message message", - ) - await session.commit() - with pytest.raises(EntityAlreadyExistsError): - await entity_repo.create( - id=1, - type=EntityType.INFORMATION, - text="some entity", - message="Very long message message", - ) - - -async def test_get_entity_by_id(entity_repo: EntityRepository) -> None: - entity = await EntityFactory.create_async() - loaded_entity = await entity_repo.get_entity_by_id(entity.id) - assert EntityDto.from_orm(entity) == loaded_entity - - -async def test_entity_not_found_by_id(entity_repo: EntityRepository) -> None: - with pytest.raises(EntityNotFoundError): - await entity_repo.get_entity_by_id(-1) - - -async def test_get_all_entities_emtpy(entity_repo: EntityRepository) -> None: - empty = await entity_repo.get_all_entities() - assert empty == tuple() - - -async def test_get_all_entities(entity_repo: EntityRepository) -> None: - entities = await EntityFactory.create_batch_async(size=5) - loaded_entities = await entity_repo.get_all_entities() - assert {EntityDto.from_orm(e) for e in entities} == set(loaded_entities) - - -async def test_get_all_entities_order_by_weight(entity_repo: EntityRepository) -> None: - third = await EntityFactory.create_async(weight=1) - first = await EntityFactory.create_async(weight=100) - second = await EntityFactory.create_async(weight=30) - - loaded_entities = await entity_repo.get_all_entities() - assert loaded_entities == tuple( - EntityDto.from_orm(e) for e in (first, second, third) - ) - - -async def test_get_entities_by_type(entity_repo: EntityRepository) -> None: - target_type = await EntityFactory.create_async(type=EntityType.CHARITY) - - charity_entities = await entity_repo.get_entities_by_type(EntityType.CHARITY) - - assert set(charity_entities) == {EntityDto.from_orm(target_type)} - - -async def test_get_entities_by_type(entity_repo: EntityRepository) -> None: - await EntityFactory.create_async(type=EntityType.EDUCATION) - - charity_entities = await entity_repo.get_entities_by_type(EntityType.CHARITY) - assert set(charity_entities) == set() diff --git a/tests/test_unit/test_repositories/test_feedback.py b/tests/test_unit/test_repositories/test_feedback.py index e69de29..bc1459c 100644 --- a/tests/test_unit/test_repositories/test_feedback.py +++ b/tests/test_unit/test_repositories/test_feedback.py @@ -0,0 +1,31 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from inclusive_dance_bot.db.models import Feedback +from inclusive_dance_bot.db.repositories.feedback import FeedbackRepository +from inclusive_dance_bot.dto import FeedbackDto +from inclusive_dance_bot.enums import FeedbackType +from inclusive_dance_bot.exceptions import InvalidUserIDError +from tests.factories import UserFactory + + +async def test_create(feedback_repo: FeedbackRepository, session: AsyncSession) -> None: + user = await UserFactory.create_async() + feedback = await feedback_repo.create( + user_id=user.id, + type=FeedbackType.QUESTION, + title="Some question", + text="Very important question", + ) + loaded_feedback = await session.get(Feedback, feedback.id) + assert feedback == FeedbackDto.from_orm(loaded_feedback) + + +async def test_invalid_user_id(feedback_repo: FeedbackRepository) -> None: + with pytest.raises(InvalidUserIDError): + await feedback_repo.create( + user_id=-1, + type=FeedbackType.ADVERTISEMENT, + title="Some advertisement", + text="With invalid user ID", + ) diff --git a/tests/test_unit/test_repositories/test_submenu.py b/tests/test_unit/test_repositories/test_submenu.py new file mode 100644 index 0000000..bcbaf18 --- /dev/null +++ b/tests/test_unit/test_repositories/test_submenu.py @@ -0,0 +1,90 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from inclusive_dance_bot.db.models import Submenu +from inclusive_dance_bot.db.repositories.submenu import SubmenuRepository +from inclusive_dance_bot.dto import SubmenuDto +from inclusive_dance_bot.enums import SubmenuType +from inclusive_dance_bot.exceptions import ( + SubmenuAlreadyExistsError, + SubmenuNotFoundError, +) +from tests.factories import SubmenuFactory + + +async def test_create(submenu_repo: SubmenuRepository, session: AsyncSession) -> None: + submenu = await submenu_repo.create( + type=SubmenuType.INFORMATION, + button_text="some submenu", + message="Very long message message", + ) + await session.commit() + saved_submenu = await session.get(Submenu, submenu.id) + assert submenu == SubmenuDto.from_orm(saved_submenu) + + +async def test_invalid_double_create( + submenu_repo: SubmenuRepository, session: AsyncSession +) -> None: + await submenu_repo.create( + id=1, + type=SubmenuType.INFORMATION, + button_text="some submenu", + message="Very long message message", + ) + await session.commit() + with pytest.raises(SubmenuAlreadyExistsError): + await submenu_repo.create( + id=1, + type=SubmenuType.INFORMATION, + button_text="some submenu", + message="Very long message message", + ) + + +async def test_get_by_id(submenu_repo: SubmenuRepository) -> None: + submenu = await SubmenuFactory.create_async() + loaded_submenu = await submenu_repo.get_by_id(submenu.id) + assert SubmenuDto.from_orm(submenu) == loaded_submenu + + +async def test_not_found_by_id(submenu_repo: SubmenuRepository) -> None: + with pytest.raises(SubmenuNotFoundError): + await submenu_repo.get_by_id(-1) + + +async def test_get_list_emtpy(submenu_repo: SubmenuRepository) -> None: + empty = await submenu_repo.get_list() + assert empty == tuple() + + +async def test_get_list(submenu_repo: SubmenuRepository) -> None: + submenus = await SubmenuFactory.create_batch_async(size=5) + loaded_submenus = await submenu_repo.get_list() + assert {SubmenuDto.from_orm(s) for s in submenus} == set(loaded_submenus) + + +async def test_get_list_order_by_weight(submenu_repo: SubmenuRepository) -> None: + third = await SubmenuFactory.create_async(weight=1) + first = await SubmenuFactory.create_async(weight=100) + second = await SubmenuFactory.create_async(weight=30) + + loaded_submenus = await submenu_repo.get_list() + assert loaded_submenus == tuple( + SubmenuDto.from_orm(e) for e in (first, second, third) + ) + + +async def test_get_list_by_type(submenu_repo: SubmenuRepository) -> None: + target_type = await SubmenuFactory.create_async(type=SubmenuType.CHARITY) + + charities = await submenu_repo.get_list_by_type(SubmenuType.CHARITY) + + assert set(charities) == {SubmenuDto.from_orm(target_type)} + + +async def test_get_list_by_type(submenu_repo: SubmenuRepository) -> None: + await SubmenuFactory.create_async(type=SubmenuType.EDUCATION) + + charities = await submenu_repo.get_list_by_type(SubmenuType.CHARITY) + assert set(charities) == set() diff --git a/tests/test_unit/test_repositories/test_url.py b/tests/test_unit/test_repositories/test_url.py index 107ea2b..ac4ef82 100644 --- a/tests/test_unit/test_repositories/test_url.py +++ b/tests/test_unit/test_repositories/test_url.py @@ -10,8 +10,6 @@ ) from tests.factories import UrlFactory -pytestmark = [pytest.mark.asyncio] - async def test_create_url(url_repo: UrlRepository, session: AsyncSession) -> None: url = await url_repo.create( @@ -54,13 +52,13 @@ async def test_invalid_double_create_by_slug( ) -async def test_get_all_urls_empty(url_repo: UrlRepository) -> None: - loaded_urls = await url_repo.get_all_urls() +async def test_get_list_empty(url_repo: UrlRepository) -> None: + loaded_urls = await url_repo.get_list() assert loaded_urls == tuple() -async def test_get_all_urls(url_repo: UrlRepository) -> None: +async def test_get_list(url_repo: UrlRepository) -> None: urls = await UrlFactory.create_batch_async(size=5) - loaded_urls = await url_repo.get_all_urls() + loaded_urls = await url_repo.get_list() assert set(loaded_urls) == {UrlDto.from_orm(u) for u in urls} diff --git a/tests/test_unit/test_repositories/test_user.py b/tests/test_unit/test_repositories/test_user.py index 342fd36..ba1701b 100644 --- a/tests/test_unit/test_repositories/test_user.py +++ b/tests/test_unit/test_repositories/test_user.py @@ -3,15 +3,14 @@ from inclusive_dance_bot.db.models import User from inclusive_dance_bot.db.repositories.user import UserRepository -from inclusive_dance_bot.dto import UserDto +from inclusive_dance_bot.dto import ANONYMOUS_USER, UserDto from inclusive_dance_bot.exceptions import UserAlreadyExistsError from tests.factories import UserFactory -pytestmark = [pytest.mark.asyncio] - async def test_create_user(user_repo: UserRepository, session: AsyncSession) -> None: user = await user_repo.create( + username="username", user_id=1, name="user name", region="Tatuin", @@ -25,6 +24,7 @@ async def test_create_user(user_repo: UserRepository, session: AsyncSession) -> async def test_invalid_double_create(user_repo: UserRepository) -> None: await user_repo.create( user_id=1, + username="username", name="user name", region="Tatuin", phone_number="+77777777", @@ -32,6 +32,7 @@ async def test_invalid_double_create(user_repo: UserRepository) -> None: with pytest.raises(UserAlreadyExistsError): await user_repo.create( user_id=1, + username="username", name="user name", region="Tatuin", phone_number="+77777777", @@ -40,10 +41,10 @@ async def test_invalid_double_create(user_repo: UserRepository) -> None: async def test_get_by_id(user_repo: UserRepository) -> None: user = await UserFactory.create_async() - loaded_user = await user_repo.get_by_id_or_none(user.id) + loaded_user = await user_repo.get_by_id(user.id) assert loaded_user == UserDto.from_orm(user) -async def test_get_by_id_none(user_repo: UserRepository) -> None: - empty = await user_repo.get_by_id_or_none(-1) - assert empty is None +async def test_get_anonymous(user_repo: UserRepository) -> None: + anonymous = await user_repo.get_by_id(-1) + assert anonymous == ANONYMOUS_USER diff --git a/tests/test_unit/test_repositories/test_user_type.py b/tests/test_unit/test_repositories/test_user_type.py index 8344f71..a15dfa7 100644 --- a/tests/test_unit/test_repositories/test_user_type.py +++ b/tests/test_unit/test_repositories/test_user_type.py @@ -7,8 +7,6 @@ from inclusive_dance_bot.exceptions import UserTypeAlreadyExistsError from tests.factories import UserTypeFactory -pytestmark = [pytest.mark.asyncio] - async def test_create_user_type( user_type_repo: UserTypeRepository, session: AsyncSession @@ -25,13 +23,13 @@ async def test_invalid_double_create(user_type_repo: UserTypeRepository) -> None await user_type_repo.create(name="New user type") -async def test_get_all_user_types_empty(user_type_repo: UserTypeRepository) -> None: - user_types = await user_type_repo.get_all_user_types() +async def test_get_list_empty(user_type_repo: UserTypeRepository) -> None: + user_types = await user_type_repo.get_list() assert user_types == tuple() -async def test_get_all_user_types(user_type_repo: UserTypeRepository) -> None: +async def test_get_list(user_type_repo: UserTypeRepository) -> None: user_types = await UserTypeFactory.create_batch_async(size=5) - loaded_user_types = await user_type_repo.get_all_user_types() + loaded_user_types = await user_type_repo.get_list() assert set(loaded_user_types) == {UserTypeDto.from_orm(ut) for ut in user_types} diff --git a/tests/test_unit/test_repositories/test_user_type_user.py b/tests/test_unit/test_repositories/test_user_type_user.py index 9649079..bf5b355 100644 --- a/tests/test_unit/test_repositories/test_user_type_user.py +++ b/tests/test_unit/test_repositories/test_user_type_user.py @@ -10,8 +10,6 @@ ) from tests.factories import UserFactory, UserTypeFactory -pytestmark = [pytest.mark.asyncio] - async def test_create( user_type_user_repo: UserTypeUserRepository, session: AsyncSession diff --git a/tests/test_unit/test_services/test_feedback/__init__.py b/tests/test_unit/test_services/test_feedback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_services/test_feedback/test_create_feedback.py b/tests/test_unit/test_services/test_feedback/test_create_feedback.py new file mode 100644 index 0000000..5b59ef1 --- /dev/null +++ b/tests/test_unit/test_services/test_feedback/test_create_feedback.py @@ -0,0 +1,39 @@ +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from inclusive_dance_bot.db.models import Feedback +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.enums import FeedbackType +from inclusive_dance_bot.exceptions import InvalidUserIDError +from inclusive_dance_bot.logic.feedback import create_feedback +from tests.factories import UserFactory + + +async def test_create_successful(uow: UnitOfWork, session: AsyncSession) -> None: + user = await UserFactory.create_async() + await create_feedback( + uow=uow, + user_id=user.id, + type=FeedbackType.QUESTION, + title="New question", + text="Very important question", + ) + query = select(Feedback).filter_by(user_id=user.id) + feedback = (await session.scalars(query)).first() + + assert feedback.user_id == user.id + assert feedback.type == FeedbackType.QUESTION + assert feedback.title == "New question" + assert feedback.text == "Very important question" + + +async def test_error_user_id(uow: UnitOfWork, session: AsyncSession) -> None: + with pytest.raises(InvalidUserIDError): + await create_feedback( + uow=uow, + user_id=1, + type=FeedbackType.ADVERTISEMENT, + title="Invalid user id", + text="", + ) diff --git a/tests/test_unit/test_services/test_mailing/__init__.py b/tests/test_unit/test_services/test_mailing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_services/test_mailing/test_process_new_mailing.py b/tests/test_unit/test_services/test_mailing/test_process_new_mailing.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_services/test_mailing/test_save_mailing.py b/tests/test_unit/test_services/test_mailing/test_save_mailing.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_services/test_mailing/test_send_mailngs.py b/tests/test_unit/test_services/test_mailing/test_send_mailngs.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_services/test_storage.py b/tests/test_unit/test_services/test_storage.py index e69de29..70d8ac9 100644 --- a/tests/test_unit/test_services/test_storage.py +++ b/tests/test_unit/test_services/test_storage.py @@ -0,0 +1,73 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from inclusive_dance_bot.dto import UrlDto +from inclusive_dance_bot.logic.storage import Storage +from tests.factories import UrlFactory + + +def test_initial_storage_is_empty(storage: Storage) -> None: + assert len(storage._cache) == 0 + + +async def test_get_urls(storage: Storage) -> None: + first_url = await UrlFactory.create_async() + second_url = await UrlFactory.create_async() + urls = await storage.get_urls() + assert urls == { + first_url.slug: UrlDto.from_orm(first_url), + second_url.slug: UrlDto.from_orm(second_url), + } + + +async def test_cache_get_urls(storage: Storage, session: AsyncSession) -> None: + first_url = await UrlFactory.create_async() + second_url = await UrlFactory.create_async() + await storage.get_urls() + await session.delete(second_url) + await session.delete(first_url) + await session.commit() + urls = await storage.get_urls() + assert urls == { + first_url.slug: UrlDto.from_orm(first_url), + second_url.slug: UrlDto.from_orm(second_url), + } + + +async def test_get_url_by_slug(storage: Storage) -> None: + new_url = await UrlFactory.create_async(slug="my_url") + url = await storage.get_url_by_slug("my_url") + assert UrlDto.from_orm(new_url) == url + + +async def test_cache_get_url_by_slug(storage: Storage, session: AsyncSession) -> None: + url = await UrlFactory.create_async(slug="my_slug") + await storage.get_url_by_slug("my_slug") + + await session.delete(url) + + cached_url = await storage.get_url_by_slug("my_slug") + assert cached_url.slug == "my_slug" + + +async def test_get_user_types(storage: Storage) -> None: + pass + + +async def test_cache_get_user_types(storage: Storage, session: AsyncSession) -> None: + pass + + +async def test_get_submenus(storage: Storage) -> None: + pass + + +async def test_cache_get_submenus(storage: Storage, session: AsyncSession) -> None: + pass + + +async def test_refresh_all(storage: Storage, session: AsyncSession) -> None: + pass + + +async def test_refresh_urls(storage: Storage, session: AsyncSession) -> None: + pass diff --git a/tests/test_unit/test_services/test_url/__init__.py b/tests/test_unit/test_services/test_url/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_services/test_url/test_create_url.py b/tests/test_unit/test_services/test_url/test_create_url.py new file mode 100644 index 0000000..7ad39fe --- /dev/null +++ b/tests/test_unit/test_services/test_url/test_create_url.py @@ -0,0 +1,30 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from inclusive_dance_bot.db.models import Url +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.dto import UrlDto +from inclusive_dance_bot.exceptions.url import UrlSlugAlreadyExistsError +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.url import create_url +from tests.factories import UrlFactory + + +async def test_create_successful( + uow: UnitOfWork, + storage: Storage, + session: AsyncSession, +) -> None: + slug = "new_url_slug" + value = "https://example.com" + + url = await create_url(uow=uow, storage=storage, slug=slug, value=value) + + loaded_url = await session.get(Url, url.id) + assert UrlDto.from_orm(loaded_url) == url + + +async def test_error_url_slug_already_exists(uow: UnitOfWork, storage: Storage) -> None: + url = await UrlFactory.create_async() + with pytest.raises(UrlSlugAlreadyExistsError): + await create_url(uow=uow, storage=storage, slug=url.slug, value="somevalue") diff --git a/tests/test_unit/test_services/test_url/test_delete_url_by_slug.py b/tests/test_unit/test_services/test_url/test_delete_url_by_slug.py new file mode 100644 index 0000000..500794c --- /dev/null +++ b/tests/test_unit/test_services/test_url/test_delete_url_by_slug.py @@ -0,0 +1,20 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from inclusive_dance_bot.db.models import Url +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.url import delete_url_by_slug +from tests.factories import UrlFactory + + +async def test_delete_successful( + uow: UnitOfWork, storage: Storage, session: AsyncSession +) -> None: + await UrlFactory.create_async(id=1, slug="new_url") + + await delete_url_by_slug(uow=uow, storage=storage, url_slug="new_url") + assert await session.get(Url, 1) is None + + +async def test_delete_unknown_slug(uow: UnitOfWork, storage: Storage) -> None: + await delete_url_by_slug(uow=uow, storage=storage, url_slug="unknown") diff --git a/tests/test_unit/test_services/test_url/test_update_url_by_slug.py b/tests/test_unit/test_services/test_url/test_update_url_by_slug.py new file mode 100644 index 0000000..b2140f2 --- /dev/null +++ b/tests/test_unit/test_services/test_url/test_update_url_by_slug.py @@ -0,0 +1,43 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from inclusive_dance_bot.db.uow.main import UnitOfWork +from inclusive_dance_bot.dto import UrlDto +from inclusive_dance_bot.exceptions.url import ( + UrlNotFoundError, + UrlSlugAlreadyExistsError, +) +from inclusive_dance_bot.logic.storage import Storage +from inclusive_dance_bot.logic.url import update_url_by_slug +from tests.factories import UrlFactory + + +async def test_update_successful( + uow: UnitOfWork, storage: Storage, session: AsyncSession +) -> None: + url = await UrlFactory.create_async(slug="slug") + updated_url = await update_url_by_slug( + uow=uow, storage=storage, url_slug="slug", value="https://vk.com" + ) + + await session.refresh(url) + assert url.value == "https://vk.com" + + assert updated_url == UrlDto.from_orm(url) + + +async def test_error_url_not_found(uow: UnitOfWork, storage: Storage) -> None: + with pytest.raises(UrlNotFoundError): + await update_url_by_slug(uow=uow, storage=storage, url_slug="unknown", value="") + + +async def test_error_url_slug_already_exists(uow: UnitOfWork, storage: Storage) -> None: + await UrlFactory.create_async(slug="first_url") + await UrlFactory.create_async(slug="second_url") + with pytest.raises(UrlSlugAlreadyExistsError): + await update_url_by_slug( + uow=uow, + storage=storage, + url_slug="second_url", + slug="first_url", + ) diff --git a/tests/test_unit/test_services/test_user/__init__.py b/tests/test_unit/test_services/test_user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_services/test_save_new_user.py b/tests/test_unit/test_services/test_user/test_create_user.py similarity index 85% rename from tests/test_unit/test_services/test_save_new_user.py rename to tests/test_unit/test_services/test_user/test_create_user.py index dce6e44..98b9dc6 100644 --- a/tests/test_unit/test_services/test_save_new_user.py +++ b/tests/test_unit/test_services/test_user/test_create_user.py @@ -8,17 +8,16 @@ InvalidUserTypeIDError, UserAlreadyExistsError, ) -from inclusive_dance_bot.services.save_data import save_new_user +from inclusive_dance_bot.logic.user import create_user from tests.factories import UserFactory, UserTypeFactory -pytestmark = [pytest.mark.asyncio] - -async def test_save_successful(uow: UnitOfWork, session: AsyncSession) -> None: +async def test_create_successful(uow: UnitOfWork, session: AsyncSession) -> None: user_types = await UserTypeFactory.create_batch_async(size=3) user_id = 1 - await save_new_user( + await create_user( uow=uow, + username="username", user_id=user_id, name="New user", region="Some region", @@ -42,9 +41,10 @@ async def test_save_successful(uow: UnitOfWork, session: AsyncSession) -> None: async def test_error_user_type(uow: UnitOfWork, session: AsyncSession) -> None: user_id = 1 with pytest.raises(InvalidUserTypeIDError): - await save_new_user( + await create_user( uow=uow, user_id=user_id, + username="username", name="New user", region="Some region", phone_number="+79999999", @@ -57,9 +57,10 @@ async def test_error_user_type(uow: UnitOfWork, session: AsyncSession) -> None: async def test_error_user_id_already_exists(uow: UnitOfWork) -> None: user = await UserFactory.create_async() with pytest.raises(UserAlreadyExistsError): - await save_new_user( + await create_user( uow=uow, user_id=user.id, + username="username", name="New user", region="Some region", phone_number="+79999999",