├── .github └── workflows │ └── CI.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── configs └── settings.toml ├── docker ├── Dockerfile └── docker-compose.yaml ├── poetry.lock ├── pyproject.toml └── src ├── __init__.py ├── core ├── __init__.py ├── config │ ├── __init__.py │ ├── parser.py │ └── validator.py └── utils │ ├── __init__.py │ ├── builders.py │ └── logging.py ├── infrastructure ├── __init__.py ├── database │ ├── __init__.py │ ├── adapter │ │ ├── __init__.py │ │ └── adapter.py │ ├── interfaces │ │ ├── __init__.py │ │ └── adapter.py │ └── models │ │ ├── __init__.py │ │ ├── base.py │ │ └── schemas.py ├── scheduler │ ├── __init__.py │ ├── tasks.py │ └── tkq.py └── stream │ ├── __init__.py │ └── worker.py └── presentation ├── __init__.py ├── locales ├── de_DE │ └── main.ftl ├── en_GB │ └── main.ftl └── ru_RU │ └── main.ftl └── tgbot ├── __init__.py ├── __main__.py ├── constants.py ├── dialogs ├── __init__.py ├── create_menu │ ├── __init__.py │ ├── dialog.py │ ├── getters.py │ └── handlers.py ├── delete_menu │ ├── __init__.py │ ├── dialog.py │ ├── getters.py │ └── handlers.py ├── edit_menu │ ├── __init__.py │ ├── dialog.py │ ├── getters.py │ └── handlers.py ├── extras │ ├── __init__.py │ ├── calendar.py │ └── i18n_format.py └── main_menu │ ├── __init__.py │ ├── dialog.py │ ├── getters.py │ └── handler.py ├── handlers ├── __init__.py ├── client.py └── errors.py ├── keyboards ├── __init__.py └── inline.py ├── middlewares ├── __init__.py ├── database.py └── i18n.py └── states ├── __init__.py └── user.py /.github/workflows/CI.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: [ "3.11" ] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Upgrade Pip 23 | run: | 24 | pip3 install --no-cache-dir --upgrade pip 25 | pip3 install --no-cache-dir setuptools wheel 26 | - name: Install Poetry 27 | run: | 28 | curl -sSL https://install.python-poetry.org | python3 - 29 | - name: Install Dependencies 30 | run: | 31 | poetry install --only dev --no-root --no-interaction --no-ansi 32 | - name: Run Ruff 33 | run: | 34 | poetry run ruff . 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Ignore dynaconf secret files 163 | configs/.secrets.toml 164 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mark 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # --- Help Menu --- 2 | .PHONY: help 3 | help: 4 | @echo Usage: make [COMMAND] 5 | @echo ㅤ 6 | @echo Available commands: 7 | @echo ruff Run ruff check 8 | @echo lint Reformat code 9 | @echo generate Generate alembic migrations 10 | @echo migrate Migrate with alembic 11 | @echo worker Run taskiq worker script 12 | @echo scheduler RUN taskiq scheduler script 13 | 14 | # --- Linters & Checkers --- 15 | .PHONY: ruff 16 | ruff: 17 | poetry run ruff . --fix 18 | 19 | .PHONY: lint 20 | lint: ruff 21 | 22 | # --- Alembic Utils --- 23 | .PHONY: generate 24 | generate: 25 | poetry run alembic revision --autogenerate 26 | 27 | .PHONY: migrate 28 | migrate: 29 | poetry run alembic upgrade head 30 | 31 | # --- Taskiq Scripts --- 32 | .PHONY: worker 33 | worker: 34 | poetry run taskiq worker src.infrastructure.scheduler.tkq:broker --fs-discover --reload --max-async-tasks -1 35 | 36 | .PHONY: scheduler 37 | scheduler: 38 | poetry run taskiq scheduler src.infrastructure.scheduler.tkq:scheduler --fs-discover 39 | 40 | # Написать программу, имитирующая работу калькулятора, который имеет арифметическую и функциональную часть 41 | # a - вещественное. вводится знак операции -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | > **controller** — probably the best bot for a reminder of the end of the subscription.
15 |
16 | ## 🚀 Stack
17 |
18 | ### Technologies
19 |
20 | - [Python](https://www.python.org/) – programming language
21 | - [Redis](https://redis.io/) – persistent storage
22 | - [PostgreSQL](https://www.postgresql.org/) – best relational database
23 | - [NATS JetStream](https://nats.io/) – communications system for digital systems
24 | - [Docker](https://www.docker.com/) – containerization platform
25 |
26 | ### Frameworks & Libraries
27 |
28 | - [aiogram](https://github.com/aiogram/aiogram) – async framework for Telegram Bot API
29 | - [aiogram-dialog](https://github.com/Tishka17/aiogram_dialog) – developing interactive messages
30 | - [asyncpg](https://github.com/MagicStack/asyncpg) – fast client for PostgreSQL Database
31 | - [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) – SQL toolkit & ORM
32 | - [alembic](https://github.com/sqlalchemy/alembic) – migration tool
33 | - [nats-py](https://github.com/nats-io/nats.py) - Python client for NATS
34 | - [taskiq](https://github.com/taskiq-python/taskiq) – distributed task queue
35 | - [dynaconf](https://github.com/dynaconf/dynaconf) – configuration management
36 | - [structlog](https://github.com/Delgan/loguru) – structured logging
37 |
38 | ### Auxiliary Libraries
39 |
40 | - [zstd](https://github.com/facebook/zstd) – compression technology
41 | - [ormsgpack](https://github.com/aviramha/ormsgpack) – msgpack serialization
42 | - [markupsafe](https://github.com/pallets/markupsafe) – safely add untrusted strings to HTML
43 | - [fluent.runtime](https://github.com/projectfluent/python-fluent) – localization / internationalization
44 |
45 | ## ⭐ Application Schema
46 | [](https://github.com/Markushik/controller-new/)
47 |
48 | ## 🐘 Database Schema
49 |
50 | [](https://drawsql.app/teams/marqezs-team/diagrams/controller-new/)
51 |
52 | ## 🪛 Installation
53 |
54 | ### 🐳 Docker
55 |
56 | **1. Clone the repository:**
57 |
58 | ```
59 | git clone https://github.com/Markushik/controller-new.git
60 | ```
61 |
62 | **2. Create file `.secrets.toml` in folder `configs` and fill data**
63 |
64 | **3. Run the command:**
65 |
66 | ```
67 | docker-compose up
68 | ```
69 |
70 | ### 💻 Default
71 |
72 | **1. Clone the repository:**
73 |
74 | ```
75 | git clone https://github.com/Markushik/controller-new.git
76 | ```
77 |
78 | **2. Create file `.secrets.toml` in folder `configs` and fill data**
79 |
80 | **3. Bring up PostgreSQL, Redis and NATS**
81 |
82 | **4. First run the `taskiq` scripts:**
83 |
84 | ```
85 | taskiq worker application.infrastructure.scheduler.tkq:broker --fs-discover --reload --max-async-tasks -1
86 | ```
87 |
88 | ```
89 | taskiq scheduler application.infrastructure.scheduler.tkq:scheduler --fs-discover
90 | ```
91 |
92 | **5. Second run the `bot`:**
93 |
94 | ```
95 | python -m application.tgbot
96 | ```
97 |
--------------------------------------------------------------------------------
/configs/settings.toml:
--------------------------------------------------------------------------------
1 | [development]
2 |
3 | [development.redis]
4 | REDIS_HOST = '127.0.0.1' # redis
5 | REDIS_PORT = 6379
6 | REDIS_DATABASE = 7
7 |
8 | [development.postgres]
9 | POSTGRES_HOST = '127.0.0.1' # postgres
10 | POSTGRES_PORT = 5432
11 | POSTGRES_USERNAME = 'postgres'
12 | POSTGRES_PASSWORD = 'postgres'
13 | POSTGRES_DATABASE = 'postgres'
14 |
15 | [development.nats]
16 | NATS_HOST = '127.0.0.1' # nats
17 | NATS_PORT = 4222
18 |
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11.4-slim-buster AS builder
2 |
3 | ENV \
4 | PYTHONDONTWRITEBYTECODE=1 \
5 | PYTHONUNBUFFERED=1 \
6 | PIP_NO_CACHE_DIR=on \
7 | PIP_ROOT_USER_ACTION=ignore
8 |
9 | ENV \
10 | POETRY_NO_INTERACTION=1 \
11 | POETRY_NO_ANSI=1
12 |
13 | WORKDIR .
14 |
15 | COPY poetry.lock pyproject.toml ./
16 |
17 | RUN pip3 install --upgrade pip \
18 | && pip3 install setuptools wheel \
19 | && pip3 install poetry
20 | RUN poetry install --only main --no-root
21 |
22 | WORKDIR ./application/
23 |
24 | COPY application ./application/
25 | COPY configs ./configs/
26 | COPY alembic.ini /alembic.ini
27 |
28 | ENTRYPOINT ["/usr/bin/env", "poetry", "run", "python3", "-m", "application.tgbot"]
29 |
--------------------------------------------------------------------------------
/docker/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | redis:
3 | container_name: 'redis'
4 | image: 'redis:7.0.12-alpine'
5 | ports:
6 | - '6379:6379'
7 | restart: 'unless-stopped'
8 | volumes:
9 | - '~/${VOLUMES_DIR}/redis-assets:/assets'
10 | postgres:
11 | container_name: 'postgres'
12 | image: 'postgres:15.3-alpine'
13 | ports:
14 | - '5432:5432'
15 | restart: 'unless-stopped'
16 | environment:
17 | POSTGRES_USER: postgres
18 | POSTGRES_PASSWORD: postgres
19 | volumes:
20 | - '~/${VOLUMES_DIR}/pg-assets:/var/lib/postgresql/assets'
21 | nats:
22 | container_name: 'nats'
23 | image: 'nats:2.9.19-alpine'
24 | restart: 'unless-stopped'
25 | ports:
26 | - '4222:4222'
27 | - '6222:6222'
28 | - '8222:8222'
29 | command: 'nats-server -js -sd /nats-assets/assets'
30 | application:
31 | container_name: 'application'
32 | build:
33 | context: .
34 | dockerfile: Dockerfile
35 | stop_signal: SIGTERM
36 | restart: 'unless-stopped'
37 | depends_on:
38 | - nats
39 | - redis
40 | - postgres
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "controller-new"
3 | version = "0.1.0"
4 | description = ""
5 | authors = ["Mark <74201924+Markushik@users.noreply.github.com>"]
6 | readme = "README.md"
7 | packages = [{include = "controller_new"}]
8 |
9 | [tool.poetry.dependencies]
10 | python = "^3.11"
11 | sqlalchemy = "^2.0.18"
12 | nats-py = "^2.3.1"
13 | fluent-runtime = "^0.4.0"
14 | dynaconf = { extras = ["redis"], version = "^3.1.12" }
15 | ormsgpack = "^1.2.6"
16 | asyncpg = "^0.28.0"
17 | alembic = "^1.11.1"
18 | pathvalidate = "^3.0.0"
19 | markupsafe = "^2.1.3"
20 | taskiq-nats = "^0.3.0"
21 | uuid6 = "^2023.5.2"
22 | adaptix = "^3.0.0a4"
23 | orjson = "^3.9.5"
24 | aiogram-dialog = "^2.0.0"
25 | zstd = "^1.5.5.1"
26 | structlog = "^23.1.0"
27 | colorama = "^0.4.6"
28 | loguru = "^0.7.2"
29 | taskiq = {extras = ["reload"], version = "^0.9.0"}
30 |
31 | [tool.poetry.group.dev.dependencies]
32 | ruff = "^0.0.280"
33 |
34 | [tool.ruff]
35 | line-length = 120
36 |
37 | [build-system]
38 | requires = ["poetry-core"]
39 | build-backend = "poetry.core.masonry.api"
40 |
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/__init__.py
--------------------------------------------------------------------------------
/src/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/core/__init__.py
--------------------------------------------------------------------------------
/src/core/config/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/core/config/__init__.py
--------------------------------------------------------------------------------
/src/core/config/parser.py:
--------------------------------------------------------------------------------
1 | from dynaconf import Dynaconf
2 |
3 | from .validator import validators
4 |
5 | settings = Dynaconf(
6 | settings_files=[
7 | 'configs//settings.toml',
8 | 'configs//.secrets.toml'
9 | ],
10 | validators=validators,
11 | environments=True,
12 | )
13 |
--------------------------------------------------------------------------------
/src/core/config/validator.py:
--------------------------------------------------------------------------------
1 | from aiogram.utils.token import validate_token
2 | from dynaconf import Validator
3 |
4 | validators = [
5 | Validator(
6 | names='API_TOKEN', condition=validate_token, must_exist=True
7 | ),
8 | Validator(
9 | names='redis.REDIS_HOST', is_type_of=int, must_exist=True
10 | ),
11 | Validator(
12 | names='redis.REDIS_PORT', is_type_of=int, must_exist=True
13 | ),
14 | Validator(
15 | names='redis.REDIS_DATABASE', is_type_of=int
16 | ),
17 | Validator(
18 | names='postgres.POSTGRES_HOST', is_type_of=str, must_exist=True
19 | ),
20 | Validator(
21 | names='postgres.POSTGRES_PORT', is_type_of=int, must_exist=True
22 | ),
23 | Validator(
24 | names='postgres.POSTGRES_USERNAME', is_type_of=str, must_exist=True
25 | ),
26 | Validator(
27 | names='postgres.POSTGRES_PASSWORD', is_type_of=str, must_exist=True
28 | ),
29 | Validator(
30 | names='postgres.POSTGRES_DATABASE', is_type_of=str, must_exist=True
31 | ),
32 | Validator(
33 | names='nats.NATS_HOST', is_type_of=str, must_exist=True
34 | ),
35 | Validator(
36 | names='nats.NATS_PORT', is_type_of=int, must_exist=True
37 | ),
38 | ]
39 |
--------------------------------------------------------------------------------
/src/core/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/core/utils/__init__.py
--------------------------------------------------------------------------------
/src/core/utils/builders.py:
--------------------------------------------------------------------------------
1 | from yarl import URL
2 |
3 | from ..config.parser import settings
4 |
5 |
6 | def create_postgres_url() -> URL:
7 | return URL.build(
8 | scheme='postgresql+asyncpg',
9 | host=settings['postgres.POSTGRES_HOST'],
10 | port=settings['postgres.POSTGRES_PORT'],
11 | user=settings['postgres.POSTGRES_USERNAME'],
12 | password=settings['postgres.POSTGRES_PASSWORD'],
13 | path=f"/{settings['postgres.POSTGRES_DATABASE']}",
14 | )
15 |
16 |
17 | def create_redis_url() -> URL:
18 | return URL.build(
19 | scheme='redis',
20 | host=settings['redis.REDIS_HOST'],
21 | port=settings['redis.REDIS_PORT'],
22 | path=f"/{settings['redis.REDIS_DATABASE']}",
23 | )
24 |
25 |
26 | def create_nats_url() -> URL:
27 | return URL.build(
28 | scheme='nats',
29 | host=settings['nats.NATS_HOST'],
30 | port=settings['nats.NATS_PORT']
31 | )
32 |
--------------------------------------------------------------------------------
/src/core/utils/logging.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import logging
3 |
4 | from loguru import logger
5 |
6 |
7 | class InterceptHandler(logging.Handler):
8 | def emit(self, record: logging.LogRecord) -> None:
9 | # Get corresponding Loguru level if it exists.
10 | level: str | int
11 | try:
12 | level = logger.level(record.levelname).name
13 | except ValueError:
14 | level = record.levelno
15 |
16 | # Find caller from where originated the logged message.
17 | frame, depth = inspect.currentframe(), 0
18 | while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
19 | frame = frame.f_back
20 | depth += 1
21 |
22 | logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
23 |
--------------------------------------------------------------------------------
/src/infrastructure/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/__init__.py
--------------------------------------------------------------------------------
/src/infrastructure/database/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/database/__init__.py
--------------------------------------------------------------------------------
/src/infrastructure/database/adapter/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/database/adapter/__init__.py
--------------------------------------------------------------------------------
/src/infrastructure/database/adapter/adapter.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Sequence, Any
3 |
4 | from sqlalchemy import delete, insert, select, CursorResult
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 |
7 | from ..interfaces.adapter import AbstractDbAdapter
8 | from ..models.schemas import User, Service
9 |
10 |
11 | class DbAdapter(AbstractDbAdapter):
12 | def __init__(self, session: AsyncSession):
13 | self.session = session
14 |
15 | async def commit(self) -> None:
16 | return await self.session.commit()
17 |
18 | async def add_user(
19 | self, user_id: int, user_name: str, chat_id: int
20 | ) -> CursorResult[Any]:
21 | return await self.session.execute(
22 | insert(User).values(
23 | user_id=user_id, user_name=user_name, chat_id=chat_id
24 | )
25 | )
26 |
27 | async def create_subscription(
28 | self, title: str, months: int, reminder: datetime, service_fk: int
29 | ) -> CursorResult[Any]:
30 | return await self.session.execute(
31 | insert(Service).values(
32 | title=title, months=months, reminder=reminder, service_fk=service_fk,
33 | )
34 | )
35 |
36 | async def delete_subscription(self, service_id: int) -> CursorResult[Any]:
37 | return await self.session.execute(
38 | delete(Service).where(Service.service_id == service_id)
39 | )
40 |
41 | async def get_user(self, user_id: int) -> None:
42 | return await self.session.get(User, user_id)
43 |
44 | async def get_service(self, service_id: int) -> None:
45 | return await self.session.scalar(
46 | select(Service).where(Service.service_id == service_id)
47 | )
48 |
49 | async def get_services(self, user_id: int) -> Sequence[Service]:
50 | return (
51 | await self.session.scalars(
52 | select(Service).where(Service.service_fk == user_id)
53 | )
54 | ).all()
55 |
56 | async def get_quantity_subs(self, user_id: int) -> None:
57 | return await self.session.scalar(
58 | select(User.count_subs).where(User.user_id == user_id)
59 | )
60 |
61 | async def get_language(self, user_id: int) -> None:
62 | return await self.session.scalar(
63 | select(User.language).where(User.user_id == user_id)
64 | )
65 |
66 | async def update_language(self, user_id: int, language: str) -> User:
67 | return await self.session.merge(
68 | User(user_id=user_id, language=language)
69 | )
70 |
71 | async def edit_sub_title(
72 | self, service_id: int, title: str
73 | ) -> Service:
74 | return await self.session.merge(
75 | Service(service_id=service_id, title=title)
76 | )
77 |
78 | async def edit_sub_months(
79 | self, service_id: int, months: int
80 | ) -> Service:
81 | return await self.session.merge(
82 | Service(service_id=service_id, months=months)
83 | )
84 |
85 | async def edit_sub_date(
86 | self, service_id: int, reminder: datetime
87 | ) -> Service:
88 | return await self.session.merge(
89 | Service(service_id=service_id, reminder=reminder)
90 | )
91 |
92 | async def increment_quantity(self, user_id: int) -> User:
93 | return await self.session.merge(
94 | User(user_id=user_id, count_subs=User.count_subs + 1)
95 | )
96 |
97 | async def decrement_quantity(self, user_id: int) -> User:
98 | return await self.session.merge(
99 | User(user_id=user_id, count_subs=User.count_subs - 1)
100 | )
101 |
--------------------------------------------------------------------------------
/src/infrastructure/database/interfaces/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/database/interfaces/__init__.py
--------------------------------------------------------------------------------
/src/infrastructure/database/interfaces/adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Protocol
2 | from datetime import datetime
3 |
4 |
5 | class AbstractDbAdapter(Protocol):
6 | async def commit(self):
7 | raise NotImplementedError
8 |
9 | async def add_user(
10 | self, user_id: int, user_name: str, chat_id: int
11 | ):
12 | raise NotImplementedError
13 |
14 | async def create_subscription(
15 | self, title: str, months: int, reminder: datetime, service_fk: int
16 | ):
17 | raise NotImplementedError
18 |
19 | async def delete_subscription(self, service_id: int):
20 | raise NotImplementedError
21 |
22 | async def get_user(self, user_id: int):
23 | raise NotImplementedError
24 |
25 | async def get_service(self, service_id: int):
26 | raise NotImplementedError
27 |
28 | async def get_services(self, user_id: int):
29 | raise NotImplementedError
30 |
31 | async def get_quantity_subs(self, user_id: int):
32 | raise NotImplementedError
33 |
34 | async def get_language(self, user_id: int):
35 | raise NotImplementedError
36 |
37 | async def update_language(self, user_id: int, language: str):
38 | raise NotImplementedError
39 |
40 | async def edit_sub_title(self, service_id: int, title: str):
41 | raise NotImplementedError
42 |
43 | async def edit_sub_months(self, service_id: int, months: int):
44 | raise NotImplementedError
45 |
46 | async def edit_sub_date(self, service_id: int, reminder: datetime):
47 | raise NotImplementedError
48 |
49 | async def increment_quantity(self, user_id: int):
50 | raise NotImplementedError
51 |
52 | async def decrement_quantity(self, user_id: int):
53 | raise NotImplementedError
54 |
55 | # @abstractmethod
56 | # async def get_services_by_date(self):
57 | # raise NotImplementedError
58 | #
59 | # @abstractmethod
60 | # async def delete_services_by_ids(self, services_ids: list):
61 | # raise NotImplementedError
62 |
--------------------------------------------------------------------------------
/src/infrastructure/database/models/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/database/models/__init__.py
--------------------------------------------------------------------------------
/src/infrastructure/database/models/base.py:
--------------------------------------------------------------------------------
1 | """
2 | This file creates a base class to define a declarative class
3 | """
4 |
5 | from sqlalchemy import MetaData
6 | from sqlalchemy.orm import DeclarativeBase, registry
7 |
8 | convention = {
9 | 'ix': 'ix_%(column_0_label)s',
10 | 'uq': 'uq_%(table_name)s_%(column_0_N_name)s',
11 | 'ck': 'ck_%(table_name)s_%(constraint_name)s',
12 | 'fk': 'fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s',
13 | 'pk': 'pk_%(table_name)s',
14 | }
15 |
16 | mapper_registry = registry(metadata=MetaData(naming_convention=convention))
17 |
18 |
19 | class BaseModel(DeclarativeBase):
20 | registry = mapper_registry
21 | metadata = mapper_registry.metadata
22 |
--------------------------------------------------------------------------------
/src/infrastructure/database/models/schemas.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from sqlalchemy import (
4 | BigInteger,
5 | DateTime,
6 | ForeignKey,
7 | Integer,
8 | SmallInteger,
9 | String,
10 | )
11 | from sqlalchemy.orm import (
12 | Mapped,
13 | mapped_column,
14 | relationship
15 | )
16 |
17 | from .base import BaseModel
18 |
19 |
20 | class User(BaseModel):
21 | __tablename__ = 'users'
22 |
23 | user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
24 | user_name: Mapped[str] = mapped_column(String(length=32))
25 | chat_id: Mapped[int] = mapped_column(BigInteger)
26 | language: Mapped[str] = mapped_column(String(length=5), default='ru_RU')
27 | count_subs: Mapped[int] = mapped_column(SmallInteger, default=0)
28 |
29 | def __repr__(self) -> str:
30 | return f'User:{self.user_id}:{self.user_name}'
31 |
32 |
33 | class Service(BaseModel):
34 | __tablename__ = 'services'
35 |
36 | service_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
37 | service_fk: Mapped[int] = mapped_column(BigInteger, ForeignKey('users.user_id'))
38 | title: Mapped[str] = mapped_column(String(length=30))
39 | months: Mapped[int] = mapped_column(SmallInteger)
40 | reminder: Mapped[datetime] = mapped_column(DateTime)
41 |
42 | user = relationship(argument='User', lazy='joined', innerjoin=True)
43 |
44 | def __repr__(self) -> str:
45 | return f'Service:{self.service_id}:{self.title}:{self.reminder}'
46 |
47 |
48 | class CommonService(BaseModel):
49 | __tablename__ = 'common_services'
50 |
51 | title: Mapped[str] = mapped_column(String(length=255), primary_key=True)
52 |
53 | def __repr__(self) -> str:
54 | return f'CommonService:{self.title}'
55 |
--------------------------------------------------------------------------------
/src/infrastructure/scheduler/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/scheduler/__init__.py
--------------------------------------------------------------------------------
/src/infrastructure/scheduler/tasks.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import ormsgpack
4 | import uuid6
5 | import zstd
6 | from sqlalchemy import delete, func, select
7 | from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
8 | from sqlalchemy.orm import joinedload
9 | from taskiq import Context, TaskiqDepends
10 |
11 | from ..database.models.schemas import Service, User
12 | from ..scheduler.tkq import broker
13 |
14 |
15 | @broker.task(
16 | task_name='base_polling',
17 | schedule=[
18 | {
19 | "cron": "*/1 * * * *",
20 | "cron_offset": "Europe/Moscow"
21 | },
22 | {
23 | "cron": "0 12 * * *",
24 | "cron_offset": "Europe/Moscow"
25 | },
26 | {
27 | "cron": "0 16 * * *",
28 | "cron_offset": "Europe/Moscow"
29 | },
30 | ],
31 | )
32 | async def base_polling_task(context: Context = TaskiqDepends()) -> None:
33 | nats_connect = context.state.nats
34 | async_engine = context.state.database
35 |
36 | jetstream = nats_connect.jetstream()
37 | async_session_maker: async_sessionmaker = async_sessionmaker(
38 | bind=async_engine,
39 | class_=AsyncSession,
40 | autoflush=True,
41 | expire_on_commit=True, # when you commit, load new object from database
42 | )
43 |
44 | async with async_session_maker() as session:
45 | request = await session.scalars(
46 | select(Service)
47 | .options(
48 | joinedload(Service.user)
49 | )
50 | .where(
51 | func.date(Service.reminder) == datetime.datetime.utcnow().date()
52 | )
53 | )
54 | services = request.all()
55 | identifiers = list()
56 |
57 | for service in services:
58 | await jetstream.publish(
59 | subject='service_notify.message',
60 | payload=zstd.compress(
61 | ormsgpack.packb(
62 | {
63 | 'chat_id': service.user.chat_id,
64 | 'language': service.user.language,
65 | 'service': service.title,
66 | 'months': service.months
67 | }
68 | )
69 | ),
70 | headers={
71 | 'Nats-Msg-Id': uuid6.uuid8().hex, # uuid8, because uniqueness guarantee
72 | },
73 | )
74 | await session.merge(
75 | User(
76 | user_id=service.user.user_id,
77 | count_subs=User.count_subs - 1,
78 | )
79 | )
80 | identifiers.append(service.service_id)
81 |
82 | await session.execute(
83 | delete(Service).where(Service.service_id.in_(identifiers))
84 | )
85 | await session.commit()
86 |
--------------------------------------------------------------------------------
/src/infrastructure/scheduler/tkq.py:
--------------------------------------------------------------------------------
1 | import nats
2 | from loguru import logger
3 | from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
4 | from taskiq import TaskiqEvents, TaskiqScheduler, TaskiqState
5 | from taskiq.schedule_sources import LabelScheduleSource
6 | from taskiq_nats import NatsBroker
7 |
8 | from src.core.utils.builders import create_nats_url, create_postgres_url
9 |
10 | broker = NatsBroker(
11 | servers=[
12 | create_nats_url().human_repr(),
13 | ],
14 | queue='send_service',
15 | )
16 | scheduler = TaskiqScheduler(
17 | broker=broker,
18 | sources=[
19 | LabelScheduleSource(broker=broker),
20 | ],
21 | )
22 |
23 |
24 | @broker.on_event(TaskiqEvents.WORKER_STARTUP)
25 | async def startup(state: TaskiqState) -> None:
26 | logger.info('Taskiq Launching')
27 |
28 | nats_connect = await nats.connect(
29 | servers=[
30 | create_nats_url().human_repr()
31 | ]
32 | )
33 | async_engine: AsyncEngine = create_async_engine(
34 | url=create_postgres_url().human_repr(),
35 | pool_pre_ping=True,
36 | echo=False,
37 | )
38 |
39 | state.nats = nats_connect
40 | state.database = async_engine
41 |
42 |
43 | @broker.on_event(TaskiqEvents.WORKER_SHUTDOWN)
44 | async def shutdown(state: TaskiqState) -> None:
45 | logger.warning('Taskiq Shutdown')
46 |
47 | await state.nats.drain()
48 | await state.database.dispose()
49 |
--------------------------------------------------------------------------------
/src/infrastructure/stream/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/stream/__init__.py
--------------------------------------------------------------------------------
/src/infrastructure/stream/worker.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import ormsgpack
4 | import zstd
5 | from aiogram import Bot
6 | from aiogram.exceptions import TelegramRetryAfter, TelegramForbiddenError
7 | from loguru import logger
8 | from nats.js import JetStreamContext
9 |
10 | from src.presentation.tgbot.keyboards.inline import get_extension_menu
11 |
12 |
13 | async def nats_polling(
14 | bot: Bot, i18n_middleware, jetstream: JetStreamContext
15 | ) -> None:
16 | subscribe = await jetstream.subscribe(
17 | subject='service_notify.message',
18 | stream='service_notify',
19 | durable='get_message',
20 | manual_ack=True,
21 | )
22 |
23 | async for message in subscribe.messages:
24 | try:
25 | data = ormsgpack.unpackb(zstd.decompress(message.data))
26 |
27 | chat_id = data['chat_id']
28 | language = data['language']
29 | service = data['service']
30 | months = data['months']
31 |
32 | l10n = i18n_middleware.l10ns[language]
33 |
34 | await bot.send_message(
35 | chat_id=chat_id,
36 | text=l10n.format_value(
37 | 'notification-message', {
38 | 'service': service
39 | }
40 | ),
41 | reply_markup=get_extension_menu(
42 | text=l10n.format_value('renew'),
43 | service=service,
44 | months=months,
45 | ),
46 | )
47 | await message.ack()
48 |
49 | except TimeoutError:
50 | pass
51 | except TelegramRetryAfter as ex:
52 | logger.warning(f'Limit exceeded, continue in: {ex.retry_after}')
53 | await asyncio.sleep(float(ex.retry_after))
54 | continue
55 | except TelegramForbiddenError:
56 | logger.info('User blocked Bot')
57 | await message.ack()
58 | continue
59 | except BaseException as ex:
60 | logger.error(f'Unexpected error: {ex}')
61 | continue
62 |
--------------------------------------------------------------------------------
/src/presentation/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/__init__.py
--------------------------------------------------------------------------------
/src/presentation/locales/de_DE/main.ftl:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/locales/de_DE/main.ftl
--------------------------------------------------------------------------------
/src/presentation/locales/en_GB/main.ftl:
--------------------------------------------------------------------------------
1 | settings = ⚙️ Settings
2 | support = 🆘 Support
3 | my-subscriptions = 🗂️ My Subscriptions
4 | administrator = 👨💻 Administrator
5 | back = ↩️ Back
6 |
7 | add = Add
8 | delete = Delete
9 | change = Change
10 |
11 | title = Title
12 | months = Months
13 | date = Date
14 | renew = Renew
15 |
16 | select-lang = 🌍 Select the language in which the bot will communicate:
17 |
18 | catalog-add = 🗂️ Subscriptions add catalog:
19 |
20 | { $subs }
21 |
22 | catalog-remove = 🗂️ Subscription removal catalog:
23 |
24 | { $message }
25 |
26 | catalog-edit = 🗂️ Subscription editing catalog:
27 |
28 | { $message }
29 |
30 | conformation = Are you sure you want to delete the subscription?
31 |
32 | faq = ❓ FAQ
33 |
34 | 1. What is this bot for?
35 | — The bot was created to remind the user when his subscription to any service expires.
36 |
37 | 2. What services can be added?
38 | — It doesn't matter where you subscribed, you can add any services.
39 |
40 | 3. How to add a service?
41 | — Go to the My Subscriptions section and click the Add button. Fill in the data strictly following the instructions: first enter the name, next step enter the quantity. months (number), then select on the calendar when remind you to write off. Confirm that the subscription is correct and the subscription will be added.
42 |
43 | add-service-title = What is the name of the service that you subscribed to?
44 |
45 | Example:
Tinkoff Premium
46 | add-service-months = How many months will the subscription last?
47 |
48 | Example: 12 (mon.)
49 |
50 | add-calendar-date = What date to notify about the next write-off?
51 |
52 | check-form = 📩 Check correctness of the entered data:
53 |
54 | Service: { $service }
55 | Duration: { $months } (mon.)
56 | Notify: { $reminder }
57 |
58 | start-menu = Subscriptions Controller — is the best way to control your subscriptions
59 |
60 | 📣 Required add your subscriptions to our service to receive notifications about the next charge
61 |
62 | nothing-delete = 🤷♂️ It seems, there is nothing to delete here...
63 |
64 | nothing-output = 🤷♂️ It seems that we haven't found anything...
65 |
66 | set-for-delete = Select the subscription that you want to delete:
67 |
68 | set-for-edit = Select the subscription that you want to change:
69 |
70 | error-subs-limit = 🚫 Error: Subscription limit reached
71 |
72 | error-len-limit = 🚫 Error: Character limit reached
73 |
74 | error-unsupported-char = 🚫 Error: Invalid characters entered
75 |
76 | error-range-reached = 🚫 Error: Value range reached
77 |
78 | approve-sub-add = ✅ Approved: Data written successfully
79 |
80 | error-sub-add = ❎ Rejected: Data not recorded
81 |
82 | approve-sub-delete = ✅ Approved: Subscription successfully deleted
83 |
84 | reject-sub-delete = ❎ Rejected: Subscription not deleted
85 |
86 | approve-sub-edit = ✅ Approved: Subscription changed successfully
87 |
88 | reject-sub-edit = ❎ Rejected: Subscription not changed
89 |
90 | notification-message = 🔔 Notification
91 | We remind you that your { $service }
subscription will soon end!
92 |
93 | select-parameters = Select the parameter that you want to change:
94 |
95 | edit-form = Select the subscription that you want to change:
96 |
97 | check-title-form = 📩 Check correctness of data changes:
98 |
99 | { $service_old_title } → { $service_new_title }
100 |
101 | check-months-form = 📩 Check correctness of data changes:
102 |
103 | { $service_old_months } (mon.)
→ { $service_new_months } (mon.)
104 |
105 | check-reminder-form = 📩 Check correctness of data changes:
106 |
107 | { $service_old_reminder } → { $service_new_reminder }
--------------------------------------------------------------------------------
/src/presentation/locales/ru_RU/main.ftl:
--------------------------------------------------------------------------------
1 | settings = ⚙️ Настройки
2 | support = 🆘 Поддержка
3 | my-subscriptions = 🗂️ Мои подписки
4 | administrator = 👨💻 Администратор
5 | back = ↩️ Назад
6 |
7 | add = Добавить
8 | delete = Удалить
9 | change = Изменить
10 |
11 | title = Название
12 | months = Месяцы
13 | date = Дату
14 | renew = Продлить
15 |
16 | start-menu = Subscriptions Controller — лучший способ контролировать свои подписки
17 |
18 | 📣 Обязательно добавляйте свои подписки в наш сервис, чтобы получать уведомления о ближайшем списании
19 |
20 | select-lang = 🌍 Выберите язык, на котором будет общаться бот:
21 |
22 | faq = ❓ ЧаВо
23 |
24 | 1. Для чего этот бот?
25 | — Бот создан, с целью напомнить пользователю, когда истечет его подписка в каком-либо сервисе.
26 |
27 | 2. Какие сервисы можно добавлять?
28 | — Неважно где вы оформили подписку, можно добавлять любые сервисы.
29 |
30 | 3. Как добавить сервис?
31 | — Перейдите в раздел Мои подписки и нажмите кнопку Добавить. Заполняйте данные, строго следуя инструкциям: сначала введите название, следующим шагом введите кол-во.бли месяцев (число), затем выберите на календаре, когда напомнить о списании. Подтвердите правильность, и подписка будет добавлена.
32 |
33 |
34 | catalog-add = 🗂️ Каталог добавленных подписок:
35 |
36 | { $subs }
37 |
38 | catalog-remove = 🗂️ Каталог удаления подписок:
39 |
40 | { $message }
41 |
42 | catalog-edit = 🗂️ Каталог изменения подписок:
43 |
44 | { $message }
45 |
46 | conformation = Вы действительно хотите удалить подписку?
47 |
48 | add-service-title = Как называется сервис на который Вы подписались?
49 |
50 | Пример: Tinkoff Premium
51 |
52 | add-service-months = Сколько месяцев будет действовать подписка?
53 |
54 | Пример: 12 (мес.)
55 |
56 | add-calendar-date = В какую дату оповестить о ближайшем списании?
57 |
58 | check-form = 📩 Проверьте правильность введённых данных:
59 |
60 | Сервис: { $service }
61 | Длительность: { $months } (мес.)
62 | Оповестить: { $reminder }
63 |
64 | nothing-delete = 🤷♂️ Кажется, здесь нечего удалять...
65 |
66 | nothing-output = 🤷♂️ Кажется, мы ничего не нашли...
67 |
68 | set-for-delete = Выберите подписку, которую хотите удалить:
69 |
70 | set-for-edit = Выберите подписку, которую хотите изменить:
71 |
72 | error-subs-limit = 🚫 Ошибка: Достигнут лимит подписок
73 |
74 | error-len-limit = 🚫 Ошибка: Достигнут лимит символов
75 |
76 | error-unsupported-char = 🚫 Ошибка: Введены недопустимые символы
77 |
78 | error-range-reached = 🚫 Ошибка: Достигнут диапазон значений
79 |
80 | approve-sub-add = ✅ Одобрено: Данные успешно записаны
81 |
82 | error-sub-add = ❎ Отклонено: Данные не записаны
83 |
84 | approve-sub-delete = ✅ Одобрено: Подписка успешно удалена
85 |
86 | reject-sub-delete = ❎ Отклонено: Подписка не удалена
87 |
88 | approve-sub-edit = ✅ Одобрено: Подписка успешно изменена
89 |
90 | reject-sub-edit = ❎ Отклонено: Подписка не изменена
91 |
92 | notification-message = 🔔 Уведомление
93 | Напоминаем Вам, что ваша подписка { $service }
скоро закончится!
94 |
95 | select-parameters = Выберите параметр, который хотите изменить:
96 |
97 | edit-form = Выберите подписку, которую хотите изменить:
98 |
99 | check-title-form = 📩 Проверьте правильность изменения данных:
100 |
101 | { $service_old_title } → { $service_new_title }
102 |
103 | check-months-form = 📩 Проверьте правильность изменения данных:
104 |
105 | { $service_old_months } (мес.)
→ { $service_new_months } (мес.)
106 |
107 | check-reminder-form = 📩 Проверьте правильность изменения данных:
108 |
109 | { $service_old_reminder } → { $service_new_reminder }
--------------------------------------------------------------------------------
/src/presentation/tgbot/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/__main__.py:
--------------------------------------------------------------------------------
1 | """
2 | The main file responsible for launching the bot
3 | """
4 |
5 | import asyncio
6 | import logging
7 |
8 | import nats
9 | from aiogram import Bot, Dispatcher
10 | from aiogram.enums import ParseMode
11 | from aiogram.filters import ExceptionTypeFilter
12 | from aiogram.fsm.storage.redis import DefaultKeyBuilder, RedisStorage
13 | from aiogram_dialog import setup_dialogs
14 | from aiogram_dialog.api.exceptions import UnknownIntent, UnknownState
15 | from loguru import logger
16 | from nats.aio.client import Client
17 | from nats.js import JetStreamContext
18 | from sqlalchemy.ext.asyncio import (
19 | async_sessionmaker,
20 | AsyncEngine,
21 | AsyncSession,
22 | create_async_engine,
23 | )
24 |
25 | from src.core.config.parser import settings
26 | from src.core.utils.builders import create_postgres_url, create_nats_url, create_redis_url
27 | from src.core.utils.logging import InterceptHandler
28 | from src.infrastructure.stream.worker import nats_polling
29 | from src.presentation.tgbot.dialogs.create_menu.dialog import create_menu
30 | from src.presentation.tgbot.dialogs.delete_menu.dialog import delete_menu
31 | from src.presentation.tgbot.dialogs.edit_menu.dialog import edit_menu
32 | from src.presentation.tgbot.dialogs.main_menu.dialog import main_menu
33 | from src.presentation.tgbot.handlers import client, errors
34 | from src.presentation.tgbot.middlewares.database import DbSessionMiddleware
35 | from src.presentation.tgbot.middlewares.i18n import I18nMiddleware, make_i18n_middleware
36 |
37 |
38 | async def _main() -> None:
39 | """
40 | The main function responsible for launching the bot
41 | :return:
42 | """
43 | logging.basicConfig(
44 | handlers=[
45 | InterceptHandler()
46 | ],
47 | level='DEBUG',
48 | force=True
49 | )
50 | logger.add(
51 | sink='../../../debug.log',
52 | format='{time} | {level} | {message}',
53 | level='DEBUG',
54 | enqueue=True,
55 | colorize=True,
56 | encoding='utf-8',
57 | rotation='10 MB',
58 | compression='zip'
59 | )
60 |
61 | async_engine: AsyncEngine = create_async_engine(
62 | url=create_postgres_url().human_repr(),
63 | pool_pre_ping=True,
64 | echo=False,
65 | )
66 | async_session_maker: async_sessionmaker = async_sessionmaker(
67 | bind=async_engine,
68 | class_=AsyncSession,
69 | autoflush=True,
70 | expire_on_commit=True,
71 | )
72 |
73 | nats_client: Client = await nats.connect(
74 | servers=[
75 | create_nats_url().human_repr()
76 | ],
77 | )
78 | jetstream: JetStreamContext = nats_client.jetstream()
79 |
80 | storage: RedisStorage = RedisStorage.from_url(
81 | url=create_redis_url().human_repr(),
82 | key_builder=DefaultKeyBuilder(
83 | with_destiny=True,
84 | with_bot_id=True
85 | ),
86 | )
87 | bot: Bot = Bot(
88 | token=settings['API_TOKEN'], parse_mode=ParseMode.HTML
89 | )
90 | disp: Dispatcher = Dispatcher(
91 | storage=storage, events_isolation=storage.create_isolation()
92 | )
93 |
94 | i18n_middleware: I18nMiddleware = make_i18n_middleware()
95 |
96 | disp.message.middleware(i18n_middleware)
97 | disp.callback_query.middleware(i18n_middleware)
98 | disp.update.outer_middleware(
99 | DbSessionMiddleware(session_maker=async_session_maker)
100 | )
101 |
102 | disp.include_router(client.router)
103 | disp.include_routers(
104 | main_menu,
105 | create_menu,
106 | delete_menu,
107 | edit_menu,
108 | )
109 | disp.errors.register(errors.on_unknown_intent, ExceptionTypeFilter(UnknownIntent))
110 | disp.errors.register(errors.on_unknown_state, ExceptionTypeFilter(UnknownState))
111 |
112 | setup_dialogs(disp)
113 |
114 | logger.info('Bot Launching')
115 |
116 | try:
117 | await jetstream.add_stream(
118 | name='service_notify',
119 | subjects=['service_notify.*'],
120 | retention='interest',
121 | storage='file'
122 | )
123 | await bot.delete_webhook(drop_pending_updates=True)
124 | await asyncio.gather(
125 | nats_polling(
126 | bot=bot,
127 | i18n_middleware=i18n_middleware,
128 | jetstream=jetstream
129 | ),
130 | disp.start_polling(bot),
131 | )
132 | finally:
133 | await storage.close()
134 | await bot.session.close()
135 | await async_engine.dispose()
136 | await nats_client.drain()
137 | await logger.complete()
138 |
139 |
140 | if __name__ == '__main__':
141 | try:
142 | asyncio.run(_main())
143 | except (SystemExit, KeyboardInterrupt):
144 | logger.warning('Bot Shutdown')
145 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/constants.py:
--------------------------------------------------------------------------------
1 | from typing import Final
2 |
3 | LANGUAGES: Final[list] = [' 🇷🇺 Русский', ' 🇬🇧 English']
4 |
5 | LOCALES: Final[list] = ['ru_RU', 'en_GB']
6 | DEFAULT_LOCALE: Final[str] = 'ru_RU'
7 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/create_menu/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/create_menu/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/create_menu/dialog.py:
--------------------------------------------------------------------------------
1 | from aiogram.enums import ContentType
2 | from aiogram_dialog import Dialog, Window
3 | from aiogram_dialog.widgets.input import MessageInput
4 | from aiogram_dialog.widgets.kbd import (
5 | Button,
6 | Group,
7 | Row
8 | )
9 | from aiogram_dialog.widgets.text import Const
10 |
11 | from src.presentation.tgbot.states.user import CreateMenu
12 | from .handlers import (
13 | add_title_handler,
14 | add_months_handler,
15 | on_click_confirm_data,
16 | on_click_reject_data,
17 | on_click_select_date
18 | )
19 | from ..extras.calendar import CustomCalendar
20 | from ..extras.i18n_format import I18NFormat
21 | from ..main_menu.getters import get_input_service_data
22 | from ..main_menu.handler import on_click_get_subs_menu
23 |
24 | create_menu = Dialog(
25 | Window(
26 | I18NFormat('add-service-title'),
27 | MessageInput(func=add_title_handler, content_types=ContentType.TEXT),
28 | Button(
29 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu
30 | ),
31 | state=CreateMenu.TITLE,
32 | ),
33 | Window(
34 | I18NFormat('add-service-months'),
35 | MessageInput(func=add_months_handler, content_types=ContentType.TEXT),
36 | Button(
37 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu
38 | ),
39 | state=CreateMenu.MONTHS,
40 | ),
41 | Window(
42 | I18NFormat('add-calendar-date'),
43 | Group(
44 | CustomCalendar(
45 | id='select_date_id', on_click=on_click_select_date
46 | ),
47 | Button(
48 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu,
49 | ),
50 | ),
51 | state=CreateMenu.REMINDER,
52 | ),
53 | Window(
54 | I18NFormat('check-form'),
55 | Row(
56 | Button(
57 | Const('✅'), id='confirm_add_id', on_click=on_click_confirm_data,
58 | ),
59 | Button(
60 | Const('❎'), id='reject_add_id', on_click=on_click_reject_data
61 | ),
62 | ),
63 | state=CreateMenu.CHECK_ADD,
64 | ),
65 | getter=get_input_service_data,
66 | )
67 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/create_menu/getters.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from aiogram_dialog import DialogManager
4 |
5 |
6 | async def get_subs_for_output(dialog_manager: DialogManager, **kwargs) -> dict[str, str]:
7 | l10n = dialog_manager.middleware_data['l10n']
8 | session = dialog_manager.middleware_data['session']
9 |
10 | services = await session.get_services(user_id=dialog_manager.event.from_user.id)
11 |
12 | if not services:
13 | return {'subs': l10n.format_value('nothing-output')}
14 |
15 | subs = [
16 | f'{count}. {item.title} — {datetime.date(item.reminder)}\n'
17 | for count, item in enumerate(iterable=services, start=1)
18 | ]
19 | return {'subs': ''.join(subs)}
20 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/create_menu/handlers.py:
--------------------------------------------------------------------------------
1 | from datetime import date, datetime
2 |
3 | import markupsafe
4 | from aiogram.types import CallbackQuery, Message
5 | from aiogram_dialog import DialogManager, DialogProtocol, StartMode
6 | from aiogram_dialog.widgets.kbd import Button
7 |
8 | from src.presentation.tgbot.states.user import CreateMenu, MainMenu
9 |
10 |
11 | async def add_title_handler(
12 | message: Message, protocol: DialogProtocol, dialog_manager: DialogManager
13 | ) -> Message:
14 | l10n = dialog_manager.middleware_data['l10n']
15 |
16 | if len(message.text) > 30:
17 | return await message.answer(l10n.format_value('error-len-limit'))
18 |
19 | dialog_manager.dialog_data['service'] = markupsafe.escape(message.text)
20 | await dialog_manager.switch_to(state=CreateMenu.MONTHS)
21 |
22 |
23 | async def add_months_handler(
24 | message: Message, protocol: DialogProtocol, dialog_manager: DialogManager
25 | ) -> Message:
26 | l10n = dialog_manager.middleware_data['l10n']
27 |
28 | try:
29 | value = int(message.text)
30 | except ValueError:
31 | return await message.answer(
32 | l10n.format_value('error-unsupported-char')
33 | )
34 |
35 | if value not in range(1, 12 + 1):
36 | return await message.answer(l10n.format_value('error-range-reached'))
37 |
38 | dialog_manager.dialog_data['months'] = int(message.text)
39 | await dialog_manager.switch_to(state=CreateMenu.REMINDER)
40 |
41 |
42 | async def on_click_select_date(
43 | callback: CallbackQuery,
44 | button: Button,
45 | dialog_manager: DialogManager,
46 | selected_date: date,
47 | ) -> None:
48 | dialog_manager.dialog_data['reminder'] = selected_date.isoformat()
49 | await dialog_manager.switch_to(state=CreateMenu.CHECK_ADD)
50 |
51 |
52 | async def on_click_confirm_data(
53 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager
54 | ) -> None:
55 | l10n = dialog_manager.middleware_data['l10n']
56 | session = dialog_manager.middleware_data['session']
57 |
58 | await session.create_subscription(
59 | title=dialog_manager.dialog_data['service'],
60 | months=dialog_manager.dialog_data['months'],
61 | reminder=datetime.fromisoformat(dialog_manager.dialog_data['reminder']),
62 | service_fk=callback.from_user.id,
63 | )
64 | await session.increment_quantity(user_id=dialog_manager.event.from_user.id)
65 | await session.commit()
66 |
67 | await callback.message.edit_text(l10n.format_value('approve-sub-add'))
68 | await dialog_manager.done()
69 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK)
70 |
71 |
72 | async def on_click_reject_data(
73 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager
74 | ) -> None:
75 | l10n = dialog_manager.middleware_data['l10n']
76 |
77 | await callback.message.edit_text(l10n.format_value('error-sub-add'))
78 | await dialog_manager.done()
79 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK)
80 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/delete_menu/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/delete_menu/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/delete_menu/dialog.py:
--------------------------------------------------------------------------------
1 | from operator import itemgetter
2 |
3 | from aiogram_dialog import Dialog, Window
4 | from aiogram_dialog.widgets.kbd import (
5 | Button,
6 | Column,
7 | Group,
8 | Row,
9 | Select
10 | )
11 | from aiogram_dialog.widgets.text import Const, Format
12 |
13 | from src.presentation.tgbot.states.user import DeleteMenu
14 | from .getters import get_subs_for_delete
15 | from .handlers import on_click_sub_not_delete, on_click_sub_selected
16 | from ..extras.i18n_format import I18NFormat
17 | from ..main_menu.handler import on_click_get_subs_menu, on_click_sub_delete
18 |
19 | delete_menu = Dialog(
20 | Window(
21 | I18NFormat('catalog-remove'),
22 | Group(
23 | Column(
24 | Select(
25 | Format('{item[1]} — {item[2]}'),
26 | id='delete_id',
27 | item_id_getter=itemgetter(0),
28 | items='subs',
29 | on_click=on_click_sub_selected,
30 | type_factory=int,
31 | ),
32 | ),
33 | Button(
34 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu,
35 | ),
36 | ),
37 | getter=get_subs_for_delete,
38 | state=DeleteMenu.DELETE,
39 | ),
40 | Window(
41 | I18NFormat('conformation'),
42 | Row(
43 | Button(
44 | Const('✅'), id='confirm_delete_id', on_click=on_click_sub_delete,
45 | ),
46 | Button(
47 | Const('❎'), id='reject_delete_id', on_click=on_click_sub_not_delete,
48 | ),
49 | ),
50 | state=DeleteMenu.CHECK_DELETE,
51 | ),
52 | )
53 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/delete_menu/getters.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Any
3 |
4 | from aiogram_dialog import DialogManager
5 |
6 |
7 | async def get_subs_for_delete(dialog_manager: DialogManager, **kwargs) -> dict[str, list[Any] | Any]:
8 | l10n = dialog_manager.middleware_data['l10n']
9 | session = dialog_manager.middleware_data['session']
10 |
11 | services = await session.get_services(
12 | user_id=dialog_manager.event.from_user.id
13 | )
14 |
15 | if not services:
16 | return {'message': l10n.format_value('nothing-output'), 'subs': []}
17 |
18 | subs = [
19 | (item.service_id, item.title, datetime.date(item.reminder).isoformat())
20 | for item in services
21 | ]
22 | return {'message': l10n.format_value('set-for-delete'), 'subs': subs}
23 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/delete_menu/handlers.py:
--------------------------------------------------------------------------------
1 | from aiogram.types import CallbackQuery
2 | from aiogram_dialog import DialogManager, StartMode
3 | from aiogram_dialog.widgets.kbd import Button
4 |
5 | from src.presentation.tgbot.states.user import DeleteMenu
6 |
7 |
8 | async def on_click_get_delete_menu(
9 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager,
10 | ) -> None:
11 | await dialog_manager.start(state=DeleteMenu.DELETE, mode=StartMode.RESET_STACK)
12 |
13 |
14 | async def on_click_sub_not_delete(
15 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager
16 | ) -> None:
17 | l10n = dialog_manager.middleware_data['l10n']
18 |
19 | await callback.message.edit_text(l10n.format_value('reject-sub-delete'))
20 | await dialog_manager.done()
21 | await dialog_manager.start(state=DeleteMenu.DELETE, mode=StartMode.RESET_STACK)
22 |
23 |
24 | async def on_click_sub_selected(
25 | callback: CallbackQuery,
26 | button: Button,
27 | dialog_manager: DialogManager,
28 | item_id: int,
29 | ) -> None:
30 | await dialog_manager.start(state=DeleteMenu.CHECK_DELETE, mode=StartMode.RESET_STACK)
31 | dialog_manager.dialog_data['service_id'] = item_id
32 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/edit_menu/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/edit_menu/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/edit_menu/dialog.py:
--------------------------------------------------------------------------------
1 | from operator import itemgetter
2 |
3 | from aiogram.enums import ContentType
4 | from aiogram_dialog import Dialog, Window
5 | from aiogram_dialog.widgets.input import MessageInput
6 | from aiogram_dialog.widgets.kbd import (
7 | Button,
8 | Column,
9 | Group,
10 | Row,
11 | Select,
12 | SwitchTo,
13 | )
14 | from aiogram_dialog.widgets.text import Const, Format
15 |
16 | from src.presentation.tgbot.states.user import EditMenu
17 | from ..edit_menu.getters import (
18 | get_service_months_data,
19 | get_service_reminder_data,
20 | get_service_title_data,
21 | get_subs_for_edit,
22 | )
23 | from ..edit_menu.handlers import (
24 | approve_edit_menu,
25 | edit_months_handler,
26 | edit_reminder_handler,
27 | edit_title_handler,
28 | on_click_edit_date,
29 | on_click_edit_months,
30 | on_click_edit_title,
31 | on_click_set_parameters,
32 | reject_edit_menu,
33 | )
34 | from ..extras.calendar import CustomCalendar
35 | from ..extras.i18n_format import I18NFormat
36 | from ..main_menu.handler import on_click_get_subs_menu
37 |
38 | edit_menu = Dialog(
39 | Window(
40 | I18NFormat('catalog-edit'),
41 | Column(
42 | Select(
43 | Format('{item[1]} — {item[2]}'),
44 | id='edit_id',
45 | item_id_getter=itemgetter(0),
46 | items='subs',
47 | on_click=on_click_set_parameters,
48 | type_factory=int,
49 | ),
50 | Button(
51 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu,
52 | ),
53 | ),
54 | getter=get_subs_for_edit,
55 | state=EditMenu.EDIT,
56 | ),
57 | Window(
58 | I18NFormat('select-parameters'),
59 | Group(
60 | Row(
61 | Button(
62 | I18NFormat('title'), id='title_id', on_click=on_click_edit_title,
63 | ),
64 | Button(
65 | I18NFormat('months'), id='months_id', on_click=on_click_edit_months,
66 | ),
67 | Button(
68 | I18NFormat('date'), id='date_id', on_click=on_click_edit_date,
69 | ),
70 | ),
71 | SwitchTo(
72 | I18NFormat('back'), id='back_id', state=EditMenu.EDIT
73 | ),
74 | ),
75 | state=EditMenu.PARAMETERS,
76 | ),
77 | Window(
78 | I18NFormat('add-service-title'),
79 | MessageInput(func=edit_title_handler, content_types=ContentType.TEXT),
80 | SwitchTo(
81 | I18NFormat('back'), id='back_id', state=EditMenu.PARAMETERS
82 | ),
83 | state=EditMenu.TITLE,
84 | ),
85 | Window(
86 | I18NFormat('add-service-months'),
87 | MessageInput(func=edit_months_handler, content_types=ContentType.TEXT),
88 | SwitchTo(I18NFormat('back'), id='back_id', state=EditMenu.PARAMETERS),
89 | state=EditMenu.MONTHS,
90 | ),
91 | Window(
92 | I18NFormat('add-calendar-date'),
93 | Group(
94 | CustomCalendar(
95 | id='select_date_id', on_click=edit_reminder_handler
96 | ),
97 | SwitchTo(
98 | I18NFormat('back'), id='back_id', state=EditMenu.PARAMETERS
99 | ),
100 | ),
101 | state=EditMenu.REMINDER,
102 | ),
103 | Window(
104 | I18NFormat('check-title-form'),
105 | Row(
106 | Button(
107 | Const('✅'), id='confirm_delete_id', on_click=approve_edit_menu
108 | ),
109 | Button(
110 | Const('❎'), id='reject_delete_id', on_click=reject_edit_menu
111 | ),
112 | ),
113 | getter=get_service_title_data,
114 | state=EditMenu.CHECK_TITLE_CHANGE,
115 | ),
116 | Window(
117 | I18NFormat('check-months-form'),
118 | Row(
119 | Button(
120 | Const('✅'), id='confirm_delete_id', on_click=approve_edit_menu
121 | ),
122 | Button(
123 | Const('❎'), id='reject_delete_id', on_click=reject_edit_menu
124 | ),
125 | ),
126 | getter=get_service_months_data,
127 | state=EditMenu.CHECK_MONTHS_CHANGE,
128 | ),
129 | Window(
130 | I18NFormat('check-reminder-form'),
131 | Row(
132 | Button(
133 | Const('✅'), id='confirm_delete_id', on_click=approve_edit_menu
134 | ),
135 | Button(
136 | Const('❎'), id='reject_delete_id', on_click=reject_edit_menu
137 | ),
138 | ),
139 | getter=get_service_reminder_data,
140 | state=EditMenu.CHECK_REMINDER_CHANGE,
141 | ),
142 | )
143 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/edit_menu/getters.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Any
3 |
4 | from aiogram_dialog import DialogManager
5 |
6 |
7 | async def get_subs_for_edit(
8 | dialog_manager: DialogManager, **kwargs
9 | ) -> dict[str, list[tuple[int, str, str]] | Any]:
10 | l10n = dialog_manager.middleware_data['l10n']
11 | session = dialog_manager.middleware_data['session']
12 |
13 | services = await session.get_services(user_id=dialog_manager.event.from_user.id)
14 |
15 | if not services:
16 | return {
17 | 'message': l10n.format_value('nothing-output'),
18 | 'subs': []
19 | }
20 |
21 | subs = [
22 | (item.service_id, item.title, datetime.date(item.reminder).isoformat())
23 | for item in services
24 | ]
25 | return {
26 | 'message': l10n.format_value('set-for-edit'),
27 | 'subs': subs
28 | }
29 |
30 |
31 | async def get_service_title_data(
32 | dialog_manager: DialogManager, **kwargs
33 | ) -> dict[str, str]:
34 | return {
35 | 'service_new_title': dialog_manager.dialog_data.get('service_new_title'),
36 | 'service_old_title': dialog_manager.dialog_data.get('service_old_title'),
37 | }
38 |
39 |
40 | async def get_service_months_data(
41 | dialog_manager: DialogManager, **kwargs
42 | ) -> dict[str, str]:
43 | return {
44 | 'service_new_months': dialog_manager.dialog_data.get('service_new_months'),
45 | 'service_old_months': dialog_manager.dialog_data.get('service_old_months'),
46 | }
47 |
48 |
49 | async def get_service_reminder_data(
50 | dialog_manager: DialogManager, **kwargs
51 | ) -> dict[str, str]:
52 | return {
53 | 'service_new_reminder': dialog_manager.dialog_data.get('service_new_reminder'),
54 | 'service_old_reminder': dialog_manager.dialog_data.get('service_old_reminder'),
55 | }
56 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/edit_menu/handlers.py:
--------------------------------------------------------------------------------
1 | from datetime import date, datetime
2 |
3 | import markupsafe
4 | from aiogram.types import CallbackQuery, Message
5 | from aiogram_dialog import DialogManager, DialogProtocol, StartMode
6 | from aiogram_dialog.widgets.kbd import Button
7 |
8 | from src.presentation.tgbot.states.user import EditMenu, MainMenu
9 |
10 |
11 | async def on_click_get_edit_menu(
12 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager
13 | ) -> None:
14 | await dialog_manager.start(state=EditMenu.EDIT, mode=StartMode.RESET_STACK)
15 |
16 |
17 | async def on_click_set_parameters(
18 | callback: CallbackQuery,
19 | button: Button,
20 | dialog_manager: DialogManager,
21 | item_id: int,
22 | ) -> None:
23 | dialog_manager.dialog_data['service_id'] = item_id
24 | await dialog_manager.switch_to(state=EditMenu.PARAMETERS)
25 |
26 |
27 | async def on_click_edit_title(
28 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager,
29 | ) -> None:
30 | await dialog_manager.switch_to(state=EditMenu.TITLE)
31 |
32 |
33 | async def on_click_edit_months(
34 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager,
35 | ) -> None:
36 | await dialog_manager.switch_to(state=EditMenu.MONTHS)
37 |
38 |
39 | async def on_click_edit_date(
40 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager,
41 | ) -> None:
42 | await dialog_manager.switch_to(state=EditMenu.REMINDER)
43 |
44 |
45 | async def edit_title_handler(
46 | message: Message, protocol: DialogProtocol, dialog_manager: DialogManager
47 | ) -> Message:
48 | l10n = dialog_manager.middleware_data['l10n']
49 | session = dialog_manager.middleware_data['session']
50 |
51 | if len(message.text) > 30:
52 | return await message.answer(l10n.format_value('error-len-limit'))
53 |
54 | service_id = dialog_manager.dialog_data['service_id']
55 | service = await session.get_service(service_id=service_id)
56 |
57 | dialog_manager.dialog_data['service_old_title'] = service.title
58 | dialog_manager.dialog_data['service_new_title'] = markupsafe.escape(message.text)
59 |
60 | await dialog_manager.switch_to(state=EditMenu.CHECK_TITLE_CHANGE)
61 |
62 |
63 | async def edit_months_handler(
64 | message: Message, protocol: DialogProtocol, dialog_manager: DialogManager
65 | ) -> Message:
66 | l10n = dialog_manager.middleware_data['l10n']
67 | session = dialog_manager.middleware_data['session']
68 |
69 | try:
70 | value = int(message.text)
71 | except ValueError:
72 | return await message.answer(
73 | l10n.format_value('error-unsupported-char')
74 | )
75 |
76 | if value not in range(1, 12 + 1):
77 | return await message.answer(l10n.format_value('error-range-reached'))
78 |
79 | service_id = dialog_manager.dialog_data['service_id']
80 | service = await session.get_service(service_id=service_id)
81 |
82 | dialog_manager.dialog_data['service_old_months'] = service.months
83 | dialog_manager.dialog_data['service_new_months'] = int(message.text)
84 |
85 | await dialog_manager.switch_to(state=EditMenu.CHECK_MONTHS_CHANGE)
86 |
87 |
88 | async def edit_reminder_handler(
89 | callback: CallbackQuery,
90 | button: Button,
91 | dialog_manager: DialogManager,
92 | selected_date: date,
93 | ) -> None:
94 | session = dialog_manager.middleware_data['session']
95 |
96 | service_id = dialog_manager.dialog_data['service_id']
97 | service = await session.get_service(service_id=service_id)
98 |
99 | dialog_manager.dialog_data['service_old_reminder'] = service.reminder.date().isoformat()
100 | dialog_manager.dialog_data['service_new_reminder'] = selected_date.isoformat()
101 |
102 | await dialog_manager.switch_to(state=EditMenu.CHECK_REMINDER_CHANGE)
103 |
104 |
105 | async def reject_edit_menu(
106 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager
107 | ) -> None:
108 | l10n = dialog_manager.middleware_data['l10n']
109 |
110 | await callback.message.edit_text(l10n.format_value('reject-sub-edit'))
111 | await dialog_manager.done()
112 | await dialog_manager.start(state=EditMenu.EDIT, mode=StartMode.RESET_STACK)
113 |
114 |
115 | async def approve_edit_menu(
116 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager
117 | ) -> None:
118 | l10n = dialog_manager.middleware_data['l10n']
119 | session = dialog_manager.middleware_data['session']
120 |
121 | if dialog_manager.dialog_data.get('service_new_title'):
122 | await session.edit_sub_title(
123 | service_id=dialog_manager.dialog_data['service_id'],
124 | title=dialog_manager.dialog_data['service_new_title'],
125 | )
126 | if dialog_manager.dialog_data.get('service_new_months'):
127 | await session.edit_sub_months(
128 | service_id=dialog_manager.dialog_data['service_id'],
129 | months=dialog_manager.dialog_data['service_new_months'],
130 | )
131 | if dialog_manager.dialog_data.get('service_new_reminder'):
132 | await session.edit_sub_date(
133 | service_id=dialog_manager.dialog_data['service_id'],
134 | reminder=datetime.fromisoformat(
135 | dialog_manager.dialog_data['service_new_reminder']
136 | ),
137 | )
138 | await session.commit()
139 |
140 | await callback.message.edit_text(l10n.format_value('approve-sub-edit'))
141 | await dialog_manager.done()
142 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK)
143 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/extras/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/extras/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/extras/calendar.py:
--------------------------------------------------------------------------------
1 | from datetime import date
2 | from typing import Dict
3 |
4 | from aiogram_dialog import DialogManager
5 | from aiogram_dialog.widgets.kbd import Calendar, CalendarScope
6 | from aiogram_dialog.widgets.kbd.calendar_kbd import (
7 | CalendarDaysView,
8 | CalendarMonthView,
9 | CalendarScopeView,
10 | CalendarYearsView,
11 | )
12 | from aiogram_dialog.widgets.text import Format, Text
13 | from babel.dates import get_day_names, get_month_names
14 |
15 |
16 | class WeekDay(Text):
17 | async def _render_text(self, data, dialog_manager: DialogManager) -> str:
18 | selected_date: date = data['date']
19 | session = dialog_manager.middleware_data['session']
20 | language = await session.get_language(user_id=dialog_manager.event.from_user.id)
21 |
22 | return get_day_names(
23 | width='short',
24 | context='stand-alone',
25 | locale=language,
26 | )[selected_date.weekday()].title()
27 |
28 |
29 | class Month(Text):
30 | async def _render_text(self, data, dialog_manager: DialogManager) -> str:
31 | selected_date: date = data['date']
32 | session = dialog_manager.middleware_data['session']
33 | language = await session.get_language(user_id=dialog_manager.event.from_user.id)
34 |
35 | return get_month_names(
36 | 'wide',
37 | context='stand-alone',
38 | locale=language,
39 | )[selected_date.month].title()
40 |
41 |
42 | class CustomCalendar(Calendar):
43 | def _init_views(self) -> Dict[CalendarScope, CalendarScopeView]:
44 | return {
45 | CalendarScope.DAYS: CalendarDaysView(
46 | self._item_callback_data,
47 | self.config,
48 | header_text=Month(),
49 | weekday_text=WeekDay(),
50 | next_month_text=Month() + ' →',
51 | prev_month_text='← ' + Month(),
52 | ),
53 | CalendarScope.MONTHS: CalendarMonthView(
54 | self._item_callback_data,
55 | self.config,
56 | month_text=Month(),
57 | header_text=Format('{date:%Y}'),
58 | this_month_text='[ ' + Month() + ' ]',
59 | ),
60 | CalendarScope.YEARS: CalendarYearsView(
61 | self._item_callback_data,
62 | self.config,
63 | ),
64 | }
65 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/extras/i18n_format.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict, Protocol
2 |
3 | from aiogram_dialog.api.protocols import DialogManager
4 | from aiogram_dialog.widgets.common import WhenCondition
5 | from aiogram_dialog.widgets.text import Text
6 |
7 | I18N_FORMAT_KEY = 'aiogd_i18n_format'
8 |
9 |
10 | class Values(Protocol):
11 | def __getitem__(self, item: Any) -> Any:
12 | raise NotImplementedError
13 |
14 |
15 | def default_format_text(text: str, data: Values) -> str:
16 | return text.format_map(data)
17 |
18 |
19 | class I18NFormat(Text):
20 | def __init__(self, text: str, when: WhenCondition = None):
21 | super().__init__(when)
22 | self.text = text
23 |
24 | async def _render_text(self, data: Dict, manager: DialogManager) -> str:
25 | format_text = manager.middleware_data.get(
26 | I18N_FORMAT_KEY,
27 | default_format_text,
28 | )
29 | return format_text(self.text, data)
30 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/main_menu/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/main_menu/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/main_menu/dialog.py:
--------------------------------------------------------------------------------
1 | import operator
2 |
3 | from aiogram_dialog import Dialog, Window
4 | from aiogram_dialog.widgets.kbd import (
5 | Button,
6 | Group,
7 | Row,
8 | Select,
9 | Url,
10 | SwitchTo,
11 | )
12 | from aiogram_dialog.widgets.text import Const, Format
13 |
14 | from src.presentation.tgbot.states.user import MainMenu
15 | from ..create_menu.getters import get_subs_for_output
16 | from ..delete_menu.handlers import on_click_get_delete_menu
17 | from ..edit_menu.handlers import on_click_get_edit_menu
18 | from ..extras.i18n_format import I18NFormat
19 | from ..main_menu.getters import get_langs_for_output
20 | from ..main_menu.handler import (
21 | on_click_back_to_main_menu,
22 | on_click_change_lang,
23 | on_click_get_subs_menu,
24 | on_click_sub_create,
25 | )
26 |
27 | main_menu = Dialog(
28 | Window(
29 | I18NFormat('start-menu'),
30 | Group(
31 | Button(
32 | I18NFormat('my-subscriptions'), id='subs_id', on_click=on_click_get_subs_menu,
33 | ),
34 | Row(
35 | SwitchTo(
36 | I18NFormat('settings'), id='settings_id', state=MainMenu.SETTINGS,
37 | ),
38 | SwitchTo(
39 | I18NFormat('support'), id='support_id', state=MainMenu.SUPPORT,
40 | ),
41 | ),
42 | ),
43 | state=MainMenu.MAIN,
44 | ),
45 | Window(
46 | I18NFormat('faq'),
47 | Group(
48 | Row(
49 | Url(
50 | I18NFormat('administrator'),
51 | Const('tg://user?id=878406427'),
52 | ),
53 | Url(
54 | Const('🐈 GitHub'),
55 | Const('https://github.com/Markushik/controller-new/'),
56 | ),
57 | ),
58 | Button(
59 | I18NFormat('back'), id='back_id', on_click=on_click_back_to_main_menu,
60 | ),
61 | ),
62 | state=MainMenu.SUPPORT,
63 | ),
64 | Window(
65 | I18NFormat('catalog-add'),
66 | Group(
67 | Row(
68 | Button(
69 | I18NFormat('add'), id='add_id', on_click=on_click_sub_create,
70 | ),
71 | Button(
72 | I18NFormat('change'), id='change_id', on_click=on_click_get_edit_menu,
73 | ),
74 | Button(
75 | I18NFormat('delete'), id='remove_id', on_click=on_click_get_delete_menu,
76 | ),
77 | ),
78 | Button(
79 | I18NFormat('back'), id='back_id', on_click=on_click_back_to_main_menu,
80 | ),
81 | ),
82 | state=MainMenu.CONTROL,
83 | getter=get_subs_for_output,
84 | ),
85 | Window(
86 | I18NFormat('select-lang'),
87 | Group(
88 | Row(
89 | Select(
90 | Format('{item[1]}'),
91 | id='lang_id',
92 | item_id_getter=operator.itemgetter(0),
93 | items='langs',
94 | on_click=on_click_change_lang,
95 | ),
96 | ),
97 | Button(
98 | I18NFormat('back'), id='back_id', on_click=on_click_back_to_main_menu,
99 | ),
100 | ),
101 | state=MainMenu.SETTINGS,
102 | getter=get_langs_for_output,
103 | ),
104 | )
105 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/main_menu/getters.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from aiogram_dialog import DialogManager
4 |
5 | from src.presentation.tgbot.constants import LANGUAGES
6 |
7 |
8 | async def get_langs_for_output(**kwargs) -> dict[str, list[Any]]:
9 | return {'langs': [item for item in enumerate(LANGUAGES)]}
10 |
11 |
12 | async def get_input_service_data(
13 | dialog_manager: DialogManager, **kwargs
14 | ) -> dict[str, Any | None]:
15 | return {
16 | 'service': dialog_manager.dialog_data.get('service'),
17 | 'months': dialog_manager.dialog_data.get('months'),
18 | 'reminder': dialog_manager.dialog_data.get('reminder'),
19 | }
20 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/dialogs/main_menu/handler.py:
--------------------------------------------------------------------------------
1 | from aiogram.types import CallbackQuery
2 | from aiogram_dialog import DialogManager, DialogProtocol, StartMode
3 | from aiogram_dialog.widgets.kbd import Button
4 |
5 | from src.presentation.tgbot.dialogs.extras.i18n_format import I18N_FORMAT_KEY
6 | from src.presentation.tgbot.states.user import MainMenu, CreateMenu, DeleteMenu
7 |
8 |
9 | async def on_click_get_subs_menu(
10 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager
11 | ) -> None:
12 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK)
13 |
14 |
15 | async def on_click_back_to_main_menu(
16 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager
17 | ) -> None:
18 | await dialog_manager.start(state=MainMenu.MAIN, mode=StartMode.RESET_STACK)
19 |
20 |
21 | async def on_click_sub_create(
22 | callback: CallbackQuery, protocol: DialogProtocol, dialog_manager: DialogManager
23 | ) -> None:
24 | l10n = dialog_manager.middleware_data['l10n']
25 | session = dialog_manager.middleware_data['session']
26 |
27 | count_subs = await session.get_quantity_subs(user_id=dialog_manager.event.from_user.id)
28 |
29 | if count_subs < 7:
30 | return await dialog_manager.start(state=CreateMenu.TITLE, mode=StartMode.RESET_STACK)
31 |
32 | await callback.message.edit_text(l10n.format_value('error-subs-limit'))
33 | await dialog_manager.done()
34 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK)
35 |
36 |
37 | async def on_click_sub_delete(
38 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager
39 | ) -> None:
40 | l10n = dialog_manager.middleware_data['l10n']
41 | session = dialog_manager.middleware_data['session']
42 |
43 | await session.delete_subscription(service_id=dialog_manager.dialog_data['service_id'])
44 | await session.decrement_quantity(user_id=dialog_manager.event.from_user.id)
45 | await session.commit()
46 |
47 | await callback.message.edit_text(l10n.format_value('approve-sub-delete'))
48 | await dialog_manager.done()
49 | await dialog_manager.start(state=DeleteMenu.DELETE, mode=StartMode.RESET_STACK)
50 |
51 |
52 | async def update_format_key(
53 | dialog_manager: DialogManager, language: str
54 | ) -> None:
55 | l10n = dialog_manager.middleware_data['l10ns'][language]
56 | dialog_manager.middleware_data[I18N_FORMAT_KEY] = l10n.format_value
57 | return
58 |
59 |
60 | async def on_click_change_lang(
61 | callback: CallbackQuery,
62 | button: Button,
63 | dialog_manager: DialogManager,
64 | item_id: str,
65 | ) -> None:
66 | session = dialog_manager.middleware_data['session']
67 |
68 | language = None
69 |
70 | if item_id == '0':
71 | language = 'ru_RU'
72 | await callback.answer('Вы сменили язык на 🇷🇺 Русский')
73 | if item_id == '1':
74 | language = 'en_GB'
75 | await callback.answer('You switched language to 🇬🇧 English')
76 |
77 | await update_format_key(dialog_manager=dialog_manager, language=language)
78 | await session.update_language(user_id=dialog_manager.event.from_user.id, language=language)
79 | await session.commit()
80 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/handlers/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/handlers/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/handlers/client.py:
--------------------------------------------------------------------------------
1 | from aiogram import F, Router
2 | from aiogram.filters import CommandStart, StateFilter
3 | from aiogram.types import Message, CallbackQuery
4 | from aiogram_dialog import DialogManager, StartMode
5 |
6 | from src.presentation.tgbot.keyboards.inline import CallbackExtensionBody
7 | from src.presentation.tgbot.states.user import CreateMenu, MainMenu
8 |
9 | router = Router()
10 |
11 |
12 | @router.callback_query(CallbackExtensionBody.filter(F.extension == 'extension'))
13 | async def command_extension(
14 | callback: CallbackQuery,
15 | dialog_manager: DialogManager,
16 | callback_data: CallbackExtensionBody,
17 | ) -> None:
18 | await dialog_manager.done()
19 | await dialog_manager.start(state=CreateMenu.REMINDER, mode=StartMode.NORMAL)
20 |
21 | dialog_manager.dialog_data['service'] = callback_data.service
22 | dialog_manager.dialog_data['months'] = callback_data.months
23 |
24 | await callback.answer()
25 |
26 |
27 | @router.message(CommandStart(), StateFilter('*'))
28 | async def command_start(
29 | message: Message, dialog_manager: DialogManager
30 | ) -> None:
31 | session = dialog_manager.middleware_data['session']
32 | user = await session.get_user(user_id=message.from_user.id)
33 |
34 | if user is None:
35 | await session.add_user(
36 | user_id=message.from_user.id,
37 | user_name=message.from_user.first_name,
38 | chat_id=message.chat.id,
39 | )
40 | await session.commit()
41 |
42 | await dialog_manager.start(state=MainMenu.MAIN, mode=StartMode.RESET_STACK)
43 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/handlers/errors.py:
--------------------------------------------------------------------------------
1 | from aiogram_dialog import DialogManager, StartMode
2 | from loguru import logger
3 |
4 | from src.presentation.tgbot.states.user import MainMenu
5 |
6 |
7 | async def on_unknown_intent(event, dialog_manager: DialogManager):
8 | logger.error('Restarting dialog: %s', event.exception)
9 | await dialog_manager.start(state=MainMenu.MAIN, mode=StartMode.RESET_STACK)
10 |
11 |
12 | async def on_unknown_state(event, dialog_manager: DialogManager):
13 | logger.error('Restarting dialog: %s', event.exception)
14 | await dialog_manager.start(state=MainMenu.MAIN, mode=StartMode.RESET_STACK)
15 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/keyboards/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/keyboards/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/keyboards/inline.py:
--------------------------------------------------------------------------------
1 | from aiogram.filters.callback_data import CallbackData
2 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
3 | from aiogram.utils.keyboard import InlineKeyboardBuilder
4 |
5 |
6 | class CallbackExtensionBody(CallbackData, prefix='extension'):
7 | extension: str
8 | service: str
9 | months: int
10 |
11 |
12 | def get_extension_menu(
13 | text: str, service: str, months: int,
14 | ) -> InlineKeyboardMarkup:
15 | menu_builder = InlineKeyboardBuilder()
16 |
17 | menu_builder.row(
18 | InlineKeyboardButton(
19 | text=text,
20 | callback_data=CallbackExtensionBody(
21 | extension='extension', service=service, months=months,
22 | ).pack()
23 | )
24 | )
25 |
26 | return menu_builder.as_markup()
27 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/middlewares/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/middlewares/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/middlewares/database.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Awaitable, Callable, Dict
2 |
3 | from aiogram import BaseMiddleware
4 | from aiogram.types import TelegramObject
5 | from sqlalchemy.ext.asyncio import async_sessionmaker
6 |
7 | from src.infrastructure.database.adapter.adapter import DbAdapter
8 |
9 |
10 | class DbSessionMiddleware(BaseMiddleware):
11 | def __init__(self, session_maker: async_sessionmaker):
12 | super().__init__()
13 | self.session_maker = session_maker
14 |
15 | async def __call__(
16 | self,
17 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
18 | event: TelegramObject,
19 | data: Dict[str, Any],
20 | ) -> Any:
21 | async with self.session_maker() as session:
22 | data['session'] = DbAdapter(session=session)
23 | return await handler(event, data)
24 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/middlewares/i18n.py:
--------------------------------------------------------------------------------
1 | import os
2 | from typing import Any, Awaitable, Callable, Dict, Union
3 |
4 | from aiogram.dispatcher.middlewares.base import BaseMiddleware
5 | from aiogram.types import CallbackQuery, Message
6 | from fluent.runtime import FluentLocalization, FluentResourceLoader
7 |
8 | from src.infrastructure.database.adapter.adapter import DbAdapter
9 | from src.presentation.tgbot.constants import DEFAULT_LOCALE, LOCALES
10 | from src.presentation.tgbot.dialogs.extras.i18n_format import I18N_FORMAT_KEY
11 |
12 |
13 | def make_i18n_middleware():
14 | loader = FluentResourceLoader(
15 | os.path.join(
16 | os.path.dirname(__file__), '../../', 'locales', '{locale}'
17 | )
18 | )
19 | l10ns = {
20 | locale: FluentLocalization(
21 | [locale, DEFAULT_LOCALE],
22 | ['main.ftl'],
23 | loader,
24 | )
25 | for locale in LOCALES
26 | }
27 | return I18nMiddleware(l10ns, DEFAULT_LOCALE)
28 |
29 |
30 | class I18nMiddleware(BaseMiddleware):
31 | def __init__(
32 | self,
33 | l10ns: Dict[str, FluentLocalization],
34 | default_lang: str,
35 | ):
36 | super().__init__()
37 | self.l10ns = l10ns
38 | self.default_lang = default_lang
39 |
40 | async def __call__(
41 | self,
42 | handler: Callable[
43 | [Union[Message, CallbackQuery], Dict[str, Any]],
44 | Awaitable[Any],
45 | ],
46 | event: Union[Message, CallbackQuery],
47 | data: Dict[str, Any],
48 | ) -> Any:
49 | session: DbAdapter = data['session']
50 | language = await session.get_language(user_id=event.from_user.id)
51 |
52 | language = language or 'ru_RU'
53 | l10n = self.l10ns[language]
54 |
55 | data['l10n'] = l10n
56 | data['l10ns'] = self.l10ns
57 | data[I18N_FORMAT_KEY] = l10n.format_value
58 |
59 | return await handler(event, data)
60 |
--------------------------------------------------------------------------------
/src/presentation/tgbot/states/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/states/__init__.py
--------------------------------------------------------------------------------
/src/presentation/tgbot/states/user.py:
--------------------------------------------------------------------------------
1 | from aiogram.fsm.state import State, StatesGroup
2 |
3 |
4 | class MainMenu(StatesGroup):
5 | MAIN = State()
6 | CONTROL = State()
7 | SETTINGS = State()
8 | SUPPORT = State()
9 |
10 |
11 | class CreateMenu(StatesGroup):
12 | TITLE = State()
13 | MONTHS = State()
14 | REMINDER = State()
15 | CHECK_ADD = State()
16 |
17 |
18 | class EditMenu(StatesGroup):
19 | EDIT = State()
20 | PARAMETERS = State()
21 | TITLE = State()
22 | MONTHS = State()
23 | REMINDER = State()
24 |
25 | CHECK_TITLE_CHANGE = State()
26 | CHECK_MONTHS_CHANGE = State()
27 | CHECK_REMINDER_CHANGE = State()
28 |
29 |
30 | class DeleteMenu(StatesGroup):
31 | DELETE = State()
32 | CHECK_DELETE = State()
33 |
--------------------------------------------------------------------------------