├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── alembic.ini ├── docker-compose.yml ├── pyproject.toml └── src └── tactic ├── __init__.py ├── application ├── __init__.py ├── common │ ├── __init__.py │ ├── interactor.py │ ├── repositories.py │ └── uow.py └── create_user.py ├── domain ├── __init__.py ├── common │ ├── __init__.py │ └── value_objects │ │ ├── __init__.py │ │ └── base.py ├── entities │ ├── __init__.py │ └── user.py ├── services │ ├── __init__.py │ └── user.py └── value_objects │ ├── __init__.py │ └── user.py ├── infrastructure ├── __init__.py ├── config_loader.py └── db │ ├── __init__.py │ ├── main.py │ ├── migrations │ ├── README │ ├── __init__.py │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 8c86aabd6160_init.py │ │ └── __init__.py │ ├── models.py │ ├── repositories │ ├── __init__.py │ └── user.py │ └── uow.py └── presentation ├── __init__.py ├── bot.py ├── interactor_factory.py ├── ioc.py └── telegram ├── __init__.py ├── assets └── start.gif ├── new_user ├── __init__.py └── dialog.py └── states.py /.dockerignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | tactic.egg-info/ -------------------------------------------------------------------------------- /.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 | # my 163 | .ruff_cache/ 164 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Separate build image 2 | FROM python:3.10.8-slim-buster as compile-image 3 | 4 | RUN python -m venv /opt/venv 5 | ENV PATH="/opt/venv/bin:$PATH" 6 | 7 | RUN pip install --no-cache-dir --upgrade pip 8 | 9 | # Final image 10 | FROM python:3.10.8-slim-buster 11 | 12 | COPY --from=compile-image /opt/venv /opt/venv 13 | 14 | ENV PATH="/opt/venv/bin:$PATH" 15 | 16 | WORKDIR /app 17 | ENV HOME=/app 18 | 19 | RUN addgroup --system app && adduser --system --group app 20 | 21 | COPY . . 22 | 23 | RUN chown -R app:app $HOME 24 | RUN chown -R app:app "/opt/venv/" 25 | 26 | USER app 27 | 28 | RUN pip install -e . -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Ilya Lyubavsky 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Минимальный шаблон бота aiogram3 2 | 3 | 👋 **Приветствую**! В данном репозитории хранится мой легковесный шаблон для телеграм бота на **Python** с использованием: 4 | 5 | - aiogram 3 6 | - aiogram-dialog 2 7 | - docker 8 | - postgresql 9 | - redis 10 | - alembic 11 | 12 | Из **плюсов** данного шаблона, я могу выделить: 13 | 14 | - Чистая архитектура, код легко расширять, изменять и поддерживать. 15 | - Готовая система миграций **alembic**. 16 | - Современный движок для базы данных **postgresql**. 17 | - **RedisStorage**, бот будет помнить историю сообщений даже после перезапуска. 18 | - **aiogram-dialog** для удобного описания **пользовательского интерфейса**. 19 | 20 | ### Установка 21 | 22 | Установка довольно **проста**, вам понадобится склонировать данный **репозиторий** и установить **docker + docker-compose** на свой ПК. 23 | 24 | Далее, создайте файл **.env** в корне проекта, и внесите туда следующее содержимое: 25 | 26 | ```env 27 | API_TOKEN=<токен бота тут> 28 | 29 | POSTGRES_USER=cleanbot_user 30 | 31 | POSTGRES_PASSWORD=<ваш пароль тут> 32 | 33 | POSTGRES_DB=cleanbot_db 34 | 35 | DB_HOST=db 36 | 37 | DB_PORT=5432 38 | 39 | DB_NAME=cleanbot_db 40 | 41 | DB_USER=cleanbot_user 42 | 43 | DB_PASS=<ваш пароль тут> 44 | ``` 45 | 46 | Далее, соберите образ: 47 | 48 | ```shell 49 | docker-compose up --build 50 | ``` 51 | 52 | Выполните в контейнере **bot** команду 53 | 54 | ```shell 55 | alembic revision --autogenerate -m "init" 56 | ``` 57 | 58 | Для создания начальной миграции, для применения миграций выполняете команду: 59 | 60 | ```shell 61 | alembic upgrade head 62 | ``` 63 | 64 | Выключить бота: 65 | 66 | ```shell 67 | docker-compose down -v 68 | ``` -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = src/tactic/infrastructure/db/migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to migrations/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | 64 | [post_write_hooks] 65 | # post_write_hooks defines scripts or Python functions that are run 66 | # on newly generated revision scripts. See the documentation for further 67 | # detail and examples 68 | 69 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 70 | # hooks = black 71 | # black.type = console_scripts 72 | # black.entrypoint = black 73 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 74 | 75 | # Logging configuration 76 | [loggers] 77 | keys = root,sqlalchemy,alembic 78 | 79 | [handlers] 80 | keys = console 81 | 82 | [formatters] 83 | keys = generic 84 | 85 | [logger_root] 86 | level = WARN 87 | handlers = console 88 | qualname = 89 | 90 | [logger_sqlalchemy] 91 | level = WARN 92 | handlers = 93 | qualname = sqlalchemy.engine 94 | 95 | [logger_alembic] 96 | level = INFO 97 | handlers = 98 | qualname = alembic 99 | 100 | [handler_console] 101 | class = StreamHandler 102 | args = (sys.stderr,) 103 | level = NOTSET 104 | formatter = generic 105 | 106 | [formatter_generic] 107 | format = %(levelname)-5.5s [%(name)s] %(message)s 108 | datefmt = %H:%M:%S 109 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | bot: 5 | container_name: bot 6 | build: ./ 7 | restart: on-failure 8 | command: python src/tactic/presentation/bot.py 9 | env_file: 10 | - .env 11 | volumes: 12 | - ./src/tactic/infrastructure/db/migrations/versions:/app/infrastructure/db/migrations/versions 13 | depends_on: 14 | - migration 15 | - bot_redis 16 | 17 | migration: 18 | container_name: migration 19 | build: ./ 20 | restart: on-failure 21 | env_file: 22 | - .env 23 | depends_on: 24 | db: 25 | condition: service_healthy 26 | command: [ "alembic", "upgrade", "head" ] 27 | 28 | db: 29 | container_name: db 30 | image: postgres:14.5-alpine 31 | restart: on-failure 32 | env_file: 33 | - .env 34 | volumes: 35 | - db_data:/var/lib/postgresql/data/ 36 | healthcheck: 37 | test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ] 38 | interval: 2s 39 | timeout: 60s 40 | retries: 10 41 | start_period: 5s 42 | 43 | bot_redis: 44 | container_name: redis 45 | image: redis:7.0.4-alpine 46 | restart: on-failure 47 | ports: 48 | - "6379:6378" 49 | volumes: 50 | - redis_data:/data 51 | 52 | volumes: 53 | db_data: 54 | redis_data: -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | 'setuptools==68.1.2', 4 | ] 5 | build-backend = 'setuptools.build_meta' 6 | 7 | [project] 8 | name = 'tactic' 9 | version = '1.0.0' 10 | description = 'A tactic - implementation of clean architecture on Python contains some tactical patterns from DDD' 11 | readme = 'README.md' 12 | requires-python = '>=3.10.8' 13 | dependencies= [ 14 | 'SQLAlchemy==2.0.20', 15 | 'aiogram-dialog==2.1.0', 16 | 'redis==5.0.0', 17 | 'asyncpg==0.28.0', 18 | 'alembic==1.11.3', 19 | 'aiogram~=3.0.0rc2' 20 | ] 21 | 22 | [tool.setuptools] 23 | package-dir = {"" = "src"} 24 | 25 | [[project.authors]] 26 | name = 'lubaskinc0de' 27 | email = 'lubaskincorporation@gmail.com' -------------------------------------------------------------------------------- /src/tactic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/__init__.py -------------------------------------------------------------------------------- /src/tactic/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/application/__init__.py -------------------------------------------------------------------------------- /src/tactic/application/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/application/common/__init__.py -------------------------------------------------------------------------------- /src/tactic/application/common/interactor.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | 3 | InputDTO = TypeVar("InputDTO") 4 | OutputDTO = TypeVar("OutputDTO") 5 | 6 | 7 | class Interactor(Generic[InputDTO, OutputDTO]): 8 | async def __call__(self, data: InputDTO) -> OutputDTO: 9 | raise NotImplementedError 10 | 11 | 12 | InteractorT = TypeVar("InteractorT") 13 | -------------------------------------------------------------------------------- /src/tactic/application/common/repositories.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | from tactic.domain.entities.user import User 4 | from tactic.domain.value_objects.user import UserId 5 | 6 | 7 | class UserRepository(Protocol): 8 | """User repository interface""" 9 | 10 | async def create(self, user: User) -> User: 11 | raise NotImplementedError 12 | 13 | async def exists(self, user_id: UserId) -> bool: 14 | raise NotImplementedError 15 | -------------------------------------------------------------------------------- /src/tactic/application/common/uow.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | 4 | class UnitOfWork(Protocol): 5 | """UoW interface""" 6 | 7 | async def commit(self) -> None: 8 | raise NotImplementedError 9 | 10 | async def rollback(self) -> None: 11 | raise NotImplementedError 12 | -------------------------------------------------------------------------------- /src/tactic/application/create_user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from tactic.application.common.interactor import Interactor 4 | from tactic.application.common.uow import UnitOfWork 5 | from tactic.application.common.repositories import UserRepository 6 | 7 | from tactic.domain.entities.user import User 8 | from tactic.domain.value_objects.user import UserId 9 | from tactic.domain.services.user import UserService 10 | 11 | 12 | @dataclass(frozen=True) 13 | class UserInputDTO: 14 | user_id: UserId 15 | 16 | 17 | @dataclass(frozen=True) 18 | class UserOutputDTO: 19 | user_id: UserId 20 | 21 | 22 | class CreateUser(Interactor[UserInputDTO, UserOutputDTO]): 23 | def __init__( 24 | self, 25 | repository: UserRepository, 26 | user_service: UserService, 27 | uow: UnitOfWork, 28 | ): 29 | self.repository = repository 30 | self.user_service = user_service 31 | self.uow = uow 32 | 33 | async def __call__(self, data: UserInputDTO) -> UserOutputDTO: 34 | user: User = self.user_service.create(data.user_id) 35 | 36 | user_exists: bool = await self.repository.exists(data.user_id) 37 | 38 | if not user_exists: 39 | await self.repository.create(user) 40 | await self.uow.commit() 41 | 42 | return UserOutputDTO(user_id=user.user_id) 43 | -------------------------------------------------------------------------------- /src/tactic/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/domain/__init__.py -------------------------------------------------------------------------------- /src/tactic/domain/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/domain/common/__init__.py -------------------------------------------------------------------------------- /src/tactic/domain/common/value_objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/domain/common/value_objects/__init__.py -------------------------------------------------------------------------------- /src/tactic/domain/common/value_objects/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from dataclasses import dataclass 3 | from typing import Any, Generic, TypeVar 4 | 5 | V = TypeVar("V", bound=Any) 6 | 7 | 8 | @dataclass(frozen=True) 9 | class BaseValueObject(ABC): 10 | def __post_init__(self) -> None: 11 | self._validate() 12 | 13 | def _validate(self) -> None: 14 | """This method checks that a value is valid to create this value object""" 15 | pass 16 | 17 | 18 | @dataclass(frozen=True) 19 | class ValueObject(BaseValueObject, ABC, Generic[V]): 20 | # value objects should validate value with application business rules 21 | value: V 22 | 23 | def to_raw(self) -> V: 24 | return self.value 25 | -------------------------------------------------------------------------------- /src/tactic/domain/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/domain/entities/__init__.py -------------------------------------------------------------------------------- /src/tactic/domain/entities/user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from tactic.domain.value_objects.user import UserId 4 | 5 | 6 | @dataclass 7 | class User: 8 | # your business user model 9 | # you can split it to DBUser (database user with database id) and User (domain business model of user without id) 10 | # if you need 11 | user_id: UserId 12 | -------------------------------------------------------------------------------- /src/tactic/domain/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/domain/services/__init__.py -------------------------------------------------------------------------------- /src/tactic/domain/services/user.py: -------------------------------------------------------------------------------- 1 | from tactic.domain.entities.user import User 2 | from tactic.domain.value_objects.user import UserId 3 | 4 | 5 | class UserService: 6 | # you can do some business logic here, e.g. check the business rules of your app 7 | # but value validation logic should be in VO 8 | 9 | def create(self, user_id: UserId) -> User: 10 | return User(user_id=user_id) 11 | -------------------------------------------------------------------------------- /src/tactic/domain/value_objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/domain/value_objects/__init__.py -------------------------------------------------------------------------------- /src/tactic/domain/value_objects/user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from tactic.domain.common.value_objects.base import ValueObject 4 | 5 | 6 | @dataclass(frozen=True) 7 | class UserId(ValueObject[int]): 8 | value: int 9 | -------------------------------------------------------------------------------- /src/tactic/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/infrastructure/__init__.py -------------------------------------------------------------------------------- /src/tactic/infrastructure/config_loader.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from dataclasses import dataclass 5 | 6 | 7 | @dataclass 8 | class BotConfig: 9 | """Bot config""" 10 | 11 | api_token: str 12 | 13 | 14 | @dataclass 15 | class BaseDBConfig: 16 | """Base Database Connection config""" 17 | 18 | db_host: str 19 | db_name: str 20 | db_user: str 21 | db_pass: str 22 | 23 | def get_connection_url(self) -> str: 24 | return f"postgresql+asyncpg://{self.db_user}:{self.db_pass}@{self.db_host}/{self.db_name}" 25 | 26 | 27 | @dataclass 28 | class DBConfig(BaseDBConfig): 29 | """Database config""" 30 | 31 | 32 | @dataclass 33 | class Config: 34 | """App config""" 35 | 36 | bot: BotConfig 37 | db: DBConfig 38 | 39 | 40 | @dataclass 41 | class AlembicDB(BaseDBConfig): 42 | """ 43 | Alembic database config 44 | 45 | need other user for migrations in production. 46 | """ 47 | 48 | 49 | def load_config() -> Config: 50 | """Get app config""" 51 | 52 | api_token: str = os.environ["API_TOKEN"] 53 | 54 | db_name: str = os.environ["DB_NAME"] 55 | db_user: str = os.environ["DB_USER"] 56 | db_pass: str = os.environ["DB_PASS"] 57 | db_host: str = os.environ["DB_HOST"] 58 | 59 | return Config( 60 | bot=BotConfig(api_token=api_token), 61 | db=DBConfig( 62 | db_pass=db_pass, 63 | db_user=db_user, 64 | db_host=db_host, 65 | db_name=db_name, 66 | ), 67 | ) 68 | 69 | 70 | def load_alembic_settings() -> AlembicDB: 71 | """Get alembic settings""" 72 | 73 | db_name: str = os.environ["DB_NAME"] 74 | db_user: str = os.environ["DB_USER"] 75 | db_pass: str = os.environ["DB_PASS"] 76 | db_host: str = os.environ["DB_HOST"] 77 | 78 | logging.info(db_host) 79 | 80 | return AlembicDB( 81 | db_pass=db_pass, 82 | db_user=db_user, 83 | db_host=db_host, 84 | db_name=db_name, 85 | ) 86 | -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/infrastructure/db/__init__.py -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from typing import AsyncGenerator 4 | 5 | from sqlalchemy.ext.asyncio import ( 6 | AsyncEngine, 7 | create_async_engine, 8 | async_sessionmaker, 9 | AsyncSession, 10 | ) 11 | 12 | from tactic.infrastructure.config_loader import DBConfig 13 | 14 | 15 | async def get_engine(settings: DBConfig) -> AsyncGenerator[AsyncEngine, None]: 16 | """Get async SA engine""" 17 | 18 | engine = create_async_engine( 19 | settings.get_connection_url(), 20 | future=True, 21 | ) 22 | 23 | logging.info("Engine is created.") 24 | 25 | yield engine 26 | 27 | await engine.dispose() 28 | 29 | logging.info("Engine is disposed.") 30 | 31 | 32 | async def get_async_sessionmaker( 33 | engine: AsyncEngine, 34 | ) -> async_sessionmaker[AsyncSession]: 35 | """Get async SA sessionmaker""" 36 | 37 | session_factory = async_sessionmaker( 38 | engine, expire_on_commit=False, class_=AsyncSession 39 | ) 40 | 41 | return session_factory 42 | -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/infrastructure/db/migrations/__init__.py -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from logging.config import fileConfig 4 | 5 | from sqlalchemy import pool 6 | 7 | from alembic import context 8 | from sqlalchemy.engine import Connection 9 | from sqlalchemy.ext.asyncio import async_engine_from_config 10 | 11 | from tactic.infrastructure.db.models import Base 12 | from tactic.infrastructure.config_loader import load_alembic_settings 13 | 14 | # this is the Alembic Config object, which provides 15 | # access to the values within the .ini file in use. 16 | config = context.config 17 | 18 | # Interpret the config file for Python logging. 19 | # This line sets up loggers basically. 20 | if config.config_file_name is not None: 21 | fileConfig(config.config_file_name) 22 | 23 | # add your model's MetaData object here 24 | # for 'autogenerate' support 25 | # from myapp import mymodel 26 | # target_metadata = mymodel.Base.metadata 27 | target_metadata = Base.metadata 28 | 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def get_url() -> str: 37 | settings = load_alembic_settings() 38 | 39 | return settings.get_connection_url() 40 | 41 | 42 | def run_migrations_offline() -> None: 43 | """Run migrations in 'offline' mode. 44 | 45 | This configures the context with just a URL 46 | and not an Engine, though an Engine is acceptable 47 | here as well. By skipping the Engine creation 48 | we don't even need a DBAPI to be available. 49 | 50 | Calls to context.execute() here emit the given string to the 51 | script output. 52 | 53 | """ 54 | url = get_url() 55 | context.configure( 56 | url=url, 57 | target_metadata=target_metadata, 58 | literal_binds=True, 59 | dialect_opts={"paramstyle": "named"}, 60 | ) 61 | 62 | with context.begin_transaction(): 63 | context.run_migrations() 64 | 65 | 66 | def do_run_migrations(connection: Connection) -> None: 67 | context.configure(connection=connection, target_metadata=target_metadata) 68 | 69 | with context.begin_transaction(): 70 | context.run_migrations() 71 | 72 | 73 | async def run_async_migrations() -> None: 74 | """ 75 | In this scenario we need to create an Engine 76 | and associate a connection with the context. 77 | """ 78 | 79 | configuration = config.get_section(config.config_ini_section) 80 | configuration["sqlalchemy.url"] = get_url() # type:ignore 81 | 82 | connectable = async_engine_from_config( 83 | configuration, # type:ignore 84 | prefix="sqlalchemy.", 85 | poolclass=pool.NullPool, 86 | ) 87 | 88 | async with connectable.connect() as connection: 89 | await connection.run_sync(do_run_migrations) 90 | 91 | await connectable.dispose() 92 | 93 | 94 | def run_migrations_online() -> None: 95 | """Run migrations in 'online' mode.""" 96 | 97 | asyncio.run(run_async_migrations()) 98 | 99 | 100 | if context.is_offline_mode(): 101 | run_migrations_offline() 102 | else: 103 | run_migrations_online() 104 | -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/migrations/versions/8c86aabd6160_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 8c86aabd6160 4 | Revises: 5 | Create Date: 2023-08-31 18:50:06.638684 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "8c86aabd6160" 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "users", 25 | sa.Column("user_id", sa.BigInteger(), autoincrement=False, nullable=False), 26 | sa.PrimaryKeyConstraint("user_id"), 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table("users") 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/migrations/versions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/infrastructure/db/migrations/versions/__init__.py -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase 2 | 3 | from sqlalchemy import BigInteger 4 | 5 | from tactic.domain.value_objects.user import UserId 6 | 7 | 8 | class Base(DeclarativeBase): 9 | pass 10 | 11 | 12 | class User(Base): 13 | __tablename__ = "users" 14 | 15 | user_id: Mapped[UserId] = mapped_column( 16 | BigInteger, primary_key=True, autoincrement=False 17 | ) 18 | -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/infrastructure/db/repositories/__init__.py -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/repositories/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy import select, exists 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from tactic.infrastructure.db import models 7 | 8 | from tactic.application.common.repositories import UserRepository 9 | 10 | from tactic.domain.entities.user import User 11 | from tactic.domain.value_objects.user import UserId 12 | 13 | 14 | class UserRepositoryImpl(UserRepository): 15 | """Database abstraction layer""" 16 | 17 | # if you implement some reading logic there 18 | # you can use converters for converting ORM objects to domain entities 19 | # your repository methods must return domain entities, not ORM objects. 20 | # I cant show it there, because this demo user model has not additional fields like age, name 21 | # and here is not read logic 22 | 23 | def __init__(self, session: AsyncSession): 24 | self.session = session 25 | 26 | async def create(self, user: User) -> User: 27 | db_user = models.User( 28 | user_id=user.user_id.to_raw(), 29 | ) 30 | 31 | self.session.add(db_user) 32 | 33 | return user 34 | 35 | async def exists(self, user_id: UserId) -> bool: 36 | q = select(exists().where(models.User.user_id == user_id.to_raw())) 37 | 38 | res = await self.session.execute(q) 39 | 40 | is_exists: Optional[bool] = res.scalar() 41 | 42 | if not is_exists: 43 | is_exists = False 44 | 45 | return is_exists 46 | -------------------------------------------------------------------------------- /src/tactic/infrastructure/db/uow.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from tactic.application.common.uow import UnitOfWork 4 | 5 | 6 | class SQLAlchemyUoW(UnitOfWork): 7 | """SQLAlchemy UnitOfWork implementation""" 8 | 9 | def __init__(self, session: AsyncSession) -> None: 10 | self._session = session 11 | 12 | async def commit(self) -> None: 13 | await self._session.commit() 14 | 15 | async def rollback(self) -> None: 16 | await self._session.rollback() 17 | -------------------------------------------------------------------------------- /src/tactic/presentation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/presentation/__init__.py -------------------------------------------------------------------------------- /src/tactic/presentation/bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from aiogram import Bot, Dispatcher 5 | 6 | from aiogram.fsm.storage.redis import ( 7 | RedisStorage, 8 | DefaultKeyBuilder, 9 | RedisEventIsolation, 10 | ) 11 | 12 | from aiogram_dialog import setup_dialogs 13 | 14 | from tactic.infrastructure.config_loader import load_config 15 | from tactic.infrastructure.db.main import get_async_sessionmaker, get_engine 16 | 17 | from tactic.presentation.telegram import register_handlers, register_dialogs 18 | 19 | from tactic.presentation.ioc import IoC 20 | 21 | 22 | async def main() -> None: 23 | logging.basicConfig(level=logging.INFO) 24 | 25 | logger = logging.getLogger("sqlalchemy.engine") 26 | logger.setLevel(logging.INFO) 27 | 28 | config = load_config() 29 | 30 | engine_factory = get_engine(config.db) 31 | engine = await anext(engine_factory) 32 | 33 | session_factory = await get_async_sessionmaker(engine) 34 | 35 | ioc = IoC(session_factory=session_factory) 36 | token = config.bot.api_token 37 | bot = Bot(token=token, parse_mode="html") 38 | 39 | storage: RedisStorage = RedisStorage.from_url( 40 | "redis://bot_redis:6379", key_builder=DefaultKeyBuilder(with_destiny=True) 41 | ) 42 | dp = Dispatcher( 43 | storage=storage, 44 | events_isolation=RedisEventIsolation(redis=storage.redis), 45 | ioc=ioc, 46 | ) 47 | 48 | register_handlers(dp) 49 | register_dialogs(dp) 50 | 51 | setup_dialogs(dp) 52 | 53 | try: 54 | await dp.start_polling(bot) 55 | finally: 56 | logging.info("Shutdown..") 57 | 58 | try: 59 | await anext(engine_factory) 60 | except StopAsyncIteration: 61 | logging.info("Exited") 62 | 63 | 64 | if __name__ == "__main__": 65 | asyncio.run(main()) 66 | -------------------------------------------------------------------------------- /src/tactic/presentation/interactor_factory.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | from typing import AsyncContextManager 4 | 5 | from tactic.application.create_user import CreateUser 6 | 7 | 8 | class InteractorFactory(ABC): 9 | @abstractmethod 10 | def create_user(self) -> AsyncContextManager[CreateUser]: 11 | raise NotImplementedError 12 | -------------------------------------------------------------------------------- /src/tactic/presentation/ioc.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | from typing import AsyncIterator 3 | 4 | from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession 5 | 6 | from tactic.application.create_user import CreateUser 7 | 8 | from tactic.domain.services.user import UserService 9 | 10 | from tactic.infrastructure.db.repositories.user import UserRepositoryImpl 11 | from tactic.infrastructure.db.uow import SQLAlchemyUoW 12 | 13 | from tactic.presentation.interactor_factory import InteractorFactory 14 | 15 | 16 | class IoC(InteractorFactory): 17 | _session_factory: async_sessionmaker[AsyncSession] 18 | 19 | def __init__(self, session_factory: async_sessionmaker[AsyncSession]): 20 | self._session_factory = session_factory 21 | 22 | @asynccontextmanager 23 | async def create_user(self) -> AsyncIterator[CreateUser]: 24 | async with self._session_factory() as session: 25 | uow = SQLAlchemyUoW(session) 26 | repo = UserRepositoryImpl(session) 27 | 28 | yield CreateUser( 29 | repository=repo, 30 | uow=uow, 31 | user_service=UserService(), 32 | ) 33 | -------------------------------------------------------------------------------- /src/tactic/presentation/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.filters import Command 3 | 4 | from .new_user.dialog import new_user_dialog 5 | from .new_user.dialog import user_start 6 | 7 | 8 | def register_handlers(dp: Dispatcher) -> None: 9 | """Register all client-side handlers""" 10 | 11 | dp.message.register(user_start, Command(commands="start")) 12 | 13 | 14 | def register_dialogs(dp: Dispatcher) -> None: 15 | dp.include_router(new_user_dialog) 16 | 17 | 18 | __all__ = [ 19 | "register_handlers", 20 | "register_dialogs", 21 | ] 22 | -------------------------------------------------------------------------------- /src/tactic/presentation/telegram/assets/start.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/presentation/telegram/assets/start.gif -------------------------------------------------------------------------------- /src/tactic/presentation/telegram/new_user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lubaskinc0de/tactic/e2ef7465133822a83929a47287f23418be505824/src/tactic/presentation/telegram/new_user/__init__.py -------------------------------------------------------------------------------- /src/tactic/presentation/telegram/new_user/dialog.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from aiogram.enums import ContentType 4 | from aiogram.types import Message 5 | 6 | from aiogram_dialog import Dialog, Window, DialogManager, StartMode 7 | from aiogram_dialog.widgets.kbd import Toggle 8 | from aiogram_dialog.widgets.media import StaticMedia 9 | 10 | from aiogram_dialog.widgets.text import Format 11 | 12 | from tactic.application.create_user import UserInputDTO, UserOutputDTO 13 | from tactic.domain.value_objects.user import UserId 14 | 15 | from tactic.presentation.interactor_factory import InteractorFactory 16 | from tactic.presentation.telegram import states 17 | 18 | OPTIONS_KEY = "options" 19 | 20 | 21 | async def user_start( 22 | message: Message, ioc: InteractorFactory, dialog_manager: DialogManager 23 | ) -> None: 24 | async with ioc.create_user() as create_user: 25 | user_data: UserOutputDTO = await create_user( 26 | UserInputDTO( 27 | user_id=UserId(message.from_user.id), # type:ignore 28 | ) 29 | ) 30 | 31 | await dialog_manager.start( 32 | states.NewUser.user_id, 33 | mode=StartMode.RESET_STACK, 34 | data={ 35 | "user_id": user_data.user_id.to_raw(), 36 | }, 37 | ) 38 | 39 | 40 | async def window_getter( 41 | dialog_manager: DialogManager, **_kwargs: dict[str, Any] 42 | ) -> dict[str, UserId | str | Any]: 43 | return { 44 | "user_id": dialog_manager.start_data.get("user_id"), 45 | OPTIONS_KEY: ["Пинг!", "Понг!"] 46 | } 47 | 48 | 49 | new_user_dialog = Dialog( 50 | Window( 51 | StaticMedia( 52 | path="/app/src/tactic/presentation/telegram/assets/start.gif", 53 | type=ContentType.ANIMATION, 54 | ), 55 | Format("👋 Привет! Твой айди:\n> {user_id}"), 56 | Toggle( 57 | text=Format("{item}"), 58 | id="ping_pong", 59 | items=OPTIONS_KEY, 60 | item_id_getter=lambda item: item, 61 | ), 62 | getter=window_getter, 63 | state=states.NewUser.user_id, 64 | ), 65 | ) 66 | -------------------------------------------------------------------------------- /src/tactic/presentation/telegram/states.py: -------------------------------------------------------------------------------- 1 | from aiogram.fsm.state import StatesGroup, State 2 | 3 | 4 | class NewUser(StatesGroup): 5 | user_id = State() 6 | --------------------------------------------------------------------------------