├── .env.example ├── .env.test ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── alembic.ini ├── app ├── __init__.py ├── config.py ├── domain │ ├── __init__.py │ ├── access_levels │ │ ├── __init__.py │ │ ├── access_policy.py │ │ ├── dto │ │ │ ├── __init__.py │ │ │ └── access_level.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ └── access_levels.py │ │ ├── interfaces │ │ │ ├── __init__.py │ │ │ ├── persistence.py │ │ │ └── uow.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── access_level.py │ │ │ └── helper.py │ │ └── usecases │ │ │ ├── __init__.py │ │ │ └── access_levels.py │ ├── base │ │ ├── __init__.py │ │ ├── dto │ │ │ ├── __init__.py │ │ │ └── base.py │ │ ├── events │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── dispatcher.py │ │ │ ├── event.py │ │ │ ├── middleware.py │ │ │ └── observer.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ └── repo.py │ │ ├── interfaces │ │ │ ├── __init__.py │ │ │ └── uow.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── aggregate.py │ │ │ ├── entity.py │ │ │ └── value_object.py │ │ └── usecases │ │ │ └── __init__.py │ ├── goods │ │ ├── __init__.py │ │ ├── access_policy.py │ │ ├── dto │ │ │ ├── __init__.py │ │ │ └── goods.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ └── goods.py │ │ ├── interfaces │ │ │ ├── __init__.py │ │ │ ├── persistence.py │ │ │ └── uow.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── goods.py │ │ │ └── goods_type.py │ │ └── usecases │ │ │ ├── __init__.py │ │ │ └── goods.py │ ├── market │ │ ├── __init__.py │ │ ├── access_policy.py │ │ ├── dto │ │ │ ├── __init__.py │ │ │ └── market.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ └── market.py │ │ ├── interfaces │ │ │ ├── __init__.py │ │ │ ├── persistence.py │ │ │ └── uow.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── market.py │ │ └── usecases │ │ │ ├── __init__.py │ │ │ └── market.py │ ├── order │ │ ├── __init__.py │ │ ├── access_policy.py │ │ ├── dto │ │ │ ├── __init__.py │ │ │ ├── goods.py │ │ │ ├── market.py │ │ │ ├── order.py │ │ │ └── user.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ └── order.py │ │ ├── interfaces │ │ │ ├── __init__.py │ │ │ ├── persistence.py │ │ │ └── uow.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── goods.py │ │ │ ├── market.py │ │ │ ├── order.py │ │ │ └── user.py │ │ ├── usecases │ │ │ ├── __init__.py │ │ │ └── order.py │ │ └── value_objects │ │ │ ├── __init__.py │ │ │ ├── confirmed_status.py │ │ │ └── goods_type.py │ └── user │ │ ├── __init__.py │ │ ├── access_policy.py │ │ ├── dto │ │ ├── __init__.py │ │ └── user.py │ │ ├── exceptions │ │ ├── __init__.py │ │ └── user.py │ │ ├── interfaces │ │ ├── __init__.py │ │ ├── persistence.py │ │ └── uow.py │ │ ├── models │ │ ├── __init__.py │ │ └── user.py │ │ └── usecases │ │ ├── __init__.py │ │ └── user.py ├── infrastructure │ ├── __init__.py │ ├── database │ │ ├── __init__.py │ │ ├── alembic │ │ │ ├── README │ │ │ ├── env.py │ │ │ ├── script.py.mako │ │ │ └── versions │ │ │ │ ├── 9dfdf7059df2_order_creator_id_bigint.py │ │ │ │ ├── c82ae5449675_init.py │ │ │ │ └── eb19178d8727_unique_market_name.py │ │ ├── db.py │ │ ├── exception_mapper.py │ │ ├── import_from_csv.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── goods.py │ │ │ ├── map.py │ │ │ ├── market.py │ │ │ ├── order.py │ │ │ └── user.py │ │ ├── repositories │ │ │ ├── __init__.py │ │ │ ├── access_level.py │ │ │ ├── goods.py │ │ │ ├── market.py │ │ │ ├── order.py │ │ │ ├── repo.py │ │ │ └── user.py │ │ └── uow.py │ └── exporters │ │ ├── __init__.py │ │ └── orders_csv.py └── tgbot │ ├── __init__.py │ ├── __main__.py │ ├── constants.py │ ├── event_handlers │ ├── __init__.py │ ├── order.py │ └── setup_middlewares.py │ ├── filters │ ├── __init__.py │ └── access_level.py │ ├── handlers │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── goods │ │ │ ├── __init__.py │ │ │ ├── add.py │ │ │ ├── common.py │ │ │ ├── edit.py │ │ │ ├── menu.py │ │ │ └── setup.py │ │ ├── market │ │ │ ├── __init__.py │ │ │ ├── add.py │ │ │ ├── edit.py │ │ │ ├── menu.py │ │ │ └── setup.py │ │ ├── menu.py │ │ ├── setup.py │ │ └── user │ │ │ ├── __init__.py │ │ │ ├── add.py │ │ │ ├── common.py │ │ │ ├── delete.py │ │ │ ├── edit.py │ │ │ ├── menu.py │ │ │ └── setup.py │ ├── chief │ │ ├── __init__.py │ │ ├── order_confirm.py │ │ └── setup.py │ ├── dialogs │ │ ├── __init__.py │ │ └── common.py │ ├── message_templates.py │ ├── setup.py │ └── user │ │ ├── __init__.py │ │ ├── add_order.py │ │ ├── common.py │ │ ├── help_.py │ │ ├── history.py │ │ ├── main_menu.py │ │ ├── setup.py │ │ └── start.py │ ├── middlewares │ ├── __init__.py │ ├── database.py │ ├── services.py │ ├── setup.py │ └── user.py │ ├── services │ ├── __init__.py │ └── set_commands.py │ └── states │ ├── __init__.py │ ├── add_order.py │ ├── admin_menu.py │ ├── goods_db.py │ ├── help_.py │ ├── history.py │ ├── main_menu.py │ ├── market_db.py │ └── user_db.py ├── docker-compose-dev.yml ├── docker-compose-test.yml ├── docker-compose.yml ├── poetry.lock ├── prepare_volumes.sh ├── pyproject.toml ├── pytest.ini ├── redis.conf └── tests ├── __init__.py ├── conftest.py ├── fixtures ├── __init__.py ├── db.py └── repo.py └── infrastructure ├── __init__.py └── repositories ├── __init__.py ├── conftest.py ├── test_access_levels.py ├── test_goods.py ├── test_market.py └── test_order.py /.env.example: -------------------------------------------------------------------------------- 1 | # tg_bot 2 | TG_BOT__TOKEN=123:qwe 3 | TG_BOT__ADMIN_IDS=[123] 4 | TG_BOT__USE_REDIS=true 5 | 6 | # database 7 | DB__HOST=db 8 | DB__PORT=5432 9 | DB__NAME=example_db_name 10 | DB__USER=example_user 11 | DB__PASSWORD=example_password 12 | 13 | # redis 14 | REDIS__HOST=redis 15 | REDIS__DB=13 16 | 17 | # volumes directory, must be outside of project directory in home directory 18 | VOLUMES_DIR=orders_bot_example/volumes/ 19 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # tg_bot 2 | TG_BOT__TOKEN=123 3 | TG_BOT__ADMIN_IDS=[123] 4 | TG_BOT__USE_REDIS=true 5 | 6 | # database 7 | DB__HOST=localhost 8 | DB__PORT=15432 9 | DB__NAME=test 10 | DB__USER=test 11 | DB__PASSWORD=test 12 | 13 | # redis 14 | REDIS__HOST=localhost 15 | REDIS__DB=13 16 | 17 | # volumes directory, must be outside of project directory in home directory 18 | VOLUMES_DIR=orders_bot/test_volumes/ 19 | TEST_VOLUMES_DIR=orders_bot/test_volumes/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | __pycache__/ 3 | .idea 4 | /.env.dev 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim-buster as python-base 2 | 3 | ENV PYTHONUNBUFFERED=1 \ 4 | PYTHONDONTWRITEBYTECODE=1 \ 5 | PIP_NO_CACHE_DIR=off \ 6 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 7 | PIP_DEFAULT_TIMEOUT=100 \ 8 | POETRY_HOME="/opt/poetry" \ 9 | POETRY_VIRTUALENVS_IN_PROJECT=true \ 10 | POETRY_NO_INTERACTION=1 \ 11 | PYSETUP_PATH="/opt/pysetup" \ 12 | VENV_PATH="/opt/pysetup/.venv" 13 | 14 | ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH" 15 | 16 | 17 | FROM python-base as builder-base 18 | RUN apt-get update \ 19 | && apt-get install -y gcc git 20 | 21 | RUN git clone https://github.com/vishnubob/wait-for-it.git 22 | 23 | WORKDIR $PYSETUP_PATH 24 | COPY ./pyproject.toml . 25 | RUN pip install --no-cache-dir --upgrade pip \ 26 | && pip install --no-cache-dir setuptools wheel \ 27 | && pip install --no-cache-dir poetry 28 | 29 | RUN poetry install --no-dev 30 | 31 | FROM python-base as production 32 | COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH 33 | COPY --from=builder-base /wait-for-it /wait-for-it 34 | 35 | WORKDIR app/ 36 | COPY ./alembic.ini /app/alembic.ini 37 | COPY ./app /app/app 38 | CMD ["python", "-m", "app.tgbot"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 darksidecat 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 | .ONESHELL: 2 | 3 | py := poetry run 4 | python := $(py) python 5 | 6 | package_dir := app 7 | tests_dir := tests 8 | 9 | code_dir := $(package_dir) $(tests_dir) 10 | 11 | 12 | define setup_env 13 | $(eval ENV_FILE := $(1)) 14 | @echo " - setup env $(ENV_FILE)" 15 | $(eval include $(1)) 16 | $(eval export) 17 | endef 18 | 19 | .PHONY: reformat 20 | reformat: 21 | $(py) black $(code_dir) 22 | $(py) isort $(code_dir) --profile black --filter-files 23 | 24 | .PHONY: prepare-volumes 25 | prepare-volumes: 26 | $(call setup_env, .env) 27 | bash prepare_volumes.sh 28 | 29 | .PHONY: dev-docker 30 | dev-docker: 31 | docker compose -f=docker-compose-dev.yml --env-file=.env.dev up 32 | 33 | .PHONY: dev-alembic 34 | dev-alembic: 35 | $(call setup_env, .env) 36 | alembic -c alembic.ini upgrade head 37 | 38 | .PHONY: dev-bot 39 | dev-bot: 40 | $(call setup_env, .env.dev) 41 | python -m app.tgbot 42 | 43 | .PHONY: test-docker 44 | test-docker: 45 | docker compose -f=docker-compose-test.yml --env-file=.env.test up 46 | 47 | .PHONY: tests 48 | tests: 49 | $(call setup_env, .env.test) 50 | $(py) pytest $(tests_dir) --rootdir . 51 | 52 | .PHONY: prod 53 | prod: 54 | docker compose -f=docker-compose.yml --env-file=.env up -d 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Orders bot 2 | The bot was created for internal use in the company in order for managers to create orders for marketing materials, for their further processing by the marketing department. 3 | 4 | Released to open source as an example of using aiogram v3, aiogram-dialog and several other technologies. 5 | 6 | ### Used technologies 7 | * Python; 8 | * aiogram v3 (Telegram Bot framework); 9 | * aiogram-dialog (GUI framework for telegram bot); 10 | * Docker and Docker Compose (containerization); 11 | * PostgreSQL (database); 12 | * Redis (persistent storage for some ongoing game data); 13 | * SQLAlchemy (working with database from Python); 14 | * Alembic (database migrations made easy); 15 | * A small piece of DDD ideology (Domain Driven Design); 16 | 17 | ### Project deployment: 18 | 1. Clone the repository: 19 | `git clone https://github.com/darksidecat/orders_bot.git` 20 | 2. Copy `.env.example` to `.env` and fill in the values 21 | 4. Create volumes by running: 22 | `make prepare-volumes` 23 | 5. Up bot and environment by running: 24 | `make prod` 25 | 26 | ### ToDo's 27 | * Coverage critical code with tests; 28 | * Add docker container for database backuping; 29 | * Add few exporting services: CSV, Google Sheets; 30 | * Support for multiple languages (maybe?); -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = app/infrastructure/database/alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to app/infrastructure/database/alembic/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" below. 39 | # version_locations = %(here)s/bar:%(here)s/bat:app/infrastructure/database/alembic/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 44 | # Valid values for version_path_separator are: 45 | # 46 | # version_path_separator = : 47 | # version_path_separator = ; 48 | # version_path_separator = space 49 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 50 | 51 | # the output encoding used when revision files 52 | # are written from script.py.mako 53 | # output_encoding = utf-8 54 | 55 | sqlalchemy.url = driver://user:pass@localhost/dbname 56 | 57 | 58 | [post_write_hooks] 59 | # post_write_hooks defines scripts or Python functions that are run 60 | # on newly generated revision scripts. See the documentation for further 61 | # detail and examples 62 | 63 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 64 | # hooks = black 65 | # black.type = console_scripts 66 | # black.entrypoint = black 67 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 68 | 69 | # Logging configuration 70 | [loggers] 71 | keys = root,sqlalchemy,alembic 72 | 73 | [handlers] 74 | keys = console 75 | 76 | [formatters] 77 | keys = generic 78 | 79 | [logger_root] 80 | level = WARN 81 | handlers = console 82 | qualname = 83 | 84 | [logger_sqlalchemy] 85 | level = WARN 86 | handlers = 87 | qualname = sqlalchemy.engine 88 | 89 | [logger_alembic] 90 | level = INFO 91 | handlers = 92 | qualname = alembic 93 | 94 | [handler_console] 95 | class = StreamHandler 96 | args = (sys.stderr,) 97 | level = NOTSET 98 | formatter = generic 99 | 100 | [formatter_generic] 101 | format = %(levelname)-5.5s [%(name)s] %(message)s 102 | datefmt = %H:%M:%S 103 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/__init__.py -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from pydantic import BaseSettings, validator 4 | 5 | 6 | class DB(BaseSettings): 7 | host: str 8 | port: int 9 | name: str 10 | user: str 11 | password: str 12 | 13 | 14 | class Redis(BaseSettings): 15 | host: str 16 | db: int 17 | 18 | 19 | class TgBot(BaseSettings): 20 | token: str 21 | admin_ids: list[int] 22 | use_redis: bool 23 | 24 | @validator("admin_ids", pre=True, always=True) 25 | def admin_ids_list(cls, v) -> list[int]: 26 | return json.loads(v) 27 | 28 | 29 | class Settings(BaseSettings): 30 | tg_bot: TgBot 31 | db: DB 32 | redis: Redis 33 | 34 | class Config: 35 | env_file = ".env" 36 | env_file_encoding = "utf-8" 37 | env_nested_delimiter = "__" 38 | 39 | 40 | def load_config(env_file=".env") -> Settings: 41 | settings = Settings(_env_file=env_file) 42 | return settings 43 | -------------------------------------------------------------------------------- /app/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/__init__.py -------------------------------------------------------------------------------- /app/domain/access_levels/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/access_levels/__init__.py -------------------------------------------------------------------------------- /app/domain/access_levels/access_policy.py: -------------------------------------------------------------------------------- 1 | from asyncio import Protocol 2 | 3 | 4 | class User(Protocol): 5 | id: int 6 | is_blocked: bool 7 | is_admin: bool 8 | can_confirm_order: bool 9 | 10 | 11 | class AccessLevelsAccessPolicy(Protocol): 12 | def read_access_levels(self) -> bool: 13 | ... 14 | 15 | 16 | class AllowedAccessLevelsPolicy(AccessLevelsAccessPolicy): 17 | def allow(self, *args, **kwargs): 18 | return True 19 | 20 | read_access_levels = allow 21 | 22 | 23 | class UserBasedAccessLevelsAccessPolicy(AccessLevelsAccessPolicy): 24 | def __init__(self, user: User): 25 | self.user = user 26 | 27 | def _is_not_blocked(self): 28 | return not self.user.is_blocked 29 | 30 | def _is_admin(self): 31 | return self.user.is_admin 32 | 33 | read_access_levels = _is_not_blocked 34 | -------------------------------------------------------------------------------- /app/domain/access_levels/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .access_level import AccessLevel 2 | 3 | __all__ = ["AccessLevel"] 4 | -------------------------------------------------------------------------------- /app/domain/access_levels/dto/access_level.py: -------------------------------------------------------------------------------- 1 | from app.domain.access_levels.models.access_level import LevelName 2 | from app.domain.base.dto.base import DTO 3 | 4 | 5 | class AccessLevel(DTO): 6 | id: int 7 | name: LevelName 8 | 9 | def __hash__(self): 10 | return hash((type(self), self.id)) 11 | -------------------------------------------------------------------------------- /app/domain/access_levels/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/access_levels/exceptions/__init__.py -------------------------------------------------------------------------------- /app/domain/access_levels/exceptions/access_levels.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.exceptions.base import AppException 2 | 3 | 4 | class AccessLevelException(AppException): 5 | """Base Exception for AccessLevel""" 6 | 7 | 8 | class AccessLevelNotExist(AccessLevelException): 9 | """Access level with this id not found""" 10 | -------------------------------------------------------------------------------- /app/domain/access_levels/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/access_levels/interfaces/__init__.py -------------------------------------------------------------------------------- /app/domain/access_levels/interfaces/persistence.py: -------------------------------------------------------------------------------- 1 | from asyncio import Protocol 2 | 3 | from app.domain.access_levels import dto 4 | 5 | 6 | class IAccessLevelReader(Protocol): 7 | async def all_access_levels(self) -> list[dto.AccessLevel]: 8 | ... 9 | 10 | async def user_access_levels(self, user_id: int) -> list[dto.access_level]: 11 | ... 12 | -------------------------------------------------------------------------------- /app/domain/access_levels/interfaces/uow.py: -------------------------------------------------------------------------------- 1 | from app.domain.access_levels.interfaces.persistence import IAccessLevelReader 2 | from app.domain.base.interfaces.uow import IUoW 3 | 4 | 5 | class IAccessLevelUoW(IUoW): 6 | access_level_reader: IAccessLevelReader 7 | -------------------------------------------------------------------------------- /app/domain/access_levels/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/access_levels/models/__init__.py -------------------------------------------------------------------------------- /app/domain/access_levels/models/access_level.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | from app.domain.base.models.value_object import value_object 4 | 5 | 6 | @unique 7 | class LevelName(Enum): 8 | BLOCKED = "BLOCKED" 9 | USER = "USER" 10 | ADMINISTRATOR = "ADMINISTRATOR" 11 | CONFIRMATION = "CONFIRMATION" 12 | 13 | 14 | @value_object 15 | class AccessLevel: 16 | id: int 17 | name: LevelName 18 | 19 | def __eq__(self, other): 20 | return self.id == other.id and self.name == other.name 21 | -------------------------------------------------------------------------------- /app/domain/access_levels/models/helper.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Iterable 3 | 4 | from app.domain.access_levels.exceptions.access_levels import AccessLevelNotExist 5 | from app.domain.access_levels.models.access_level import AccessLevel, LevelName 6 | 7 | 8 | class Levels(Enum): 9 | BLOCKED = AccessLevel(id=-1, name=LevelName.BLOCKED) 10 | ADMINISTRATOR = AccessLevel(id=1, name=LevelName.ADMINISTRATOR) 11 | USER = AccessLevel(id=2, name=LevelName.USER) 12 | CONFIRMATION = AccessLevel(id=3, name=LevelName.CONFIRMATION) 13 | 14 | 15 | def id_to_access_levels(level_ids: Iterable[int]): 16 | levels_map = {level.value.id: level.value for level in Levels} 17 | 18 | if not set(level_ids).issubset(levels_map): 19 | not_found_levels = set(level_ids).difference(levels_map) 20 | raise AccessLevelNotExist( 21 | f"Access levels with ids: {not_found_levels} not found" 22 | ) 23 | else: 24 | return list(levels_map[level] for level in level_ids) 25 | 26 | 27 | def name_to_access_levels(level_names: Iterable[LevelName]): 28 | levels_map = {level.value.name: level.value for level in Levels} 29 | 30 | if not set(level_names).issubset(levels_map): 31 | not_found_levels = set(level_names).difference(levels_map) 32 | raise AccessLevelNotExist( 33 | f"Access levels with names: {not_found_levels} not found" 34 | ) 35 | else: 36 | return list(levels_map[level] for level in level_names) 37 | -------------------------------------------------------------------------------- /app/domain/access_levels/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/access_levels/usecases/__init__.py -------------------------------------------------------------------------------- /app/domain/access_levels/usecases/access_levels.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from app.domain.access_levels.access_policy import AccessLevelsAccessPolicy 4 | from app.domain.access_levels.dto.access_level import AccessLevel 5 | from app.domain.access_levels.interfaces.uow import IAccessLevelUoW 6 | from app.domain.base.events.dispatcher import EventDispatcher 7 | from app.domain.base.exceptions.base import AccessDenied 8 | 9 | 10 | class AccessLevelsUseCase: 11 | def __init__(self, uow: IAccessLevelUoW, event_dispatcher: EventDispatcher) -> None: 12 | self.uow = uow 13 | self.event_dispatcher = event_dispatcher 14 | 15 | 16 | class GetAccessLevels(AccessLevelsUseCase): 17 | async def __call__(self) -> List[AccessLevel]: 18 | """ 19 | 20 | Returns: List of AccessLevel 21 | 22 | """ 23 | return await self.uow.access_level_reader.all_access_levels() 24 | 25 | 26 | class GetUserAccessLevels(AccessLevelsUseCase): 27 | async def __call__(self, user_id: int) -> List[AccessLevel]: 28 | """ 29 | Use for getting user access levels 30 | 31 | Args: 32 | user_id: user id 33 | 34 | Returns: List of AccessLevel 35 | 36 | Raises: 37 | UserNotExists - if user not exist 38 | 39 | """ 40 | return await self.uow.access_level_reader.user_access_levels(user_id) 41 | 42 | 43 | class AccessLevelsService: 44 | def __init__( 45 | self, 46 | uow: IAccessLevelUoW, 47 | access_policy: AccessLevelsAccessPolicy, 48 | event_dispatcher: EventDispatcher, 49 | ) -> None: 50 | self.uow = uow 51 | self.access_policy = access_policy 52 | self.event_dispatcher = event_dispatcher 53 | 54 | async def get_access_levels(self) -> List[AccessLevel]: 55 | if not self.access_policy.read_access_levels(): 56 | raise AccessDenied() 57 | return await GetAccessLevels( 58 | uow=self.uow, event_dispatcher=self.event_dispatcher 59 | )() 60 | 61 | async def get_user_access_levels(self, user_id: int) -> List[AccessLevel]: 62 | if not self.access_policy.read_access_levels(): 63 | raise AccessDenied() 64 | return await GetUserAccessLevels( 65 | uow=self.uow, event_dispatcher=self.event_dispatcher 66 | )(user_id=user_id) 67 | -------------------------------------------------------------------------------- /app/domain/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/base/__init__.py -------------------------------------------------------------------------------- /app/domain/base/dto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/base/dto/__init__.py -------------------------------------------------------------------------------- /app/domain/base/dto/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from unittest.mock import sentinel 3 | 4 | from pydantic import BaseModel, Extra 5 | 6 | 7 | class DTO(BaseModel): 8 | class Config: 9 | use_enum_values = False 10 | extra = Extra.forbid 11 | frozen = True 12 | orm_mode = True 13 | 14 | 15 | UNSET: Any = sentinel.UNSET 16 | -------------------------------------------------------------------------------- /app/domain/base/events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/base/events/__init__.py -------------------------------------------------------------------------------- /app/domain/base/events/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Awaitable, Callable, Dict, Union 4 | 5 | from app.domain.base.events.event import Event 6 | from app.domain.base.events.middleware import BaseMiddleware 7 | 8 | NextMiddlewareType = Callable[[Event, Dict[str, Any]], Awaitable[Any]] 9 | MiddlewareType = Union[ 10 | BaseMiddleware, 11 | Callable[[NextMiddlewareType, Event, Dict[str, Any]], Awaitable[Any]], 12 | ] 13 | -------------------------------------------------------------------------------- /app/domain/base/events/dispatcher.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | from app.domain.base.events.event import Event 4 | from app.domain.base.events.observer import Handler, Observer 5 | 6 | 7 | class EventDispatcher: 8 | def __init__(self, **kwargs): 9 | self.domain_events = Observer() 10 | self.notifications = Observer() 11 | self.data = kwargs 12 | 13 | async def publish_events(self, events: List[Event]): 14 | await self.domain_events.notify(events, data=self.data.copy()) 15 | 16 | async def publish_notifications(self, events: List[Event]): 17 | await self.notifications.notify(events, data=self.data.copy()) 18 | 19 | def register_domain_event(self, event_type: Type[Event], handler: Handler): 20 | self.domain_events.register(event_type, handler) 21 | 22 | def register_notify(self, event_type: Type[Event], handler: Handler): 23 | self.notifications.register(event_type, handler) 24 | -------------------------------------------------------------------------------- /app/domain/base/events/event.py: -------------------------------------------------------------------------------- 1 | class Event: 2 | pass 3 | -------------------------------------------------------------------------------- /app/domain/base/events/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import Any, Awaitable, Callable, Dict 5 | 6 | from app.domain.base.events.event import Event 7 | 8 | 9 | class BaseMiddleware(ABC): 10 | @abstractmethod 11 | async def __call__( 12 | self, 13 | handler: Callable[[Event, Dict[str, Any]], Awaitable[Any]], 14 | event: Event, 15 | data: Dict[str, Any], 16 | ) -> Any: 17 | """ 18 | Execute middleware 19 | :param handler: Wrapped handler in middlewares chain 20 | :param event: Incoming event 21 | :param data: Contextual data 22 | :return: :class:`Any` 23 | """ 24 | pass 25 | -------------------------------------------------------------------------------- /app/domain/base/events/observer.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Any, Awaitable, Callable, Dict, List, Type 3 | 4 | from app.domain.base.events.base import MiddlewareType, NextMiddlewareType 5 | from app.domain.base.events.event import Event 6 | 7 | Handler = Callable[[Event, Dict[str, Any]], Awaitable[Any]] 8 | 9 | 10 | class Observer: 11 | def __init__(self): 12 | self.handlers: Dict[Type[Event], List[Handler]] = {} 13 | self.middlewares: List[MiddlewareType] = [] 14 | 15 | async def notify(self, events: List[Event], data: Dict[str, Any]): 16 | for event in events: 17 | handlers = self.handlers.get(type(event), []) 18 | for handler in handlers: 19 | wrapped_handler = self._wrap_middleware(self.middlewares, handler) 20 | await wrapped_handler(event, data) 21 | 22 | def register(self, event_type: Type[Event], handler: Handler): 23 | handlers = self.handlers.setdefault(event_type, []) 24 | handlers.append(handler) 25 | 26 | @classmethod 27 | def _wrap_middleware( 28 | cls, middlewares: List[MiddlewareType], handler: Handler 29 | ) -> NextMiddlewareType: 30 | @functools.wraps(handler) 31 | def mapper(event: Event, data: Dict[str, Any]) -> Any: 32 | return handler(event, data) 33 | 34 | middleware = mapper 35 | for m in reversed(middlewares): 36 | middleware = functools.partial(m, middleware) 37 | return middleware 38 | 39 | def middleware(self, middleware: MiddlewareType): 40 | self.middlewares.append(middleware) 41 | return middleware 42 | -------------------------------------------------------------------------------- /app/domain/base/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/base/exceptions/__init__.py -------------------------------------------------------------------------------- /app/domain/base/exceptions/base.py: -------------------------------------------------------------------------------- 1 | class AppException(Exception): 2 | """Base Exception""" 3 | 4 | 5 | class AccessDenied(AppException): 6 | """Access Denied""" 7 | -------------------------------------------------------------------------------- /app/domain/base/exceptions/repo.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.exceptions.base import AppException 2 | 3 | 4 | class RepositoryError(AppException): 5 | """Base repository error""" 6 | 7 | 8 | class IntegrityViolationError(RepositoryError): 9 | """Violation of constraint""" 10 | -------------------------------------------------------------------------------- /app/domain/base/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/base/interfaces/__init__.py -------------------------------------------------------------------------------- /app/domain/base/interfaces/uow.py: -------------------------------------------------------------------------------- 1 | from asyncio import Protocol 2 | 3 | 4 | class IUoW(Protocol): 5 | async def commit(self) -> None: 6 | ... 7 | 8 | async def rollback(self) -> None: 9 | ... 10 | -------------------------------------------------------------------------------- /app/domain/base/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/base/models/__init__.py -------------------------------------------------------------------------------- /app/domain/base/models/aggregate.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import attrs 4 | 5 | from app.domain.base.events.event import Event 6 | from app.domain.base.models.entity import entity 7 | 8 | 9 | @entity 10 | class Aggregate: 11 | _events: List[Event] = attrs.field(factory=list, repr=False) 12 | 13 | @property 14 | def events(self): 15 | # there is strange bug with attrs and sqlalchemy 16 | # after loading from db, aggregate._events is not present as attribute, 17 | # so we need to create it manually 18 | if not hasattr(self, "_events"): 19 | self._events = [] 20 | return self._events 21 | -------------------------------------------------------------------------------- /app/domain/base/models/entity.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | 3 | entity = define(slots=False, kw_only=True) 4 | -------------------------------------------------------------------------------- /app/domain/base/models/value_object.py: -------------------------------------------------------------------------------- 1 | from attrs import define 2 | 3 | value_object = define( 4 | slots=False, kw_only=True, hash=True 5 | ) # frozen=True break sqlalchemy loading 6 | -------------------------------------------------------------------------------- /app/domain/base/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/base/usecases/__init__.py -------------------------------------------------------------------------------- /app/domain/goods/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/goods/__init__.py -------------------------------------------------------------------------------- /app/domain/goods/access_policy.py: -------------------------------------------------------------------------------- 1 | from asyncio import Protocol 2 | 3 | 4 | class User(Protocol): 5 | id: int 6 | is_blocked: bool 7 | is_admin: bool 8 | can_confirm_order: bool 9 | 10 | 11 | class GoodsAccessPolicy(Protocol): 12 | def read_goods(self) -> bool: 13 | ... 14 | 15 | def modify_goods(self) -> bool: 16 | ... 17 | 18 | 19 | class AllowedGoodsAccessPolicy(GoodsAccessPolicy): 20 | def allow(self, *args, **kwargs): 21 | return True 22 | 23 | read_goods = allow 24 | modify_goods = allow 25 | 26 | 27 | class UserBasedGoodsAccessPolicy(GoodsAccessPolicy): 28 | def __init__(self, user: User): 29 | self.user = user 30 | 31 | def _is_not_blocked(self): 32 | return not self.user.is_blocked 33 | 34 | def _is_admin(self): 35 | return self.user.is_admin 36 | 37 | read_goods = _is_not_blocked 38 | modify_goods = _is_admin 39 | -------------------------------------------------------------------------------- /app/domain/goods/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .goods import Goods, GoodsCreate, GoodsPatch 2 | 3 | __all__ = ["Goods", "GoodsCreate", "GoodsPatch"] 4 | -------------------------------------------------------------------------------- /app/domain/goods/dto/goods.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from uuid import UUID 3 | 4 | from app.domain.base.dto.base import DTO, UNSET 5 | from app.domain.goods.models.goods_type import GoodsType 6 | 7 | 8 | class GoodsCreate(DTO): 9 | name: str 10 | type: GoodsType 11 | parent_id: Optional[UUID] 12 | sku: Optional[str] 13 | 14 | 15 | class GoodsPatch(DTO): 16 | id: UUID 17 | name: Optional[str] = UNSET 18 | sku: Optional[str] = UNSET 19 | is_active: Optional[bool] = UNSET 20 | 21 | 22 | class Goods(DTO): 23 | id: UUID 24 | name: str 25 | type: GoodsType 26 | parent_id: Optional[UUID] 27 | sku: Optional[str] 28 | is_active: bool 29 | 30 | @property 31 | def icon(self): 32 | return "📦" if self.type is GoodsType.GOODS else "📁" 33 | 34 | @property 35 | def active_icon(self): 36 | return "" if self.is_active else "❌" 37 | 38 | @property 39 | def sku_text(self): 40 | return self.sku or "" 41 | -------------------------------------------------------------------------------- /app/domain/goods/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/goods/exceptions/__init__.py -------------------------------------------------------------------------------- /app/domain/goods/exceptions/goods.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.exceptions.base import AppException 2 | 3 | 4 | class GoodsException(AppException): 5 | """Base Goods Exception""" 6 | 7 | 8 | class GoodsAlreadyExists(GoodsException): 9 | """User already exist""" 10 | 11 | 12 | class GoodsNotExists(GoodsException): 13 | """User not exist""" 14 | 15 | 16 | class CantMakeInactiveWithActiveChildren(GoodsException): 17 | """Can't make inactive with active children""" 18 | 19 | 20 | class CantDeleteWithChildren(GoodsException): 21 | """Can't delete with children""" 22 | 23 | 24 | class CantSetFolderSKU(GoodsException): 25 | """Can't set folder SKU""" 26 | 27 | 28 | class CantMakeActiveWithInactiveParent(GoodsException): 29 | """Can't make active with inactive parent""" 30 | 31 | 32 | class GoodsTypeCantBeParent(GoodsException): 33 | """Goods with GoodsType.Goods can't be parent""" 34 | 35 | 36 | class CantSetSKUForFolder(GoodsException): 37 | """Can't set SKU for folder""" 38 | 39 | 40 | class GoodsMustHaveSKU(GoodsException): 41 | """Goods must have SKU""" 42 | 43 | 44 | class CantDeleteWithOrders(GoodsException): 45 | """Can't delete with orders""" 46 | -------------------------------------------------------------------------------- /app/domain/goods/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/goods/interfaces/__init__.py -------------------------------------------------------------------------------- /app/domain/goods/interfaces/persistence.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Protocol 2 | from uuid import UUID 3 | 4 | from app.domain.goods.dto.goods import Goods as GoodsDTO 5 | from app.domain.goods.models.goods import Goods 6 | 7 | 8 | class IGoodsReader(Protocol): 9 | async def goods_in_folder( 10 | self, parent_id: Optional[UUID], only_active: bool 11 | ) -> List[GoodsDTO]: 12 | ... 13 | 14 | async def get_parent_folder(self, child_id: UUID) -> Optional[GoodsDTO]: 15 | ... 16 | 17 | async def goods_by_id(self, goods_id: UUID) -> GoodsDTO: 18 | ... 19 | 20 | 21 | class IGoodsRepo(Protocol): 22 | async def add_goods(self, goods: Goods) -> Goods: 23 | ... 24 | 25 | async def goods_by_id(self, goods_id: UUID) -> Goods: 26 | ... 27 | 28 | async def delete_goods(self, goods_id: UUID) -> None: 29 | ... 30 | 31 | async def edit_goods(self, goods: Goods) -> Goods: 32 | ... 33 | -------------------------------------------------------------------------------- /app/domain/goods/interfaces/uow.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.interfaces.uow import IUoW 2 | from app.domain.goods.interfaces.persistence import IGoodsReader, IGoodsRepo 3 | 4 | 5 | class IGoodsUoW(IUoW): 6 | goods: IGoodsRepo 7 | goods_reader: IGoodsReader 8 | -------------------------------------------------------------------------------- /app/domain/goods/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/goods/models/__init__.py -------------------------------------------------------------------------------- /app/domain/goods/models/goods.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from typing import List, Optional 5 | from uuid import UUID 6 | 7 | import attrs 8 | from attrs import validators 9 | 10 | from app.domain.base.events.event import Event 11 | from app.domain.base.models.aggregate import Aggregate 12 | from app.domain.base.models.entity import entity 13 | from app.domain.goods import dto 14 | from app.domain.goods.exceptions.goods import ( 15 | CantMakeActiveWithInactiveParent, 16 | CantMakeInactiveWithActiveChildren, 17 | CantSetFolderSKU, 18 | ) 19 | from app.domain.goods.models.goods_type import GoodsType 20 | 21 | 22 | @entity 23 | class Goods(Aggregate): 24 | id: UUID = attrs.field(validator=validators.instance_of(UUID), factory=uuid.uuid4) 25 | name: str = attrs.field(validator=validators.instance_of(str)) 26 | type: Optional[GoodsType] = attrs.field( 27 | validator=validators.instance_of(Optional[GoodsType]) 28 | ) 29 | sku: Optional[str] = attrs.field( 30 | validator=validators.instance_of(Optional[str]), default=None 31 | ) 32 | is_active: bool = attrs.field(validator=validators.instance_of(bool), default=True) 33 | 34 | parent: Optional[Goods] = attrs.field(default=None, repr=False) 35 | children: List[Goods] = attrs.field(factory=list, repr=False) 36 | 37 | @classmethod 38 | def create( 39 | cls, 40 | name: str, 41 | type: GoodsType, 42 | parent: Optional[Goods] = None, 43 | sku: str = None, 44 | is_active: bool = True, 45 | ) -> Goods: 46 | goods = Goods( 47 | name=name, 48 | type=type, 49 | parent=parent, 50 | sku=sku, 51 | is_active=is_active, 52 | ) 53 | goods.events.append(GoodsCreated(dto.Goods.from_orm(goods))) 54 | 55 | return goods 56 | 57 | def change_name(self, name: str) -> None: 58 | self.name = name 59 | 60 | def change_sku(self, sku: str) -> None: 61 | if self.type == GoodsType.FOLDER: 62 | raise CantSetFolderSKU() 63 | self.sku = sku 64 | 65 | def change_active_status(self, is_active: bool) -> None: 66 | if is_active is False: 67 | children_statuses = [child.is_active for child in self.children] 68 | if True in children_statuses: 69 | raise CantMakeInactiveWithActiveChildren() 70 | 71 | else: 72 | if self.parent and self.parent.is_active is False: 73 | raise CantMakeActiveWithInactiveParent() 74 | 75 | self.is_active = is_active 76 | 77 | 78 | class GoodsCreated(Event): 79 | def __init__(self, goods: dto.Goods): 80 | self.goods = goods 81 | -------------------------------------------------------------------------------- /app/domain/goods/models/goods_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class GoodsType(Enum): 5 | GOODS = "GOODS" 6 | FOLDER = "FOLDER" 7 | -------------------------------------------------------------------------------- /app/domain/goods/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/goods/usecases/__init__.py -------------------------------------------------------------------------------- /app/domain/goods/usecases/goods.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | from pydantic import parse_obj_as 7 | 8 | from app.domain.base.dto.base import UNSET 9 | from app.domain.base.events.dispatcher import EventDispatcher 10 | from app.domain.base.exceptions.base import AccessDenied 11 | from app.domain.goods import dto 12 | from app.domain.goods.access_policy import GoodsAccessPolicy 13 | from app.domain.goods.exceptions.goods import ( 14 | CantDeleteWithChildren, 15 | GoodsAlreadyExists, 16 | GoodsNotExists, 17 | ) 18 | from app.domain.goods.interfaces.uow import IGoodsUoW 19 | from app.domain.goods.models.goods import Goods 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class GoodsUseCase(ABC): 25 | def __init__(self, uow: IGoodsUoW, event_dispatcher: EventDispatcher) -> None: 26 | self.uow = uow 27 | self.event_dispatcher = event_dispatcher 28 | 29 | 30 | class AddGoods(GoodsUseCase): 31 | async def __call__(self, goods: dto.GoodsCreate) -> dto.Goods: 32 | """ 33 | Args: 34 | goods: payload for user creation 35 | 36 | Returns: 37 | created goods 38 | Raises: 39 | GoodsAlreadyExists - if user already exist 40 | """ 41 | try: 42 | parent = await self.uow.goods.goods_by_id(goods.parent_id) 43 | except GoodsNotExists: 44 | parent = None 45 | goods = Goods.create( 46 | name=goods.name, type=goods.type, parent=parent, sku=goods.sku 47 | ) 48 | 49 | try: 50 | goods = await self.uow.goods.add_goods(goods=goods) 51 | 52 | await self.event_dispatcher.publish_events(goods.events) 53 | await self.uow.commit() 54 | await self.event_dispatcher.publish_notifications(goods.events) 55 | goods.events.clear() 56 | 57 | logger.info("Goods persisted: id=%s, %s", goods.id, goods) 58 | except GoodsAlreadyExists: 59 | logger.error("Goods already exists: %s", goods) 60 | await self.uow.rollback() 61 | raise 62 | 63 | return dto.Goods.from_orm(goods) 64 | 65 | 66 | class GetGoodsInFolder(GoodsUseCase): 67 | async def __call__( 68 | self, parent_id: Optional[UUID], only_active: bool 69 | ) -> list[dto.Goods]: 70 | goods = await self.uow.goods_reader.goods_in_folder( 71 | parent_id=parent_id, only_active=only_active 72 | ) 73 | return parse_obj_as(List[dto.Goods], goods) 74 | 75 | 76 | class GetGoods(GoodsUseCase): 77 | async def __call__(self, goods_id: Optional[UUID]) -> dto.Goods: 78 | goods = await self.uow.goods_reader.goods_by_id(goods_id) 79 | return dto.Goods.from_orm(goods) 80 | 81 | 82 | class DeleteGoods(GoodsUseCase): 83 | async def __call__(self, goods_id: Optional[UUID]) -> None: 84 | try: 85 | await self.uow.goods.delete_goods(goods_id) 86 | except CantDeleteWithChildren: 87 | await self.uow.rollback() 88 | raise 89 | await self.uow.commit() 90 | 91 | 92 | class PatchGoods(GoodsUseCase): 93 | async def __call__(self, patch_goods_data: dto.GoodsPatch) -> dto.Goods: 94 | goods = await self.uow.goods.goods_by_id(patch_goods_data.id) 95 | 96 | if patch_goods_data.name is not UNSET: 97 | goods.change_name(patch_goods_data.name) 98 | if patch_goods_data.sku is not UNSET: 99 | goods.change_sku(patch_goods_data.sku) 100 | if patch_goods_data.is_active is not UNSET: 101 | goods.change_active_status(patch_goods_data.is_active) 102 | 103 | await self.uow.goods.edit_goods(goods=goods) 104 | await self.uow.commit() 105 | return dto.Goods.from_orm(goods) 106 | 107 | 108 | class GoodsService: 109 | def __init__( 110 | self, 111 | uow: IGoodsUoW, 112 | access_policy: GoodsAccessPolicy, 113 | event_dispatcher: EventDispatcher, 114 | ) -> None: 115 | self.uow = uow 116 | self.access_policy = access_policy 117 | self.event_dispatcher = event_dispatcher 118 | 119 | async def add_goods(self, goods: dto.GoodsCreate) -> dto.Goods: 120 | if not self.access_policy.modify_goods(): 121 | raise AccessDenied() 122 | return await AddGoods(uow=self.uow, event_dispatcher=self.event_dispatcher)( 123 | goods=goods 124 | ) 125 | 126 | async def get_goods_by_id(self, goods_id: UUID) -> dto.Goods: 127 | if not self.access_policy.read_goods(): 128 | raise AccessDenied() 129 | return await GetGoods(uow=self.uow, event_dispatcher=self.event_dispatcher)( 130 | goods_id=goods_id 131 | ) 132 | 133 | async def get_goods_in_folder( 134 | self, parent_id: Optional[UUID], only_active: bool 135 | ) -> list[dto.Goods]: 136 | if not self.access_policy.read_goods(): 137 | raise AccessDenied() 138 | return await GetGoodsInFolder( 139 | uow=self.uow, event_dispatcher=self.event_dispatcher 140 | )(parent_id=parent_id, only_active=only_active) 141 | 142 | async def delete_goods(self, goods_id: UUID) -> None: 143 | if not self.access_policy.modify_goods(): 144 | raise AccessDenied() 145 | return await DeleteGoods(uow=self.uow, event_dispatcher=self.event_dispatcher)( 146 | goods_id=goods_id 147 | ) 148 | 149 | async def patch_goods(self, patch_goods_data: dto.GoodsPatch) -> dto.Goods: 150 | if not self.access_policy.modify_goods(): 151 | raise AccessDenied() 152 | return await PatchGoods(uow=self.uow, event_dispatcher=self.event_dispatcher)( 153 | patch_goods_data=patch_goods_data 154 | ) 155 | -------------------------------------------------------------------------------- /app/domain/market/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/market/__init__.py -------------------------------------------------------------------------------- /app/domain/market/access_policy.py: -------------------------------------------------------------------------------- 1 | from asyncio import Protocol 2 | 3 | 4 | class User(Protocol): 5 | id: int 6 | is_blocked: bool 7 | is_admin: bool 8 | can_confirm_order: bool 9 | 10 | 11 | class MarketAccessPolicy1(Protocol): 12 | def read_markets(self) -> bool: 13 | ... 14 | 15 | def modify_markets(self) -> bool: 16 | ... 17 | 18 | 19 | class AllowedMarketAccessPolicy(MarketAccessPolicy1): 20 | def allow(self, *args, **kwargs): 21 | return True 22 | 23 | read_markets = allow 24 | modify_markets = allow 25 | 26 | 27 | class UserBasedMarketAccessPolicy(MarketAccessPolicy1): 28 | def __init__(self, user: User): 29 | self.user = user 30 | 31 | def _is_not_blocked(self): 32 | return not self.user.is_blocked 33 | 34 | def _is_admin(self): 35 | return self.user.is_admin 36 | 37 | read_markets = _is_not_blocked 38 | modify_markets = _is_admin 39 | -------------------------------------------------------------------------------- /app/domain/market/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .market import Market, MarketCreate, MarketPatch 2 | 3 | __all__ = [ 4 | "Market", 5 | "MarketCreate", 6 | "MarketPatch", 7 | ] 8 | -------------------------------------------------------------------------------- /app/domain/market/dto/market.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from uuid import UUID 3 | 4 | from app.domain.base.dto.base import DTO, UNSET 5 | 6 | 7 | class MarketCreate(DTO): 8 | name: str 9 | 10 | 11 | class MarketPatch(DTO): 12 | id: UUID 13 | name: Optional[str] = UNSET 14 | is_active: Optional[bool] = UNSET 15 | 16 | 17 | class Market(DTO): 18 | id: UUID 19 | name: str 20 | is_active: bool 21 | 22 | @property 23 | def active_icon(self): 24 | return "" if self.is_active else "❌" 25 | -------------------------------------------------------------------------------- /app/domain/market/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/market/exceptions/__init__.py -------------------------------------------------------------------------------- /app/domain/market/exceptions/market.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.exceptions.base import AppException 2 | 3 | 4 | class MarketException(AppException): 5 | """Base Market Exception""" 6 | 7 | 8 | class MarketNotExists(MarketException): 9 | """Market not exist""" 10 | 11 | 12 | class CantDeleteWithOrders(MarketException): 13 | """Can't delete market with orders""" 14 | 15 | 16 | class MarketAlreadyExists(MarketException): 17 | """Market already exists""" 18 | -------------------------------------------------------------------------------- /app/domain/market/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/market/interfaces/__init__.py -------------------------------------------------------------------------------- /app/domain/market/interfaces/persistence.py: -------------------------------------------------------------------------------- 1 | from typing import List, Protocol 2 | from uuid import UUID 3 | 4 | from app.domain.market.dto.market import Market as MarketDTO 5 | from app.domain.market.models.market import Market 6 | 7 | 8 | class IMarketReader(Protocol): 9 | async def market_by_id(self, market_id: UUID) -> MarketDTO: 10 | ... 11 | 12 | async def all_markets(self, only_active: bool) -> List[MarketDTO]: 13 | ... 14 | 15 | 16 | class IMarketRepo(Protocol): 17 | async def add_market(self, market: Market) -> Market: 18 | ... 19 | 20 | async def market_by_id(self, market_id: UUID) -> Market: 21 | ... 22 | 23 | async def delete_market(self, market_id: UUID) -> None: 24 | ... 25 | 26 | async def edit_market(self, market: Market) -> Market: 27 | ... 28 | -------------------------------------------------------------------------------- /app/domain/market/interfaces/uow.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.interfaces.uow import IUoW 2 | from app.domain.market.interfaces.persistence import IMarketReader, IMarketRepo 3 | 4 | 5 | class IMarketUoW(IUoW): 6 | market: IMarketRepo 7 | market_reader: IMarketReader 8 | -------------------------------------------------------------------------------- /app/domain/market/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/market/models/__init__.py -------------------------------------------------------------------------------- /app/domain/market/models/market.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from uuid import UUID 5 | 6 | import attrs 7 | from attrs import validators 8 | 9 | from app.domain.base.events.event import Event 10 | from app.domain.base.models.aggregate import Aggregate 11 | from app.domain.base.models.entity import entity 12 | from app.domain.market import dto 13 | 14 | 15 | @entity 16 | class Market(Aggregate): 17 | id: UUID = attrs.field(validator=validators.instance_of(UUID), factory=uuid.uuid4) 18 | name: str = attrs.field(validator=validators.instance_of(str)) 19 | is_active: bool = attrs.field(validator=validators.instance_of(bool), default=True) 20 | 21 | @classmethod 22 | def create(cls, name: str) -> Market: 23 | market = Market(name=name) 24 | market.events.append(MarketCreated(dto.Market.from_orm(market))) 25 | 26 | return market 27 | 28 | def __eq__(self, other): 29 | return self.id == other.id 30 | 31 | 32 | class MarketCreated(Event): 33 | def __init__(self, market: dto.Market): 34 | self.market = market 35 | -------------------------------------------------------------------------------- /app/domain/market/usecases/__init__.py: -------------------------------------------------------------------------------- 1 | from .market import MarketService 2 | 3 | __all__ = ["MarketService"] 4 | -------------------------------------------------------------------------------- /app/domain/market/usecases/market.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC 3 | from typing import List 4 | from uuid import UUID 5 | 6 | from app.domain.base.dto.base import UNSET 7 | from app.domain.base.events.dispatcher import EventDispatcher 8 | from app.domain.base.exceptions.base import AccessDenied 9 | from app.domain.market import dto 10 | from app.domain.market.access_policy import UserBasedMarketAccessPolicy 11 | from app.domain.market.exceptions.market import ( 12 | CantDeleteWithOrders, 13 | MarketAlreadyExists, 14 | ) 15 | from app.domain.market.interfaces.uow import IMarketUoW 16 | from app.domain.market.models.market import Market 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class MarketUseCase(ABC): 22 | def __init__(self, uow: IMarketUoW, event_dispatcher: EventDispatcher) -> None: 23 | self.uow = uow 24 | self.event_dispatcher = event_dispatcher 25 | 26 | 27 | class GetAllMarkets(MarketUseCase): 28 | async def __call__(self, only_active: bool) -> List[dto.Market]: 29 | markets = await self.uow.market_reader.all_markets(only_active=only_active) 30 | return [dto.Market.from_orm(market) for market in markets] 31 | 32 | 33 | class GetMarket(MarketUseCase): 34 | async def __call__(self, market_id: UUID) -> dto.Market: 35 | market = await self.uow.market_reader.market_by_id(market_id) 36 | return market 37 | 38 | 39 | class AddMarket(MarketUseCase): 40 | async def __call__(self, market: dto.MarketCreate) -> Market: 41 | 42 | market = Market.create(name=market.name) 43 | 44 | try: 45 | market = await self.uow.market.add_market(market) 46 | await self.event_dispatcher.publish_events(market.events) 47 | await self.uow.commit() 48 | await self.event_dispatcher.publish_notifications(market.events) 49 | market.events.clear() 50 | 51 | logger.info("Market persisted: id=%s, %s", market.id, market) 52 | except MarketAlreadyExists: 53 | logger.info("Market already exists: %s", market) 54 | await self.uow.rollback() 55 | raise 56 | 57 | return dto.Market.from_orm(market) 58 | 59 | 60 | class PatchMarket(MarketUseCase): 61 | async def __call__(self, patch_market_data: dto.MarketPatch) -> Market: 62 | market = await self.uow.market.market_by_id(patch_market_data.id) 63 | 64 | if patch_market_data.name is not UNSET: 65 | market.name = patch_market_data.name 66 | if patch_market_data.is_active is not UNSET: 67 | market.is_active = patch_market_data.is_active 68 | 69 | await self.uow.market.edit_market(market) 70 | await self.uow.commit() 71 | 72 | return dto.Market.from_orm(market) 73 | 74 | 75 | class DeleteMarket(MarketUseCase): 76 | async def __call__(self, market_id: UUID) -> None: 77 | try: 78 | await self.uow.market.delete_market(market_id) 79 | await self.uow.commit() 80 | except CantDeleteWithOrders: 81 | await self.uow.rollback() 82 | raise 83 | 84 | 85 | class MarketService: 86 | def __init__( 87 | self, 88 | uow: IMarketUoW, 89 | access_policy: UserBasedMarketAccessPolicy, 90 | event_dispatcher: EventDispatcher, 91 | ) -> None: 92 | self.uow = uow 93 | self.access_policy = access_policy 94 | self.event_dispatcher = event_dispatcher 95 | 96 | async def get_all_markets(self, only_active: bool) -> List[dto.Market]: 97 | if not self.access_policy.read_markets(): 98 | raise AccessDenied() 99 | return await GetAllMarkets(self.uow, self.event_dispatcher)( 100 | only_active=only_active 101 | ) 102 | 103 | async def get_market_by_id(self, market_id: UUID): 104 | if not self.access_policy.read_markets(): 105 | raise AccessDenied() 106 | return await GetMarket(self.uow, self.event_dispatcher)(market_id) 107 | 108 | async def add_market(self, market: dto.MarketCreate) -> Market: 109 | if not self.access_policy.modify_markets(): 110 | raise AccessDenied() 111 | return await AddMarket(self.uow, self.event_dispatcher)(market) 112 | 113 | async def patch_market(self, market: dto.MarketPatch) -> Market: 114 | if not self.access_policy.modify_markets(): 115 | raise AccessDenied() 116 | return await PatchMarket(self.uow, self.event_dispatcher)(market) 117 | 118 | async def delete_market(self, market_id: UUID): 119 | if not self.access_policy.modify_markets(): 120 | raise AccessDenied() 121 | return await DeleteMarket(self.uow, self.event_dispatcher)(market_id) 122 | -------------------------------------------------------------------------------- /app/domain/order/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/order/__init__.py -------------------------------------------------------------------------------- /app/domain/order/access_policy.py: -------------------------------------------------------------------------------- 1 | from asyncio import Protocol 2 | from typing import Optional 3 | 4 | from app.domain.order.dto import Order 5 | 6 | 7 | class User(Protocol): 8 | id: int 9 | is_blocked: bool 10 | is_admin: bool 11 | can_confirm_order: bool 12 | 13 | 14 | class OrderAccessPolicy(Protocol): 15 | def add_orders(self) -> bool: 16 | ... 17 | 18 | def read_order(self, order: Optional[Order]) -> bool: 19 | ... 20 | 21 | def read_all_orders(self) -> bool: 22 | ... 23 | 24 | def read_user_orders(self, user_id: int) -> bool: 25 | ... 26 | 27 | def modify_orders(self) -> bool: 28 | ... 29 | 30 | def confirm_orders(self) -> bool: 31 | ... 32 | 33 | 34 | class AllowedOrderAccessPolicy(OrderAccessPolicy): 35 | def allow(self, *args, **kwargs): 36 | return True 37 | 38 | read_order = allow 39 | read_all_orders = allow 40 | read_user_orders = allow 41 | modify_orders = allow 42 | add_orders = allow 43 | confirm_orders = allow 44 | 45 | 46 | class UserBasedOrderAccessPolicy(OrderAccessPolicy): 47 | def __init__(self, user: User): 48 | self.user = user 49 | 50 | def _is_not_blocked(self): 51 | return not self.user.is_blocked 52 | 53 | def _is_admin(self): 54 | return self.user.is_admin 55 | 56 | modify_orders = _is_admin 57 | add_orders = _is_not_blocked 58 | 59 | def read_order(self, order: Optional[Order]) -> bool: 60 | if self.user.is_admin or self.user.can_confirm_order or not order: 61 | return True 62 | return order.creator.id == self.user.id 63 | 64 | def read_all_orders(self) -> bool: 65 | return self.user.is_admin or self.user.can_confirm_order 66 | 67 | def read_user_orders(self, user_id: int) -> bool: 68 | return self.user.id == user_id 69 | 70 | def confirm_orders(self) -> bool: 71 | return self.user.can_confirm_order 72 | -------------------------------------------------------------------------------- /app/domain/order/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .goods import Goods 2 | from .market import Market 3 | from .order import Order, OrderCreate, OrderLine, OrderLineCreate, OrderMessageCreate 4 | from .user import User 5 | 6 | __all__ = [ 7 | "Order", 8 | "OrderCreate", 9 | "OrderLine", 10 | "OrderLineCreate", 11 | "OrderMessageCreate", 12 | "User", 13 | "Market", 14 | "Goods", 15 | ] 16 | -------------------------------------------------------------------------------- /app/domain/order/dto/goods.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | from uuid import UUID 5 | 6 | from app.domain.base.dto.base import DTO 7 | from app.domain.order.value_objects import GoodsType 8 | 9 | 10 | class Goods(DTO): 11 | id: UUID 12 | name: str 13 | type: GoodsType 14 | sku: Optional[str] 15 | is_active: bool 16 | 17 | @property 18 | def icon(self): 19 | return "📦" if self.type is GoodsType.GOODS else "📁" 20 | 21 | @property 22 | def active_icon(self): 23 | return "" if self.is_active else "❌" 24 | 25 | @property 26 | def sku_text(self): 27 | return self.sku or "" 28 | -------------------------------------------------------------------------------- /app/domain/order/dto/market.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import UUID 4 | 5 | from app.domain.base.dto.base import DTO 6 | 7 | 8 | class Market(DTO): 9 | id: UUID 10 | name: str 11 | is_active: bool 12 | 13 | @property 14 | def active_icon(self): 15 | return "" if self.is_active else "❌" 16 | -------------------------------------------------------------------------------- /app/domain/order/dto/order.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from uuid import UUID 5 | 6 | from app.domain.base.dto.base import DTO 7 | from app.domain.order.dto.goods import Goods 8 | from app.domain.order.dto.market import Market 9 | from app.domain.order.dto.user import User 10 | from app.domain.order.value_objects import ConfirmedStatus, GoodsType 11 | 12 | 13 | class OrderLineCreate(DTO): 14 | goods_id: UUID 15 | goods_type: GoodsType 16 | quantity: int 17 | 18 | 19 | class OrderLine(DTO): 20 | quantity: int 21 | goods: Goods 22 | 23 | 24 | class OrderMessageCreate(DTO): 25 | message_id: int 26 | chat_id: int 27 | 28 | 29 | class OrderMessage(DTO): 30 | message_id: int 31 | chat_id: int 32 | 33 | 34 | class OrderCreate(DTO): 35 | order_lines: list[OrderLineCreate] 36 | creator_id: int 37 | recipient_market_id: UUID 38 | commentary: str 39 | 40 | 41 | class Order(DTO): 42 | id: UUID 43 | order_lines: list[OrderLine] 44 | creator: User 45 | created_at: datetime 46 | recipient_market: Market 47 | commentary: str 48 | confirmed: ConfirmedStatus 49 | order_messages: list[OrderMessage] 50 | 51 | @property 52 | def confirmed_icon(self): 53 | if self.confirmed == ConfirmedStatus.YES: 54 | return "✅" 55 | elif self.confirmed == ConfirmedStatus.NO: 56 | return "❌" 57 | else: 58 | return "" 59 | -------------------------------------------------------------------------------- /app/domain/order/dto/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from app.domain.base.dto.base import DTO 4 | 5 | 6 | class User(DTO): 7 | id: int 8 | name: str 9 | -------------------------------------------------------------------------------- /app/domain/order/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/order/exceptions/__init__.py -------------------------------------------------------------------------------- /app/domain/order/exceptions/order.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.exceptions.base import AppException 2 | 3 | 4 | class OrderException(AppException): 5 | """Base Order Exception""" 6 | 7 | 8 | class OrderAlreadyConfirmed(OrderException): 9 | """Order already confirmed""" 10 | 11 | 12 | class OrderNotExists(OrderException): 13 | """Order not exists""" 14 | 15 | 16 | class OrderLineGoodsHasIncorrectType(OrderException): 17 | """Order line goods has incorrect type""" 18 | 19 | 20 | class OrderAlreadyExists(OrderException): 21 | """Order already exists""" 22 | -------------------------------------------------------------------------------- /app/domain/order/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/order/interfaces/__init__.py -------------------------------------------------------------------------------- /app/domain/order/interfaces/persistence.py: -------------------------------------------------------------------------------- 1 | from typing import List, Protocol 2 | from uuid import UUID 3 | 4 | from app.domain.order import dto 5 | from app.domain.order.models.order import Order 6 | 7 | 8 | class IOrderReader(Protocol): 9 | async def all_orders(self) -> List[dto.Order]: 10 | ... 11 | 12 | async def order_by_id(self, goods_id: UUID) -> dto.Order: 13 | ... 14 | 15 | async def get_user_orders( 16 | self, user_id: int, limit: int, offset: int 17 | ) -> List[dto.Order]: 18 | ... 19 | 20 | async def get_user_orders_count(self, user_id: int) -> int: 21 | ... 22 | 23 | async def get_orders_for_confirmation(self, limit: int, offset: int) -> List[Order]: 24 | ... 25 | 26 | async def get_orders_for_confirmation_count(self) -> int: 27 | ... 28 | 29 | async def get_all_orders(self, limit: int, offset: int) -> List[dto.Order]: 30 | ... 31 | 32 | async def get_all_orders_count(self) -> int: 33 | ... 34 | 35 | 36 | class IOrderRepo(Protocol): 37 | async def create_order(self, order: dto.OrderCreate) -> Order: 38 | ... 39 | 40 | async def order_by_id(self, order_id: UUID) -> Order: 41 | ... 42 | 43 | async def edit_order(self, goods: Order) -> Order: 44 | ... 45 | -------------------------------------------------------------------------------- /app/domain/order/interfaces/uow.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.interfaces.uow import IUoW 2 | from app.domain.order.interfaces.persistence import IOrderReader, IOrderRepo 3 | 4 | 5 | class IOrderUoW(IUoW): 6 | order: IOrderRepo 7 | order_reader: IOrderReader 8 | -------------------------------------------------------------------------------- /app/domain/order/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import goods, market, order, user 2 | 3 | __all__ = ["goods", "market", "order", "user"] 4 | -------------------------------------------------------------------------------- /app/domain/order/models/goods.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from typing import List, Optional 5 | from uuid import UUID 6 | 7 | import attrs 8 | from attrs import validators 9 | 10 | from app.domain.base.models.entity import entity 11 | from app.domain.order.value_objects import GoodsType 12 | 13 | 14 | @entity 15 | class Goods: 16 | id: UUID = attrs.field(validator=validators.instance_of(UUID), factory=uuid.uuid4) 17 | name: str = attrs.field(validator=validators.instance_of(str)) 18 | type: Optional[GoodsType] = attrs.field( 19 | validator=validators.instance_of(Optional[GoodsType]) 20 | ) 21 | sku: Optional[str] = attrs.field( 22 | validator=validators.instance_of(Optional[str]), default=None 23 | ) 24 | is_active: bool = attrs.field(validator=validators.instance_of(bool), default=True) 25 | 26 | parent: Optional[Goods] = attrs.field(default=None, repr=False) 27 | children: List[Goods] = attrs.field(factory=list, repr=False) 28 | -------------------------------------------------------------------------------- /app/domain/order/models/market.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from uuid import UUID 5 | 6 | import attrs 7 | from attrs import validators 8 | 9 | from app.domain.base.models.entity import entity 10 | 11 | 12 | @entity 13 | class Market: 14 | id: UUID = attrs.field(validator=validators.instance_of(UUID), factory=uuid.uuid4) 15 | name: str = attrs.field(validator=validators.instance_of(str)) 16 | is_active: bool = attrs.field(validator=validators.instance_of(bool), default=True) 17 | -------------------------------------------------------------------------------- /app/domain/order/models/order.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | from typing import List, Optional 4 | from uuid import UUID 5 | 6 | import attrs 7 | from attrs import validators 8 | 9 | from app.domain.base.events.event import Event 10 | from app.domain.base.models.aggregate import Aggregate 11 | from app.domain.base.models.entity import entity 12 | from app.domain.order import dto 13 | from app.domain.order.dto import User 14 | from app.domain.order.exceptions.order import OrderAlreadyConfirmed 15 | from app.domain.order.models.goods import Goods 16 | from app.domain.order.models.market import Market 17 | from app.domain.order.models.user import TelegramUser 18 | from app.domain.order.value_objects.confirmed_status import ConfirmedStatus 19 | 20 | 21 | @entity 22 | class OrderLine: 23 | id: UUID = attrs.field(validator=validators.instance_of(UUID), factory=uuid.uuid4) 24 | goods: Goods = attrs.field(validator=validators.instance_of(Goods)) 25 | quantity: int = attrs.field(validator=validators.instance_of(int)) 26 | 27 | 28 | @entity 29 | class OrderMessage: 30 | id: UUID = attrs.field(validator=validators.instance_of(UUID), factory=uuid.uuid4) 31 | message_id: int = attrs.field(validator=validators.instance_of(int)) 32 | chat_id: int = attrs.field(validator=validators.instance_of(int)) 33 | 34 | 35 | @entity 36 | class Order(Aggregate): 37 | id: UUID = attrs.field(validator=validators.instance_of(UUID), factory=uuid.uuid4) 38 | order_lines: List[OrderLine] = attrs.field(factory=list) 39 | creator: TelegramUser = attrs.field(validator=validators.instance_of(TelegramUser)) 40 | created_at: datetime = attrs.field( 41 | validator=validators.instance_of(datetime), factory=datetime.now 42 | ) 43 | confirmed_at: Optional[datetime] = attrs.field( 44 | validator=validators.instance_of(Optional[datetime]), default=None 45 | ) 46 | recipient_market: Market = attrs.field(validator=validators.instance_of(Market)) 47 | commentary: str = attrs.field(validator=validators.instance_of(str)) 48 | confirmed: ConfirmedStatus = attrs.field( 49 | validator=validators.instance_of(ConfirmedStatus), 50 | default=ConfirmedStatus.NOT_PROCESSED, 51 | ) 52 | order_messages: List[OrderMessage] = attrs.field(factory=list) 53 | 54 | def create(self): 55 | self.events.append(OrderCreated(dto.order.Order.from_orm(self))) 56 | 57 | def add_order_line(self, order_line: OrderLine): 58 | self.order_lines.append(order_line) 59 | 60 | def change_confirm_status(self, status: ConfirmedStatus, confirmed_by: User): 61 | if self.confirmed is not ConfirmedStatus.NOT_PROCESSED: 62 | raise OrderAlreadyConfirmed() 63 | self.confirmed = status 64 | self.confirmed_at = datetime.now() 65 | self.events.append( 66 | OrderConfirmStatusChanged(dto.order.Order.from_orm(self), confirmed_by) 67 | ) 68 | 69 | def add_order_message(self, order_message: OrderMessage): 70 | self.order_messages.append(order_message) 71 | 72 | 73 | class OrderCreated(Event): 74 | def __init__(self, order: dto.Order): 75 | self.order = order 76 | 77 | 78 | class OrderConfirmStatusChanged(Event): 79 | def __init__(self, order: dto.Order, user: User): 80 | self.order = order 81 | self.user = user 82 | -------------------------------------------------------------------------------- /app/domain/order/models/user.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | from typing import List 3 | 4 | import attrs 5 | from attr import validators 6 | 7 | from app.domain.base.models.entity import entity 8 | from app.domain.base.models.value_object import value_object 9 | 10 | 11 | def list_with_unique_values(access_levels: list): 12 | return list(set(access_levels)) 13 | 14 | 15 | @unique 16 | class LevelName(Enum): 17 | BLOCKED = "BLOCKED" 18 | USER = "USER" 19 | ADMINISTRATOR = "ADMINISTRATOR" 20 | CONFIRMATION = "CONFIRMATION" 21 | 22 | 23 | @value_object 24 | class AccessLevel: 25 | id: int 26 | name: LevelName 27 | 28 | 29 | @entity 30 | class TelegramUser: 31 | id: int = attrs.field(validator=validators.instance_of(int)) 32 | name: str = attrs.field(validator=validators.instance_of(str)) 33 | access_levels: List[AccessLevel] = attrs.field(converter=list_with_unique_values) 34 | -------------------------------------------------------------------------------- /app/domain/order/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/order/usecases/__init__.py -------------------------------------------------------------------------------- /app/domain/order/value_objects/__init__.py: -------------------------------------------------------------------------------- 1 | from .confirmed_status import ConfirmedStatus 2 | from .goods_type import GoodsType 3 | 4 | __all__ = ["ConfirmedStatus", "GoodsType"] 5 | -------------------------------------------------------------------------------- /app/domain/order/value_objects/confirmed_status.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | 4 | @unique 5 | class ConfirmedStatus(Enum): 6 | YES = "YES" 7 | NO = "NO" 8 | NOT_PROCESSED = "NOT_PROCESSED" 9 | -------------------------------------------------------------------------------- /app/domain/order/value_objects/goods_type.py: -------------------------------------------------------------------------------- 1 | from app.domain.goods.models.goods_type import GoodsType 2 | 3 | __all__ = ["GoodsType"] 4 | -------------------------------------------------------------------------------- /app/domain/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/user/__init__.py -------------------------------------------------------------------------------- /app/domain/user/access_policy.py: -------------------------------------------------------------------------------- 1 | from asyncio import Protocol 2 | 3 | 4 | class User(Protocol): 5 | id: int 6 | is_blocked: bool 7 | is_admin: bool 8 | can_confirm_order: bool 9 | 10 | 11 | class UserAccessPolicy(Protocol): 12 | def read_users(self) -> bool: 13 | ... 14 | 15 | def modify_users(self) -> bool: 16 | ... 17 | 18 | def read_user_self(self, user_id: int): 19 | ... 20 | 21 | 22 | class AllowedUserAccessPolicy(UserAccessPolicy): 23 | def allow(self, *args, **kwargs): 24 | return True 25 | 26 | read_user_self = allow 27 | read_users = allow 28 | modify_users = allow 29 | 30 | 31 | class UserBasedUserAccessPolicy(UserAccessPolicy): 32 | def __init__(self, user: User): 33 | self.user = user 34 | 35 | def _is_not_blocked(self): 36 | return not self.user.is_blocked 37 | 38 | def _is_admin(self): 39 | return self.user.is_admin 40 | 41 | def read_user_self(self, user_id: int): 42 | return self.user.id == user_id 43 | 44 | read_users = _is_admin 45 | modify_users = _is_admin 46 | -------------------------------------------------------------------------------- /app/domain/user/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import PatchUserData, User, UserCreate, UserPatch 2 | 3 | __all__ = [ 4 | "PatchUserData", 5 | "User", 6 | "UserCreate", 7 | "UserPatch", 8 | ] 9 | -------------------------------------------------------------------------------- /app/domain/user/dto/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List, Optional, Tuple 4 | 5 | from app.domain.access_levels.dto.access_level import AccessLevel 6 | from app.domain.access_levels.models.helper import Levels 7 | from app.domain.base.dto.base import DTO 8 | 9 | 10 | class UserCreate(DTO): 11 | id: int 12 | name: str 13 | access_levels: List[int] 14 | 15 | 16 | class PatchUserData(DTO): 17 | id: Optional[int] 18 | name: Optional[str] 19 | access_levels: Optional[list[int]] 20 | 21 | 22 | class UserPatch(DTO): 23 | id: int 24 | user_data: PatchUserData 25 | 26 | 27 | class BaseUser(DTO): 28 | id: int 29 | name: str 30 | 31 | 32 | class User(BaseUser): 33 | access_levels: Tuple[AccessLevel, ...] 34 | 35 | @property 36 | def is_blocked(self) -> bool: 37 | return Levels.BLOCKED.name in [l.name.name for l in self.access_levels] 38 | 39 | @property 40 | def is_admin(self) -> bool: 41 | return Levels.ADMINISTRATOR.name in [l.name.name for l in self.access_levels] 42 | 43 | @property 44 | def can_confirm_order(self) -> bool: 45 | return Levels.CONFIRMATION.name in [l.name.name for l in self.access_levels] 46 | -------------------------------------------------------------------------------- /app/domain/user/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/user/exceptions/__init__.py -------------------------------------------------------------------------------- /app/domain/user/exceptions/user.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.exceptions.base import AppException 2 | 3 | 4 | class UserException(AppException): 5 | """Base User Exception""" 6 | 7 | 8 | class UserAlreadyExists(UserException): 9 | """User already exist""" 10 | 11 | 12 | class UserNotExists(UserException): 13 | """User not exist""" 14 | 15 | 16 | class UserWithNoAccessLevels(UserException): 17 | """User must have at least one access level""" 18 | 19 | 20 | class BlockedUserWithOtherRole(UserException): 21 | """Blocked user can't have other roles""" 22 | 23 | 24 | class CantDeleteWithOrders(UserException): 25 | """Can't delete user with orders""" 26 | -------------------------------------------------------------------------------- /app/domain/user/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/user/interfaces/__init__.py -------------------------------------------------------------------------------- /app/domain/user/interfaces/persistence.py: -------------------------------------------------------------------------------- 1 | from typing import List, Protocol 2 | 3 | from app.domain.user.dto.user import User as UserDTO 4 | from app.domain.user.models.user import TelegramUser 5 | 6 | 7 | class IUserReader(Protocol): 8 | async def all_users(self) -> List[UserDTO]: 9 | ... 10 | 11 | async def users_for_confirmation(self) -> List[UserDTO]: 12 | ... 13 | 14 | async def user_by_id(self, user_id: int) -> UserDTO: 15 | ... 16 | 17 | 18 | class IUserRepo(Protocol): 19 | async def add_user(self, user: TelegramUser) -> TelegramUser: 20 | ... 21 | 22 | async def user_by_id(self, user_id: int) -> TelegramUser: 23 | ... 24 | 25 | async def delete_user(self, user_id: int) -> None: 26 | ... 27 | 28 | async def edit_user(self, user: TelegramUser) -> TelegramUser: 29 | ... 30 | -------------------------------------------------------------------------------- /app/domain/user/interfaces/uow.py: -------------------------------------------------------------------------------- 1 | from app.domain.base.interfaces.uow import IUoW 2 | from app.domain.user.interfaces.persistence import IUserReader, IUserRepo 3 | 4 | 5 | class IUserUoW(IUoW): 6 | user: IUserRepo 7 | user_reader: IUserReader 8 | -------------------------------------------------------------------------------- /app/domain/user/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/user/models/__init__.py -------------------------------------------------------------------------------- /app/domain/user/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import attrs 4 | from attr import validators 5 | 6 | from app.domain.access_levels.models.access_level import AccessLevel 7 | from app.domain.access_levels.models.helper import Levels 8 | from app.domain.base.events.event import Event 9 | from app.domain.base.models.aggregate import Aggregate 10 | from app.domain.base.models.entity import entity 11 | from app.domain.user import dto 12 | from app.domain.user.exceptions.user import ( 13 | BlockedUserWithOtherRole, 14 | UserWithNoAccessLevels, 15 | ) 16 | 17 | 18 | def list_with_unique_values(access_levels: list): 19 | return list(set(access_levels)) 20 | 21 | 22 | @entity 23 | class TelegramUser(Aggregate): 24 | id: int = attrs.field(validator=validators.instance_of(int)) 25 | name: str = attrs.field(validator=validators.instance_of(str)) 26 | access_levels: List[AccessLevel] = attrs.field(converter=list_with_unique_values) 27 | 28 | @classmethod 29 | def create(cls, id: int, name: str, access_levels: List[AccessLevel]): 30 | user = TelegramUser(id=id, name=name, access_levels=access_levels) 31 | user.events.append(UserCreated(dto.User.from_orm(user))) 32 | return user 33 | 34 | @access_levels.validator 35 | def validate_access_levels(self, attribute, value): 36 | if len(value) < 1: 37 | raise UserWithNoAccessLevels("User must have at least one access level") 38 | if len(value) > 1 and Levels.BLOCKED.value in value: 39 | raise BlockedUserWithOtherRole("Blocked user can have only that role") 40 | 41 | def block_user(self) -> None: 42 | self.access_levels = [ 43 | Levels.BLOCKED.value, 44 | ] 45 | 46 | @property 47 | def is_blocked(self) -> bool: 48 | return Levels.BLOCKED.value in self.access_levels 49 | 50 | @property 51 | def is_admin(self) -> bool: 52 | return Levels.ADMINISTRATOR.value in self.access_levels 53 | 54 | 55 | class UserCreated(Event): 56 | def __init__(self, user: dto.User): 57 | self.user = user 58 | -------------------------------------------------------------------------------- /app/domain/user/usecases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/domain/user/usecases/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/infrastructure/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/infrastructure/database/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config, pool 5 | from sqlalchemy.orm import clear_mappers 6 | 7 | from app.config import load_config 8 | from app.infrastructure.database.db import make_connection_string 9 | from app.infrastructure.database.models import map_tables, mapper_registry 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | 14 | config = context.config 15 | 16 | config.set_main_option( 17 | "sqlalchemy.url", make_connection_string(load_config().db, async_fallback=True) 18 | ) 19 | 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | if config.config_file_name is not None: 23 | fileConfig(config.config_file_name) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | # from myapp import mymodel 28 | # target_metadata = mymodel.Base.metadata 29 | target_metadata = mapper_registry.metadata 30 | clear_mappers() 31 | map_tables() 32 | 33 | # other values from the config, defined by the needs of env.py, 34 | # can be acquired: 35 | # my_important_option = config.get_main_option("my_important_option") 36 | # ... etc. 37 | 38 | 39 | def run_migrations_offline(): 40 | """Run migrations in 'offline' mode. 41 | 42 | This configures the context with just a URL 43 | and not an Engine, though an Engine is acceptable 44 | here as well. By skipping the Engine creation 45 | we don't even need a DBAPI to be available. 46 | 47 | Calls to context.execute() here emit the given string to the 48 | script output. 49 | 50 | """ 51 | url = config.get_main_option("sqlalchemy.url") 52 | context.configure( 53 | url=url, 54 | target_metadata=target_metadata, 55 | literal_binds=True, 56 | dialect_opts={"paramstyle": "named"}, 57 | ) 58 | 59 | with context.begin_transaction(): 60 | context.run_migrations() 61 | 62 | 63 | def run_migrations_online(): 64 | """Run migrations in 'online' mode. 65 | 66 | In this scenario we need to create an Engine 67 | and associate a connection with the context. 68 | 69 | """ 70 | connectable = engine_from_config( 71 | config.get_section(config.config_ini_section), 72 | prefix="sqlalchemy.", 73 | poolclass=pool.NullPool, 74 | ) 75 | 76 | with connectable.connect() as connection: 77 | context.configure(connection=connection, target_metadata=target_metadata) 78 | 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | 82 | 83 | if context.is_offline_mode(): 84 | run_migrations_offline() 85 | else: 86 | run_migrations_online() 87 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/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 alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/versions/9dfdf7059df2_order_creator_id_bigint.py: -------------------------------------------------------------------------------- 1 | """order_creator_id_bigint 2 | 3 | Revision ID: 9dfdf7059df2 4 | Revises: eb19178d8727 5 | Create Date: 2023-01-27 09:34:00.493830 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy import BIGINT 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '9dfdf7059df2' 14 | down_revision = 'eb19178d8727' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.alter_column("order", "creator_id", type_=BIGINT) 21 | 22 | 23 | def downgrade(): 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | pass 26 | # ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/versions/eb19178d8727_unique_market_name.py: -------------------------------------------------------------------------------- 1 | """unique market name 2 | 3 | Revision ID: eb19178d8727 4 | Revises: c82ae5449675 5 | Create Date: 2022-06-21 19:21:40.720952 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "eb19178d8727" 13 | down_revision = "c82ae5449675" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_unique_constraint(op.f("uq_market_name"), "market", ["name"]) 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_constraint(op.f("uq_market_name"), "market", type_="unique") 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /app/infrastructure/database/db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine 2 | from sqlalchemy.orm import sessionmaker 3 | 4 | from app.config import DB 5 | 6 | 7 | def make_connection_string(db: DB, async_fallback: bool = False) -> str: 8 | result = ( 9 | f"postgresql+asyncpg://{db.user}:{db.password}@{db.host}:{db.port}/{db.name}" 10 | ) 11 | if async_fallback: 12 | result += "?async_fallback=True" 13 | return result 14 | 15 | 16 | def sa_sessionmaker(db: DB, echo: bool = False) -> sessionmaker: 17 | """ 18 | Make sessionmaker 19 | :param driver: dialect+driver 20 | :param db_path: database path and credential 21 | :return: sessionmaker 22 | :rtype: sqlalchemy.orm.sessionmaker 23 | """ 24 | engine = create_async_engine(make_connection_string(db), echo=echo) 25 | return sessionmaker( 26 | bind=engine, 27 | expire_on_commit=False, 28 | class_=AsyncSession, 29 | future=True, 30 | autoflush=False, 31 | ) 32 | -------------------------------------------------------------------------------- /app/infrastructure/database/exception_mapper.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Any, Callable 3 | 4 | from sqlalchemy.exc import IntegrityError 5 | 6 | from app.domain.base.exceptions import repo 7 | 8 | 9 | def exception_mapper(func: Callable[..., Any]) -> Callable[..., Any]: 10 | @wraps(func) 11 | async def wrapped(*args: Any, **kwargs: Any): 12 | try: 13 | return await func(*args, **kwargs) 14 | except IntegrityError as err: 15 | raise repo.IntegrityViolationError from err 16 | 17 | return wrapped 18 | -------------------------------------------------------------------------------- /app/infrastructure/database/import_from_csv.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import csv 4 | from dataclasses import dataclass 5 | from typing import Optional 6 | from uuid import UUID 7 | 8 | from app.config import load_config 9 | from app.domain.goods.models.goods import Goods 10 | from app.domain.goods.models.goods_type import GoodsType 11 | from app.domain.market.models.market import Market 12 | from app.infrastructure.database.db import sa_sessionmaker 13 | from app.infrastructure.database.models import map_tables 14 | 15 | 16 | def parse_args(): 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument( 19 | "--markets", type=str, required=True, help="path to csv file with markets" 20 | ) 21 | parser.add_argument( 22 | "--goods", type=str, required=True, help="path to csv file with goods" 23 | ) 24 | args = parser.parse_args() 25 | return {"markets": args.markets, "goods": args.goods} 26 | 27 | 28 | # import markets from csv file 29 | # csv format: 30 | # market_name 31 | 32 | 33 | def import_markets(path): 34 | # read file as csv 35 | with open(path, "r") as f: 36 | reader = csv.reader(f) 37 | # skip header 38 | next(reader) 39 | # read file as csv 40 | for line in reader: 41 | market_name = line[0] 42 | yield market_name 43 | 44 | 45 | # import goods from csv file 46 | # csv format: 47 | # id, parent_id,type,name,SKU 48 | 49 | 50 | @dataclass 51 | class Good: 52 | id: UUID 53 | parent_id: UUID 54 | type: GoodsType 55 | name: str 56 | SKU: Optional[str] 57 | 58 | 59 | def import_goods(path): 60 | # read file as csv 61 | with open(path, "r") as f: 62 | reader = csv.reader(f) 63 | # skip header 64 | next(reader) 65 | # read file as csv 66 | for line in reader: 67 | id = UUID(line[0]) 68 | parent_id = UUID(line[1]) if line[1] else None 69 | type = GoodsType(line[2]) 70 | name = line[3] 71 | SKU = line[4] if line[4] else None 72 | yield Good(id, parent_id, type, name, SKU) 73 | 74 | 75 | async def import_data_to_db(markets, goods): 76 | map_tables() 77 | sessionmaker = sa_sessionmaker(load_config(".env.dev").db) 78 | async with sessionmaker() as session: 79 | # import markets 80 | for market_name in markets: 81 | market = Market(name=market_name) 82 | session.add(market) 83 | await session.commit() 84 | # import goods 85 | added_goods = {} 86 | for good in goods: 87 | db_good = Goods( 88 | id=good.id, 89 | type=good.type, 90 | name=good.name, 91 | sku=good.SKU, 92 | parent=added_goods.get(good.parent_id), 93 | ) 94 | added_goods[db_good.id] = db_good 95 | session.add(db_good) 96 | await session.commit() 97 | 98 | print("Data imported to db") 99 | 100 | 101 | if __name__ == "__main__": 102 | args = parse_args() 103 | markets = import_markets(args["markets"]) 104 | goods = import_goods(args["goods"]) 105 | asyncio.run(import_data_to_db(markets, goods)) 106 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import mapper_registry 2 | from .goods import Goods 3 | from .map import map_tables 4 | from .market import Market 5 | from .order import Order, OrderLine 6 | from .user import AccessLevel, TelegramUser 7 | 8 | __all__ = [ 9 | "AccessLevel", 10 | "Goods", 11 | "Market", 12 | "Order", 13 | "OrderLine", 14 | "TelegramUser", 15 | "mapper_registry", 16 | "map_tables", 17 | ] 18 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import MetaData 2 | from sqlalchemy.orm import registry 3 | 4 | convention = { 5 | "ix": "ix_%(column_0_label)s", 6 | "uq": "uq_%(table_name)s_%(column_0_name)s", 7 | "ck": "ck_%(table_name)s_%(constraint_name)s", 8 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 9 | "pk": "pk_%(table_name)s", 10 | } 11 | 12 | meta = MetaData(naming_convention=convention) 13 | mapper_registry = registry(metadata=meta) 14 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/goods.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BOOLEAN, TEXT, CheckConstraint, Column 2 | from sqlalchemy import Enum as SQLEnum 3 | from sqlalchemy import ForeignKeyConstraint, Table, UniqueConstraint, func 4 | from sqlalchemy.dialects.postgresql import UUID 5 | from sqlalchemy.orm import relationship 6 | 7 | from app.domain.goods.models.goods import Goods 8 | from app.domain.goods.models.goods_type import GoodsType 9 | 10 | from .base import mapper_registry 11 | 12 | goods_table = Table( 13 | "goods", 14 | mapper_registry.metadata, 15 | Column( 16 | "id", 17 | UUID(as_uuid=True), 18 | primary_key=True, 19 | server_default=func.uuid_generate_v4(), 20 | ), 21 | Column("name", TEXT, nullable=False), 22 | Column("type", SQLEnum(GoodsType), nullable=False), 23 | Column( 24 | "parent_id", 25 | UUID(as_uuid=True), 26 | nullable=True, 27 | ), 28 | Column("parent_type", SQLEnum(GoodsType), nullable=True), 29 | Column("sku", TEXT, nullable=True), 30 | Column("is_active", BOOLEAN, nullable=False, default=True), 31 | ForeignKeyConstraint( 32 | ["parent_id", "parent_type"], 33 | ["goods.id", "goods.type"], 34 | ondelete="RESTRICT", 35 | onupdate="CASCADE", 36 | ), 37 | # check constraint if type is GOODS then sku is required otherwise must be null 38 | CheckConstraint( 39 | "(type in ('GOODS') AND sku IS NOT NULL) or type in ('FOLDER')", 40 | name="goods_sku_not_null", 41 | ), 42 | CheckConstraint( 43 | "(type in ('FOLDER') AND sku IS NULL) or type in ('GOODS')", 44 | name="folder_sku_null", 45 | ), 46 | CheckConstraint("parent_type in ('FOLDER')", name="parent_type_is_folder"), 47 | UniqueConstraint("id", "type"), 48 | ) 49 | 50 | 51 | def map_goods(): 52 | mapper_registry.map_imperatively( 53 | Goods, 54 | goods_table, 55 | properties={ 56 | "parent": relationship( 57 | Goods, 58 | remote_side=[goods_table.c.id, goods_table.c.type], 59 | back_populates="children", 60 | passive_deletes="all", 61 | lazy="joined", 62 | join_depth=1, 63 | uselist=False, 64 | ), 65 | "children": relationship( 66 | Goods, 67 | remote_side=[goods_table.c.parent_id, goods_table.c.parent_type], 68 | back_populates="parent", 69 | passive_deletes="all", 70 | lazy="joined", 71 | join_depth=1, 72 | uselist=True, 73 | ), 74 | }, 75 | ) 76 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/map.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.database.models.goods import map_goods 2 | from app.infrastructure.database.models.market import map_market 3 | from app.infrastructure.database.models.order import map_order 4 | from app.infrastructure.database.models.user import map_user 5 | 6 | 7 | def map_tables(): 8 | map_goods() 9 | map_market() 10 | map_order() 11 | map_user() 12 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/market.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy import BOOLEAN, TEXT, Column, Table, func 4 | from sqlalchemy.dialects.postgresql import UUID 5 | 6 | from app.domain.market.models.market import Market 7 | from app.infrastructure.database.models import mapper_registry 8 | 9 | market_table = Table( 10 | "market", 11 | mapper_registry.metadata, 12 | Column( 13 | "id", 14 | UUID(as_uuid=True), 15 | primary_key=True, 16 | server_default=func.uuid_generate_v4(), 17 | ), 18 | Column("name", TEXT, nullable=False, unique=True), 19 | Column("is_active", BOOLEAN, nullable=False, default=True), 20 | ) 21 | 22 | 23 | def map_market(): 24 | mapper_registry.map_imperatively( 25 | Market, 26 | market_table, 27 | ) 28 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | from sqlalchemy import BIGINT, INT, TEXT, Column 6 | from sqlalchemy import Enum as SQLEnum 7 | from sqlalchemy import ForeignKey, Table 8 | from sqlalchemy.orm import relationship 9 | 10 | from app.domain.access_levels.models import helper 11 | from app.domain.access_levels.models.access_level import AccessLevel, LevelName 12 | from app.domain.user.models.user import TelegramUser 13 | 14 | from .base import mapper_registry 15 | 16 | user_access_levels = Table( 17 | "user_access_levels", 18 | mapper_registry.metadata, 19 | Column( 20 | "user_id", 21 | ForeignKey("user.id", ondelete="CASCADE", onupdate="CASCADE"), 22 | primary_key=True, 23 | ), 24 | Column( 25 | "access_level_id", 26 | ForeignKey("access_level.id", ondelete="CASCADE", onupdate="CASCADE"), 27 | primary_key=True, 28 | ), 29 | ) 30 | 31 | access_level_table = Table( 32 | "access_level", 33 | mapper_registry.metadata, 34 | Column("id", INT, primary_key=True, autoincrement=True), 35 | Column("name", SQLEnum(LevelName), nullable=False), 36 | ) 37 | 38 | user_table = Table( 39 | "user", 40 | mapper_registry.metadata, 41 | Column("id", BIGINT, primary_key=True), 42 | Column("name", TEXT, nullable=False), 43 | ) 44 | 45 | 46 | def map_user(): 47 | mapper_registry.map_imperatively( 48 | TelegramUser, 49 | user_table, 50 | properties={ 51 | "access_levels": relationship( 52 | AccessLevel, 53 | secondary=user_access_levels, 54 | back_populates="users", 55 | lazy="selectin", 56 | ), 57 | }, 58 | ) 59 | mapper_registry.map_imperatively( 60 | AccessLevel, 61 | access_level_table, 62 | properties={ 63 | "users": relationship( 64 | TelegramUser, 65 | secondary=user_access_levels, 66 | back_populates="access_levels", 67 | ) 68 | }, 69 | ) 70 | 71 | class UpdatedLevels(Enum): # ToDo 72 | BLOCKED = AccessLevel(id=-1, name=LevelName.BLOCKED) 73 | ADMINISTRATOR = AccessLevel(id=1, name=LevelName.ADMINISTRATOR) 74 | USER = AccessLevel(id=2, name=LevelName.USER) 75 | CONFIRMATION = AccessLevel(id=3, name=LevelName.CONFIRMATION) 76 | 77 | helper.Levels = UpdatedLevels 78 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | from .access_level import AccessLevelReader 2 | from .goods import GoodsReader, GoodsRepo 3 | from .market import MarketReader, MarketRepo 4 | from .order import OrderReader, OrderRepo 5 | from .user import UserReader, UserRepo 6 | 7 | __all__ = [ 8 | "AccessLevelReader", 9 | "UserRepo", 10 | "UserReader", 11 | "GoodsRepo", 12 | "GoodsReader", 13 | "MarketRepo", 14 | "MarketReader", 15 | "OrderRepo", 16 | "OrderReader", 17 | ] 18 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/access_level.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import parse_obj_as 4 | from sqlalchemy import select 5 | 6 | from app.domain.access_levels import dto 7 | from app.domain.access_levels.interfaces.persistence import IAccessLevelReader 8 | from app.domain.access_levels.models.access_level import AccessLevel 9 | from app.domain.user.exceptions.user import UserNotExists 10 | from app.domain.user.models.user import TelegramUser 11 | from app.infrastructure.database.exception_mapper import exception_mapper 12 | from app.infrastructure.database.repositories.repo import SQLAlchemyRepo 13 | 14 | 15 | class AccessLevelReader(SQLAlchemyRepo, IAccessLevelReader): 16 | @exception_mapper 17 | async def all_access_levels(self) -> List[dto.AccessLevel]: 18 | query = select(AccessLevel) 19 | result = await self.session.execute(query) 20 | access_levels = result.scalars().all() 21 | 22 | return parse_obj_as(List[dto.AccessLevel], access_levels) 23 | 24 | @exception_mapper 25 | async def user_access_levels(self, user_id: int) -> List[dto.AccessLevel]: 26 | user = await self.session.get(TelegramUser, user_id) 27 | 28 | if not user: 29 | raise UserNotExists 30 | 31 | return parse_obj_as(List[dto.AccessLevel], user.access_levels) 32 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/goods.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Optional 3 | from uuid import UUID 4 | 5 | from pydantic import parse_obj_as 6 | from sqlalchemy import select 7 | from sqlalchemy.exc import IntegrityError 8 | 9 | from app.domain.goods import dto 10 | from app.domain.goods.exceptions.goods import ( 11 | CantDeleteWithChildren, 12 | CantDeleteWithOrders, 13 | CantSetSKUForFolder, 14 | GoodsAlreadyExists, 15 | GoodsMustHaveSKU, 16 | GoodsNotExists, 17 | GoodsTypeCantBeParent, 18 | ) 19 | from app.domain.goods.interfaces.persistence import IGoodsReader, IGoodsRepo 20 | from app.domain.goods.models.goods import Goods 21 | from app.infrastructure.database.repositories.repo import SQLAlchemyRepo 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class GoodsReader(SQLAlchemyRepo, IGoodsReader): 27 | async def goods_in_folder( 28 | self, parent_id: Optional[UUID], only_active: bool 29 | ) -> List[dto.Goods]: 30 | query = ( 31 | select(Goods) 32 | .where(Goods.parent_id == parent_id) 33 | .order_by(Goods.type.desc(), Goods.name) 34 | ) 35 | 36 | if only_active: 37 | query = query.where(Goods.is_active.is_(True)) 38 | 39 | result = await self.session.execute(query) 40 | goods = result.scalars().unique().all() 41 | 42 | return parse_obj_as(List[dto.Goods], goods) 43 | 44 | async def goods_by_id(self, goods_id: UUID) -> dto.Goods: 45 | goods = await self.session.get(Goods, goods_id) 46 | 47 | if not goods: 48 | raise GoodsNotExists(f"Goods with id {goods_id} not exists") 49 | 50 | return dto.Goods.from_orm(goods) 51 | 52 | 53 | class GoodsRepo(SQLAlchemyRepo, IGoodsRepo): 54 | async def _goods(self, goods_id: UUID) -> Goods: 55 | goods = await self.session.get(Goods, goods_id) 56 | if not goods: 57 | raise GoodsNotExists(f"Goods with id {goods_id} not exists") 58 | 59 | return goods 60 | 61 | async def add_goods(self, goods: Goods) -> Goods: 62 | try: 63 | self.session.add(goods) 64 | await self.session.flush() 65 | except IntegrityError as err: 66 | if "pk_goods" in str(err): 67 | raise GoodsAlreadyExists( 68 | f"Goods with id {goods.id} already exists" 69 | ) from err 70 | if "fk_goods_parent_id_goods" in str( 71 | err 72 | ) or "ck_goods_parent_type_is_folder" in str(err): 73 | raise GoodsTypeCantBeParent( 74 | f"Goods with id {goods.id} and {goods.type} can't be parent" 75 | ) from err 76 | if "ck_goods_folder_sku_null" in str(err): 77 | raise CantSetSKUForFolder( 78 | f"Goods id={goods.id} with {goods.type} type can't have SKU" 79 | ) from err 80 | if "ck_goods_goods_sku_not_null" in str(err): 81 | raise GoodsMustHaveSKU( 82 | f"Goods id={goods.id} with {goods.type} type must have SKU" 83 | ) from err 84 | raise 85 | 86 | return goods 87 | 88 | async def goods_by_id(self, user_id: UUID) -> Goods: 89 | return await self._goods(user_id) 90 | 91 | async def delete_goods(self, goods_id: UUID) -> None: 92 | try: 93 | goods = await self._goods(goods_id) 94 | await self.session.delete(goods) 95 | await self.session.flush() 96 | except IntegrityError as err: 97 | if "fk_order_line_goods_id_goods" in str(err): 98 | raise CantDeleteWithOrders( 99 | f"Goods with id {goods_id} can't be deleted as it has orders" 100 | ) from err 101 | if "fk_goods_parent_id_goods" in str(err): 102 | raise CantDeleteWithChildren( 103 | f"Goods with id {goods_id} can't be deleted as it has children" 104 | ) from err 105 | raise 106 | 107 | async def edit_goods(self, goods: Goods) -> Goods: 108 | goods_id = goods.id # copy goods id to access in case of exception 109 | try: 110 | await self.session.flush() 111 | except IntegrityError as err: 112 | raise GoodsAlreadyExists( 113 | f"Goods with id {goods_id} already exists" 114 | ) from err 115 | return goods 116 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/market.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List 3 | from uuid import UUID 4 | 5 | from pydantic import parse_obj_as 6 | from sqlalchemy import select 7 | from sqlalchemy.exc import IntegrityError 8 | 9 | from app.domain.market import dto 10 | from app.domain.market.exceptions.market import ( 11 | CantDeleteWithOrders, 12 | MarketAlreadyExists, 13 | MarketNotExists, 14 | ) 15 | from app.domain.market.interfaces.persistence import IMarketReader, IMarketRepo 16 | from app.domain.market.models.market import Market 17 | from app.infrastructure.database.repositories.repo import SQLAlchemyRepo 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class MarketReader(SQLAlchemyRepo, IMarketReader): 23 | async def all_markets(self, only_active: bool = False) -> List[dto.Market]: 24 | query = select(Market).order_by(Market.name) 25 | 26 | if only_active: 27 | query = query.where(Market.is_active.is_(True)) 28 | 29 | result = await self.session.execute(query) 30 | goods = result.scalars().all() 31 | 32 | return parse_obj_as(List[dto.Market], goods) 33 | 34 | async def market_by_id(self, market_id: UUID) -> dto.Market: 35 | goods = await self.session.get(Market, market_id) 36 | 37 | if not goods: 38 | raise MarketNotExists(f"Market with id {market_id} not exists") 39 | 40 | return dto.Market.from_orm(goods) 41 | 42 | 43 | class MarketRepo(SQLAlchemyRepo, IMarketRepo): 44 | async def _market(self, market_id: UUID) -> Market: 45 | market = await self.session.get(Market, market_id) 46 | 47 | if not market: 48 | raise MarketNotExists(f"Market with id {market_id} not exists") 49 | 50 | return market 51 | 52 | async def add_market(self, market: Market) -> Market: 53 | try: 54 | self.session.add(market) 55 | await self.session.flush() 56 | except IntegrityError as err: 57 | raise MarketAlreadyExists( 58 | f"Market with id {market.id} already exists" 59 | ) from err 60 | 61 | return market 62 | 63 | async def market_by_id(self, market_id: UUID) -> Market: 64 | return await self._market(market_id) 65 | 66 | async def delete_market(self, market_id: UUID) -> None: 67 | try: 68 | market = await self._market(market_id) 69 | await self.session.delete(market) 70 | await self.session.flush() 71 | except IntegrityError as err: 72 | if "fk_order_recipient_market_id_market" in str(err): 73 | raise CantDeleteWithOrders( 74 | f"Market with id {market_id} can't be deleted as it has orders" 75 | ) from err 76 | raise 77 | 78 | async def edit_market(self, market: Market) -> Market: 79 | market_id = market.id # copy market id to access in case of exception 80 | try: 81 | await self.session.flush() 82 | except IntegrityError as err: 83 | raise MarketAlreadyExists( 84 | f"Market with id {market_id} already exists" 85 | ) from err 86 | 87 | return market 88 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/order.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from uuid import UUID 3 | 4 | from pydantic import parse_obj_as 5 | from sqlalchemy import func, insert, select, desc 6 | from sqlalchemy.exc import IntegrityError 7 | 8 | from app.domain.goods.exceptions.goods import GoodsNotExists 9 | from app.domain.goods.models.goods import Goods 10 | from app.domain.order import dto 11 | from app.domain.order.exceptions.order import ( 12 | OrderAlreadyExists, 13 | OrderLineGoodsHasIncorrectType, 14 | OrderNotExists, 15 | ) 16 | from app.domain.order.interfaces.persistence import IOrderReader, IOrderRepo 17 | from app.domain.order.models.order import Order, OrderLine 18 | from app.domain.order.value_objects.confirmed_status import ConfirmedStatus 19 | from app.infrastructure.database.repositories.repo import SQLAlchemyRepo 20 | 21 | 22 | class OrderReader(SQLAlchemyRepo, IOrderReader): 23 | async def all_orders(self) -> List[dto.Order]: 24 | ... 25 | 26 | async def order_by_id(self, order_id: UUID) -> Order: 27 | order = await self.session.get(Order, order_id) 28 | 29 | if not order: 30 | raise OrderNotExists(f"Order with id {order_id} not exists") 31 | 32 | return dto.Order.from_orm(order) 33 | 34 | async def get_user_orders( 35 | self, user_id: int, limit: int, offset: int 36 | ) -> List[dto.Order]: 37 | query = ( 38 | select(Order) 39 | .where(Order.creator.has(id=user_id)) 40 | .order_by(desc(Order.created_at)) 41 | .limit(limit) 42 | .offset(offset) 43 | ) 44 | 45 | result = await self.session.execute(query) 46 | orders = result.unique().scalars().fetchall() 47 | 48 | return parse_obj_as(List[dto.Order], orders) 49 | 50 | async def get_user_orders_count(self, user_id: int) -> int: 51 | query = select(func.count(Order.id)).where(Order.creator.has(id=user_id)) 52 | 53 | result = await self.session.execute(query) 54 | count = result.scalar_one() 55 | 56 | return count 57 | 58 | async def get_orders_for_confirmation(self, limit: int, offset: int) -> List[Order]: 59 | query = ( 60 | select(Order) 61 | .where(Order.confirmed == ConfirmedStatus.NOT_PROCESSED) 62 | .order_by(desc(Order.created_at)) 63 | .limit(limit) 64 | .offset(offset) 65 | ) 66 | 67 | result = await self.session.execute(query) 68 | orders = result.unique().scalars().fetchall() 69 | 70 | return parse_obj_as(List[dto.Order], orders) 71 | 72 | async def get_orders_for_confirmation_count(self) -> int: 73 | query = select(func.count(Order.id)).where( 74 | Order.confirmed == ConfirmedStatus.NOT_PROCESSED 75 | ) 76 | 77 | result = await self.session.execute(query) 78 | count = result.scalar_one() 79 | 80 | return count 81 | 82 | async def get_all_orders(self, limit: int, offset: int) -> List[dto.Order]: 83 | query = select(Order).order_by(desc(Order.created_at)).limit(limit).offset(offset) 84 | result = await self.session.execute(query) 85 | orders = result.unique().scalars().fetchall() 86 | 87 | return parse_obj_as(List[dto.Order], orders) 88 | 89 | async def get_all_orders_count(self) -> int: 90 | query = select(func.count(Order.id)) 91 | result = await self.session.execute(query) 92 | count = result.scalar_one() 93 | 94 | return count 95 | 96 | 97 | class OrderRepo(SQLAlchemyRepo, IOrderRepo): 98 | async def create_order(self, order: dto.OrderCreate) -> Order: 99 | query = ( 100 | insert(Order) 101 | .values( 102 | creator_id=order.creator_id, 103 | recipient_market_id=order.recipient_market_id, 104 | commentary=order.commentary, 105 | ) 106 | .returning(Order.id) 107 | ) 108 | result = await self.session.execute(query) 109 | new_order_id = result.scalar_one() 110 | 111 | for line in order.order_lines: 112 | query = insert(OrderLine).values( 113 | order_id=new_order_id, 114 | goods_id=line.goods_id, 115 | quantity=line.quantity, 116 | goods_type=line.goods_type, 117 | ) 118 | try: 119 | await self.session.execute(query) 120 | except IntegrityError: 121 | await self.session.rollback() 122 | goods = await self.session.get(Goods, line.goods_id) 123 | if not goods: 124 | raise GoodsNotExists(f"Goods with id {line.goods_id} not exists") 125 | else: 126 | raise OrderLineGoodsHasIncorrectType( 127 | f"Goods with id {line.goods_id} is {goods.type} type, not GoodsType.GOODS" 128 | ) 129 | 130 | new_order: Order = await self.session.get(Order, new_order_id) 131 | 132 | new_order.create() 133 | await self.session.flush() 134 | return new_order 135 | 136 | async def order_by_id(self, order_id: UUID) -> Order: 137 | order = await self.session.get(Order, order_id) 138 | 139 | if not order: 140 | raise OrderNotExists(f"Order with id {order_id} not exists") 141 | 142 | return order 143 | 144 | async def edit_order(self, order: Order) -> Order: 145 | order_id = order.id # copy order id to access in case of exception 146 | try: 147 | await self.session.flush() 148 | await self.session.refresh(order) 149 | except IntegrityError as err: 150 | raise OrderAlreadyExists( 151 | f"Order with id {order_id} already exists" 152 | ) from err 153 | 154 | return order 155 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/repo.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | 4 | class SQLAlchemyRepo: 5 | def __init__(self, session: AsyncSession): 6 | self.session = session 7 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List 3 | 4 | from pydantic import parse_obj_as 5 | from sqlalchemy import select 6 | from sqlalchemy.exc import IntegrityError 7 | 8 | from app.domain.access_levels.exceptions.access_levels import AccessLevelNotExist 9 | from app.domain.access_levels.models.access_level import AccessLevel, LevelName 10 | from app.domain.user import dto 11 | from app.domain.user.exceptions.user import ( 12 | CantDeleteWithOrders, 13 | UserAlreadyExists, 14 | UserNotExists, 15 | ) 16 | from app.domain.user.interfaces.persistence import IUserReader, IUserRepo 17 | from app.domain.user.models.user import TelegramUser 18 | from app.infrastructure.database.repositories.repo import SQLAlchemyRepo 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class UserReader(SQLAlchemyRepo, IUserReader): 24 | async def all_users(self) -> List[dto.User]: 25 | query = select(TelegramUser) 26 | 27 | result = await self.session.execute(query) 28 | users = result.scalars().all() 29 | 30 | return parse_obj_as(List[dto.User], users) 31 | 32 | async def users_for_confirmation(self) -> List[dto.User]: 33 | query = select(TelegramUser).where( 34 | TelegramUser.access_levels.any(name=LevelName.CONFIRMATION) 35 | ) 36 | 37 | result = await self.session.execute(query) 38 | users = result.scalars().all() 39 | 40 | return parse_obj_as(List[dto.User], users) 41 | 42 | async def user_by_id(self, user_id: int) -> dto.User: 43 | user = await self.session.get(TelegramUser, user_id) 44 | 45 | if not user: 46 | raise UserNotExists(f"User with id {user_id} not exists in database") 47 | 48 | return dto.User.from_orm(user) 49 | 50 | 51 | class UserRepo(SQLAlchemyRepo, IUserRepo): 52 | async def _user(self, user_id: int) -> TelegramUser: 53 | user = await self.session.get(TelegramUser, user_id) 54 | 55 | if not user: 56 | raise UserNotExists(f"User with id {user_id} not exists in database") 57 | 58 | return user 59 | 60 | async def _populate_access_levels(self, user: TelegramUser) -> TelegramUser: 61 | access_levels = [] 62 | 63 | for level in user.access_levels: 64 | lvl = await self.session.get(AccessLevel, level.id) 65 | 66 | if level in self.session: 67 | self.session.expunge(level) 68 | if lvl is not None: 69 | access_levels.append(lvl) 70 | else: 71 | raise AccessLevelNotExist(f"Access level with id {level.id} not found") 72 | 73 | user.access_levels = access_levels 74 | return user 75 | 76 | async def add_user(self, user: TelegramUser) -> TelegramUser: 77 | try: 78 | id = user.id 79 | await self._populate_access_levels(user) 80 | self.session.add(user) 81 | await self.session.flush() 82 | except IntegrityError as err: 83 | raise UserAlreadyExists( 84 | f"User with id {id} already exists in database" 85 | ) from err 86 | 87 | return user 88 | 89 | async def user_by_id(self, user_id: int) -> TelegramUser: 90 | return await self._user(user_id) 91 | 92 | async def delete_user(self, user_id: int) -> None: 93 | try: 94 | user = await self._user(user_id) 95 | await self.session.delete(user) 96 | await self.session.flush() 97 | except IntegrityError as err: 98 | raise CantDeleteWithOrders( 99 | f"User with id {user_id} has orders, can't delete" 100 | ) from err 101 | 102 | async def edit_user(self, user: TelegramUser) -> TelegramUser: 103 | user_id = user.id # copy user id to access in case of exception 104 | try: 105 | await self._populate_access_levels(user) 106 | await self.session.flush() 107 | except IntegrityError as err: 108 | raise UserAlreadyExists( 109 | f"User with id {user_id} already exists in database" 110 | ) from err 111 | 112 | return user 113 | -------------------------------------------------------------------------------- /app/infrastructure/database/uow.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from app.domain.access_levels.interfaces.persistence import IAccessLevelReader 6 | from app.domain.access_levels.interfaces.uow import IAccessLevelUoW 7 | from app.domain.base.interfaces.uow import IUoW 8 | from app.domain.goods.interfaces.persistence import IGoodsReader, IGoodsRepo 9 | from app.domain.goods.interfaces.uow import IGoodsUoW 10 | from app.domain.market.interfaces.persistence import IMarketReader, IMarketRepo 11 | from app.domain.market.interfaces.uow import IMarketUoW 12 | from app.domain.order.interfaces.persistence import IOrderReader, IOrderRepo 13 | from app.domain.order.interfaces.uow import IOrderUoW 14 | from app.domain.user.interfaces.persistence import IUserReader, IUserRepo 15 | from app.domain.user.interfaces.uow import IUserUoW 16 | from app.infrastructure.database.exception_mapper import exception_mapper 17 | 18 | 19 | class SQLAlchemyBaseUoW(IUoW): 20 | def __init__(self, session: AsyncSession): 21 | self._session = session 22 | 23 | @exception_mapper 24 | async def commit(self) -> None: 25 | await self._session.commit() 26 | 27 | async def rollback(self) -> None: 28 | await self._session.rollback() 29 | 30 | 31 | class SQLAlchemyUoW( 32 | SQLAlchemyBaseUoW, IUserUoW, IAccessLevelUoW, IGoodsUoW, IMarketUoW, IOrderUoW 33 | ): 34 | user: IUserRepo 35 | user_reader: IUserReader 36 | access_level_reader: IAccessLevelReader 37 | goods: IGoodsRepo 38 | goods_reader: IGoodsReader 39 | market: IMarketRepo 40 | market_reader: IMarketReader 41 | order: IOrderRepo 42 | order_reader: IOrderReader 43 | 44 | def __init__( 45 | self, 46 | session: AsyncSession, 47 | user_repo: Type[IUserRepo], 48 | user_reader: Type[IUserReader], 49 | access_level_reader: Type[IAccessLevelReader], 50 | goods_repo: Type[IGoodsRepo], 51 | goods_reader: Type[IGoodsReader], 52 | market_repo: Type[IMarketRepo], 53 | market_reader: Type[IMarketReader], 54 | order_repo: Type[IOrderRepo], 55 | order_reader: Type[IOrderReader], 56 | ): 57 | self.user = user_repo(session) 58 | self.user_reader = user_reader(session) 59 | self.access_level_reader = access_level_reader(session) 60 | self.goods = goods_repo(session) 61 | self.goods_reader = goods_reader(session) 62 | self.market = market_repo(session) 63 | self.market_reader = market_reader(session) 64 | self.order = order_repo(session) 65 | self.order_reader = order_reader(session) 66 | super().__init__(session) 67 | -------------------------------------------------------------------------------- /app/infrastructure/exporters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/infrastructure/exporters/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/exporters/orders_csv.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import io 3 | 4 | from app.domain.order.dto.order import Order 5 | 6 | 7 | # function that get list of orders and save them to csv file in memory as bytes 8 | def export_orders_to_csv(orders: list[Order]) -> bytes: 9 | # create csv writer 10 | output = io.StringIO() 11 | csv_writer = csv.writer( 12 | output, 13 | delimiter="\t", 14 | quotechar='"', 15 | quoting=csv.QUOTE_ALL, 16 | lineterminator="\r\n", 17 | ) 18 | # write header 19 | csv_writer.writerow( 20 | [ 21 | "id", 22 | "creator_id", 23 | "creator_name", 24 | "recipient_market_id", 25 | "recipient_market_name", 26 | "goods_name", 27 | "goods_sku", 28 | "quantity", 29 | "commentary", 30 | "created_at", 31 | "confirmed", 32 | ] 33 | ) 34 | # write orders 35 | for order in orders: 36 | for line in order.order_lines: 37 | csv_writer.writerow( 38 | [ 39 | order.id, 40 | order.creator.id, 41 | order.creator.name, 42 | order.recipient_market.id, 43 | order.recipient_market.name, 44 | line.goods.name, 45 | line.goods.sku, 46 | line.quantity, 47 | order.commentary, 48 | order.created_at, 49 | order.confirmed, 50 | ] 51 | ) 52 | # return bytes 53 | return output.getvalue().encode("utf-16") 54 | -------------------------------------------------------------------------------- /app/tgbot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/tgbot/__init__.py -------------------------------------------------------------------------------- /app/tgbot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.dispatcher.fsm.storage.memory import MemoryStorage, SimpleEventIsolation 6 | from aiogram.dispatcher.fsm.storage.redis import DefaultKeyBuilder, RedisStorage 7 | from aiogram_dialog import DialogRegistry 8 | 9 | from app.config import load_config 10 | from app.domain.base.events.dispatcher import EventDispatcher 11 | from app.infrastructure.database.db import sa_sessionmaker 12 | from app.infrastructure.database.models import map_tables 13 | from app.tgbot.event_handlers.order import setup_event_handlers 14 | from app.tgbot.event_handlers.setup_middlewares import setup_event_middlewares 15 | from app.tgbot.handlers import register_handlers 16 | from app.tgbot.middlewares import setup_middlewares 17 | from app.tgbot.services.set_commands import set_commands 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | async def main(): 23 | logging.basicConfig( 24 | level=logging.INFO, 25 | format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", 26 | ) 27 | logger.error("Starting bot") 28 | config = load_config() 29 | 30 | if config.tg_bot.use_redis: 31 | storage = RedisStorage.from_url( 32 | url=f"redis://{config.redis.host}", 33 | connection_kwargs={ 34 | "db": config.redis.db, 35 | }, 36 | key_builder=DefaultKeyBuilder(with_destiny=True), 37 | ) 38 | else: 39 | storage = MemoryStorage() 40 | 41 | session_factory = sa_sessionmaker(config.db, echo=False) 42 | 43 | bot = Bot(token=config.tg_bot.token, parse_mode="HTML") 44 | dp = Dispatcher(storage=storage, events_isolation=SimpleEventIsolation()) 45 | 46 | dialog_registry = DialogRegistry(dp) 47 | 48 | setup_middlewares( 49 | dp=dp, 50 | sessionmaker=session_factory, 51 | ) 52 | event_dispatcher = EventDispatcher(bot=bot) 53 | setup_event_handlers(event_dispatcher=event_dispatcher) 54 | setup_event_middlewares( 55 | dp=event_dispatcher, 56 | sessionmaker=session_factory, 57 | ) 58 | 59 | register_handlers(dp=dp, dialog_registry=dialog_registry) 60 | 61 | map_tables() 62 | 63 | try: 64 | await set_commands(bot, config) 65 | await bot.get_updates(offset=-1) 66 | await dp.start_polling(bot, config=config, event_dispatcher=event_dispatcher) 67 | finally: 68 | await dp.fsm.storage.close() 69 | await bot.session.close() 70 | 71 | 72 | try: 73 | asyncio.run(main()) 74 | except (KeyboardInterrupt, SystemExit): 75 | logger.error("Bot stopped!") 76 | -------------------------------------------------------------------------------- /app/tgbot/constants.py: -------------------------------------------------------------------------------- 1 | USER_ID = "user_id" 2 | USER = "user" 3 | USERS = "users" 4 | YES = "Yes" 5 | NO = "No" 6 | YES_NO = [("✅ Yes", YES), ("⛔ No", NO)] 7 | OLD_USER_ID = "old_user_id" 8 | NEW_USER_ID = "new_user_id" 9 | USER_NAME = "user_name" 10 | GOODS_NAME = "goods_name" 11 | MARKET_NAME = "market_name" 12 | GOODS_TYPE = "goods_type" 13 | GOODS_SKU = "goods_sku" 14 | SELECTOR_GOODS_ID = "selector_goods_id" 15 | SELECTOR_MARKET_ID = "selector_market_id" 16 | SELECTED_GOODS = "selected_goods" 17 | SELECTED_MARKET = "selected market" 18 | GOODS = "goods" 19 | MARKET = "market" 20 | ACCESS_LEVELS = "access_levels" 21 | FIELD = "field" 22 | ALL_ACCESS_LEVELS = "all_access_levels" 23 | -------------------------------------------------------------------------------- /app/tgbot/event_handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/tgbot/event_handlers/__init__.py -------------------------------------------------------------------------------- /app/tgbot/event_handlers/order.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | from aiogram import Bot 5 | from aiogram.exceptions import TelegramAPIError 6 | from aiogram.utils.text_decorations import html_decoration as fmt 7 | 8 | from app.domain.base.events.dispatcher import EventDispatcher 9 | from app.domain.order.access_policy import AllowedOrderAccessPolicy 10 | from app.domain.order.dto.order import OrderMessageCreate 11 | from app.domain.order.models.order import OrderConfirmStatusChanged, OrderCreated 12 | from app.domain.order.usecases.order import OrderService 13 | from app.domain.user.access_policy import AllowedUserAccessPolicy 14 | from app.domain.user.dto import User 15 | from app.domain.user.usecases.user import UserService 16 | from app.tgbot.handlers.chief.order_confirm import confirm_order_keyboard 17 | from app.tgbot.handlers.message_templates import format_order_message 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | async def order_created_handler(event: OrderCreated, data: dict[str, Any]): 23 | event_dispatcher = data.get("event_dispatcher") 24 | uow = data.get("uow") 25 | bot: Bot = data["bot"] 26 | 27 | user_service = UserService( 28 | access_policy=AllowedUserAccessPolicy(), 29 | event_dispatcher=event_dispatcher, 30 | uow=uow, 31 | ) 32 | order_service = OrderService( 33 | access_policy=AllowedOrderAccessPolicy(), 34 | event_dispatcher=event_dispatcher, 35 | uow=uow, 36 | ) 37 | 38 | users: list[User] = await user_service.get_users_for_confirmation() 39 | 40 | message_text = ( 41 | f"New order {fmt.pre(event.order.id)} from {event.order.creator.name}\n\n" 42 | + format_order_message(event.order) 43 | ) 44 | 45 | sent_messages = [] 46 | for user in users: 47 | try: 48 | message = await bot.send_message( 49 | chat_id=user.id, 50 | text=message_text, 51 | reply_markup=confirm_order_keyboard(event.order.id), 52 | ) 53 | sent_messages.append( 54 | OrderMessageCreate( 55 | message_id=message.message_id, chat_id=message.chat.id 56 | ) 57 | ) 58 | except TelegramAPIError as e: 59 | logger.error(e) 60 | continue 61 | 62 | await order_service.add_order_messages(event.order.id, sent_messages) 63 | 64 | 65 | async def order_confirm_handler(event: OrderConfirmStatusChanged, data: dict[str, Any]): 66 | bot: Bot = data["bot"] 67 | 68 | await bot.send_message( 69 | chat_id=event.order.creator.id, 70 | text=f"Order {fmt.pre(event.order.id)} confirmed by {event.user.name}\n\n" 71 | + format_order_message(event.order), 72 | ) 73 | 74 | 75 | def setup_event_handlers(event_dispatcher: EventDispatcher): 76 | event_dispatcher.register_notify(OrderCreated, order_created_handler) 77 | event_dispatcher.register_notify(OrderConfirmStatusChanged, order_confirm_handler) 78 | -------------------------------------------------------------------------------- /app/tgbot/event_handlers/setup_middlewares.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy.orm 2 | 3 | from app.domain.base.events.dispatcher import EventDispatcher 4 | from app.tgbot.middlewares.database import Database 5 | 6 | 7 | def setup_event_middlewares( 8 | dp: EventDispatcher, 9 | sessionmaker: sqlalchemy.orm.sessionmaker, 10 | ): 11 | dp.notifications.middleware(Database(sessionmaker)) 12 | -------------------------------------------------------------------------------- /app/tgbot/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from .access_level import AccessLevelFilter 2 | 3 | __all__ = ("AccessLevelFilter",) 4 | -------------------------------------------------------------------------------- /app/tgbot/filters/access_level.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from aiogram.dispatcher.filters import BaseFilter 4 | from aiogram.types import TelegramObject 5 | from pydantic import validator 6 | from sqlalchemy.orm import Session 7 | 8 | from app.domain.access_levels.dto.access_level import LevelName 9 | from app.domain.user.dto.user import User 10 | 11 | 12 | class AccessLevelFilter(BaseFilter): 13 | access_levels: Union[LevelName, List[LevelName]] 14 | 15 | @validator("access_levels") 16 | def _validate_access_levels( 17 | cls, value: Union[LevelName, List[LevelName]] 18 | ) -> List[LevelName]: 19 | if isinstance(value, LevelName): 20 | value = [value] 21 | return value 22 | 23 | async def __call__(self, obj: TelegramObject, user: User, session: Session) -> bool: 24 | if not user: 25 | if not self.access_levels: 26 | return True 27 | return False 28 | 29 | return any((level.name in self.access_levels) for level in user.access_levels) 30 | -------------------------------------------------------------------------------- /app/tgbot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_handlers 2 | 3 | __all__ = ["register_handlers"] 4 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_admin_handlers 2 | 3 | __all__ = ["register_admin_handlers"] 4 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/goods/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_goods_db_handlers 2 | 3 | __all__ = ["register_goods_db_handlers"] 4 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/goods/add.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | 3 | from aiogram.types import CallbackQuery, Message 4 | from aiogram.utils.text_decorations import html_decoration as fmt 5 | from aiogram_dialog import Dialog, Window 6 | from aiogram_dialog.manager.protocols import DialogManager, ManagedDialogAdapterProto 7 | from aiogram_dialog.widgets.input import MessageInput 8 | from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Next, Row, Select 9 | from aiogram_dialog.widgets.managed import ManagedWidgetAdapter 10 | from aiogram_dialog.widgets.text import Const, Format 11 | 12 | from app.domain.goods.dto.goods import GoodsCreate 13 | from app.domain.goods.models.goods_type import GoodsType 14 | from app.domain.goods.usecases.goods import GoodsService 15 | from app.tgbot import states 16 | from app.tgbot.constants import ( 17 | GOODS_NAME, 18 | GOODS_SKU, 19 | GOODS_TYPE, 20 | NO, 21 | SELECTED_GOODS, 22 | YES_NO, 23 | ) 24 | from app.tgbot.handlers.admin.goods.common import get_goods_data, goods_adding_process 25 | from app.tgbot.handlers.admin.user.common import copy_start_data_to_context 26 | from app.tgbot.handlers.dialogs.common import enable_send_mode 27 | from app.tgbot.states.goods_db import AddGoods 28 | 29 | 30 | async def request_goods_name( 31 | message: Message, 32 | dialog: ManagedDialogAdapterProto, 33 | manager: DialogManager, 34 | **kwargs, 35 | ): 36 | manager.current_context().dialog_data[GOODS_NAME] = message.text 37 | await dialog.next() 38 | 39 | 40 | async def save_goods_type( 41 | query: CallbackQuery, 42 | select: ManagedWidgetAdapter[Select], 43 | manager: DialogManager, 44 | item_id: str, 45 | **kwargs, 46 | ): 47 | manager.current_context().dialog_data[GOODS_TYPE] = item_id 48 | if item_id == GoodsType.FOLDER.value: 49 | await manager.dialog().switch_to(AddGoods.confirm) 50 | else: 51 | await manager.dialog().next() 52 | 53 | 54 | async def request_goods_sku( 55 | message: Message, 56 | dialog: ManagedDialogAdapterProto, 57 | manager: DialogManager, 58 | **kwargs, 59 | ): 60 | manager.current_context().dialog_data[GOODS_SKU] = message.text 61 | await dialog.next() 62 | 63 | 64 | async def add_goods_yes_no( 65 | query: CallbackQuery, 66 | select: ManagedWidgetAdapter[Select], 67 | manager: DialogManager, 68 | item_id: str, 69 | **kwargs, 70 | ): 71 | goods_service: GoodsService = manager.data.get("goods_service") 72 | data = manager.current_context().dialog_data 73 | 74 | if item_id == NO: 75 | data["result"] = "Goods adding cancelled" 76 | await manager.done() 77 | return 78 | 79 | goods = GoodsCreate( 80 | name=data[GOODS_NAME], 81 | type=GoodsType(data[GOODS_TYPE]), 82 | parent_id=data.get(SELECTED_GOODS), 83 | sku=data.get(GOODS_SKU), 84 | ) 85 | 86 | goods = await goods_service.add_goods(goods) 87 | 88 | result = fmt.quote( 89 | f"Goods created\n\n" 90 | f"id: {goods.id}\n" 91 | f"parent id: {goods.parent_id}\n\n" 92 | f"name: {goods.name}\n" 93 | f"type: {'📁' if goods.type is GoodsType.FOLDER else '📦'}\n" 94 | f"sku: {goods.sku}\n" 95 | ) 96 | data["result"] = result 97 | 98 | await manager.dialog().next() 99 | 100 | 101 | async def result_getter(dialog_manager: DialogManager, **kwargs): 102 | return {"result": dialog_manager.current_context().dialog_data.get("result")} 103 | 104 | 105 | async def back_from_confirm( 106 | query: CallbackQuery, 107 | button: ManagedWidgetAdapter[Button], 108 | manager: DialogManager, 109 | **kwargs, 110 | ): 111 | if manager.current_context().dialog_data.get(GOODS_SKU): 112 | await manager.dialog().back() 113 | else: 114 | await query.answer() 115 | await manager.dialog().switch_to(AddGoods.type) 116 | 117 | 118 | add_goods_dialog = Dialog( 119 | Window( 120 | goods_adding_process, 121 | Const("Input goods name:"), 122 | MessageInput(request_goods_name), 123 | Row(Cancel(Const("❌ Cancel")), Next(Const("➡ Next️"), when=GOODS_NAME)), 124 | getter=get_goods_data, 125 | state=states.goods_db.AddGoods.name, 126 | parse_mode="HTML", 127 | ), 128 | Window( 129 | goods_adding_process, 130 | Const("Select goods type:"), 131 | Select( 132 | Format("{item[0]}"), 133 | id="goods_type", 134 | item_id_getter=itemgetter(1), 135 | items=[ 136 | ("📁 Folder", GoodsType.FOLDER.value), 137 | ("📦 Goods", GoodsType.GOODS.value), 138 | ], 139 | on_click=save_goods_type, 140 | ), 141 | Row( 142 | Back(Const("🔙 Back")), 143 | Cancel(Const("❌ Cancel")), 144 | Next(Const("➡ Next️"), when=GOODS_TYPE), 145 | ), 146 | getter=get_goods_data, 147 | state=states.goods_db.AddGoods.type, 148 | parse_mode="HTML", 149 | ), 150 | Window( 151 | goods_adding_process, 152 | Const("Input SKU:"), 153 | MessageInput(request_goods_sku), 154 | Row( 155 | Back(Const("🔙 Back")), 156 | Cancel(Const("❌ Cancel")), 157 | Next(Const("➡ Next️"), when=GOODS_SKU), 158 | ), 159 | getter=get_goods_data, 160 | state=states.goods_db.AddGoods.sku, 161 | parse_mode="HTML", 162 | ), 163 | Window( 164 | goods_adding_process, 165 | Const("Confirm ?"), 166 | Select( 167 | Format("{item[0]}"), 168 | id="add_yes_no", 169 | item_id_getter=itemgetter(1), 170 | items=YES_NO, 171 | on_click=add_goods_yes_no, 172 | ), 173 | Row( 174 | Back(Const("🔙 Back"), on_click=back_from_confirm), Cancel(Const("❌ Cancel")) 175 | ), 176 | getter=get_goods_data, 177 | state=states.goods_db.AddGoods.confirm, 178 | parse_mode="HTML", 179 | preview_add_transitions=[Next()], 180 | ), 181 | Window( 182 | Format("{result}"), 183 | Cancel(Const("❌ Close"), on_click=enable_send_mode), 184 | getter=[get_goods_data, result_getter], 185 | state=states.goods_db.AddGoods.result, 186 | parse_mode="HTML", 187 | ), 188 | on_start=copy_start_data_to_context, 189 | ) 190 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/goods/common.py: -------------------------------------------------------------------------------- 1 | from aiogram_dialog import DialogManager 2 | from aiogram_dialog.widgets.text import Format, Multi 3 | 4 | from app.tgbot.constants import GOODS_NAME, GOODS_SKU, GOODS_TYPE 5 | from app.tgbot.handlers.dialogs.common import when_not 6 | 7 | goods_adding_process = Multi( 8 | Format(f"Goods name: {{{GOODS_NAME}}}", when=GOODS_NAME), 9 | Format(f"Goods name: ...", when=when_not(GOODS_NAME)), 10 | Format(f"Goods type: {{{GOODS_TYPE}}}", when=GOODS_TYPE), 11 | Format(f"Goods type: ...", when=when_not(GOODS_TYPE)), 12 | Format(f"Goods SKU: {{{GOODS_SKU}}}\n", when=GOODS_SKU), 13 | Format(f"GOODS SKU: ...\n", when=when_not(GOODS_SKU)), 14 | ) 15 | 16 | 17 | async def get_goods_data(dialog_manager: DialogManager, **kwargs): 18 | dialog_data = dialog_manager.current_context().dialog_data 19 | 20 | return { 21 | GOODS_NAME: dialog_data.get(GOODS_NAME), 22 | GOODS_TYPE: dialog_data.get(GOODS_TYPE), 23 | GOODS_SKU: dialog_data.get(GOODS_SKU), 24 | } 25 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/goods/menu.py: -------------------------------------------------------------------------------- 1 | from aiogram_dialog import Dialog, StartMode, Window 2 | from aiogram_dialog.widgets.kbd import Cancel, Start 3 | from aiogram_dialog.widgets.text import Const 4 | 5 | from app.tgbot.states.admin_menu import GoodsCategory 6 | from app.tgbot.states.goods_db import EditGoods 7 | 8 | goods_menu_dialog = Dialog( 9 | Window( 10 | Const("Goods\n\nSelect action"), 11 | Start( 12 | Const("⚙️ Add/Edit"), 13 | id="edit_goods", 14 | state=EditGoods.select_goods, 15 | mode=StartMode.NORMAL, 16 | ), 17 | Cancel(Const("❌ Close")), 18 | state=GoodsCategory.action, 19 | ), 20 | ) 21 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/goods/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram_dialog import DialogRegistry 3 | 4 | from .add import add_goods_dialog 5 | from .edit import ( 6 | edit_goods_dialog, 7 | goods_name_dialog, 8 | goods_sku_dialog, 9 | selected_goods_dialog, 10 | ) 11 | from .menu import goods_menu_dialog 12 | 13 | 14 | def register_goods_db_handlers(admin_router: Router, dialog_registry: DialogRegistry): 15 | dialog_registry.register(goods_menu_dialog, router=admin_router) 16 | 17 | dialog_registry.register(add_goods_dialog, router=admin_router) 18 | dialog_registry.register(edit_goods_dialog, router=admin_router) 19 | dialog_registry.register(selected_goods_dialog, router=admin_router) 20 | dialog_registry.register(goods_name_dialog, router=admin_router) 21 | dialog_registry.register(goods_sku_dialog, router=admin_router) 22 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/market/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/tgbot/handlers/admin/market/__init__.py -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/market/add.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | 3 | from aiogram.types import CallbackQuery, Message 4 | from aiogram.utils.text_decorations import html_decoration as fmt 5 | from aiogram_dialog import Dialog, Window 6 | from aiogram_dialog.manager.protocols import DialogManager, ManagedDialogAdapterProto 7 | from aiogram_dialog.widgets.input import MessageInput 8 | from aiogram_dialog.widgets.kbd import Back, Cancel, Next, Row, Select 9 | from aiogram_dialog.widgets.managed import ManagedWidgetAdapter 10 | from aiogram_dialog.widgets.text import Const, Format 11 | 12 | from app.domain.market.dto.market import MarketCreate 13 | from app.domain.market.exceptions.market import MarketAlreadyExists 14 | from app.domain.market.usecases import MarketService 15 | from app.tgbot import states 16 | from app.tgbot.constants import MARKET_NAME, NO, YES_NO 17 | from app.tgbot.handlers.dialogs.common import enable_send_mode 18 | 19 | 20 | async def request_market_name( 21 | message: Message, 22 | dialog: ManagedDialogAdapterProto, 23 | manager: DialogManager, 24 | **kwargs, 25 | ): 26 | manager.current_context().dialog_data[MARKET_NAME] = message.text 27 | await dialog.next() 28 | 29 | 30 | async def get_market_data(dialog_manager: DialogManager, **kwargs): 31 | dialog_data = dialog_manager.current_context().dialog_data 32 | 33 | return { 34 | MARKET_NAME: dialog_data.get(MARKET_NAME), 35 | } 36 | 37 | 38 | async def add_market_yes_no( 39 | query: CallbackQuery, 40 | select: ManagedWidgetAdapter[Select], 41 | manager: DialogManager, 42 | item_id: str, 43 | **kwargs, 44 | ): 45 | market_service: MarketService = manager.data.get("market_service") 46 | data = manager.current_context().dialog_data 47 | 48 | if item_id == NO: 49 | data["result"] = "Goods adding cancelled" 50 | await manager.done() 51 | return 52 | 53 | market = MarketCreate(name=data[MARKET_NAME]) 54 | 55 | try: 56 | market = await market_service.add_market(market) 57 | except MarketAlreadyExists as err: 58 | await query.answer(str(err), show_alert=True) 59 | await manager.dialog().back() 60 | return 61 | 62 | result = fmt.quote(f"Market created\n\n" f"id: {market.id}\n" f"name: {market.name}\n") 63 | data["result"] = result 64 | 65 | await manager.dialog().next() 66 | 67 | 68 | async def result_getter(dialog_manager: DialogManager, **kwargs): 69 | return {"result": dialog_manager.current_context().dialog_data.get("result")} 70 | 71 | 72 | add_market_dialog = Dialog( 73 | Window( 74 | Const("Input market name:"), 75 | MessageInput(request_market_name), 76 | Row(Cancel(Const("❌ Cancel")), Next(Const("➡ Next️"), when=MARKET_NAME)), 77 | state=states.market_db.AddMarket.name, 78 | getter=get_market_data, 79 | parse_mode="HTML", 80 | ), 81 | Window( 82 | Format(f"Market name: {{{MARKET_NAME}}}\n"), 83 | Const("Confirm ?"), 84 | Select( 85 | Format("{item[0]}"), 86 | id="add_yes_no", 87 | item_id_getter=itemgetter(1), 88 | items=YES_NO, 89 | on_click=add_market_yes_no, 90 | ), 91 | Row(Back(Const("🔙 Back")), Cancel(Const("❌ Cancel"))), 92 | getter=get_market_data, 93 | state=states.market_db.AddMarket.confirm, 94 | parse_mode="HTML", 95 | ), 96 | Window( 97 | Format("{result}"), 98 | Cancel(Const("❌ Close"), on_click=enable_send_mode), 99 | getter=result_getter, 100 | state=states.market_db.AddMarket.result, 101 | parse_mode="HTML", 102 | ), 103 | ) 104 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/market/edit.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter 2 | from uuid import UUID 3 | 4 | from aiogram.types import CallbackQuery, Message 5 | from aiogram_dialog import Dialog, DialogManager, Window 6 | from aiogram_dialog.manager.protocols import ManagedDialogAdapterProto 7 | from aiogram_dialog.widgets.input import MessageInput 8 | from aiogram_dialog.widgets.kbd import ( 9 | Back, 10 | Button, 11 | Cancel, 12 | Next, 13 | ScrollingGroup, 14 | Select, 15 | Start, 16 | ) 17 | from aiogram_dialog.widgets.managed import ManagedWidgetAdapter 18 | from aiogram_dialog.widgets.text import Const, Format 19 | 20 | from app.domain.market.dto import MarketPatch 21 | from app.domain.market.exceptions.market import CantDeleteWithOrders 22 | from app.domain.market.usecases import MarketService 23 | from app.tgbot import states 24 | from app.tgbot.constants import MARKET, SELECTED_MARKET, SELECTOR_MARKET_ID 25 | from app.tgbot.handlers.admin.user.common import copy_start_data_to_context 26 | 27 | 28 | async def add_new_market( 29 | query: CallbackQuery, button: Button, manager: DialogManager, **kwargs 30 | ): 31 | data_for_copy = { 32 | SELECTED_MARKET: manager.current_context().dialog_data.get(SELECTED_MARKET) 33 | } 34 | await manager.start(states.market_db.AddMarket.name, data=data_for_copy) 35 | 36 | 37 | async def get_markets( 38 | dialog_manager: DialogManager, market_service: MarketService, **kwargs 39 | ): 40 | markets = await market_service.get_all_markets(only_active=False) 41 | return {MARKET: markets} 42 | 43 | 44 | async def save_selected_market_id( 45 | query: CallbackQuery, 46 | dialog: ManagedWidgetAdapter[Select], 47 | manager: DialogManager, 48 | item_id: str, 49 | ): 50 | manager.current_context().dialog_data[SELECTED_MARKET] = item_id 51 | await manager.dialog().next() 52 | 53 | 54 | async def start_edit_market_name_dialog( 55 | query: CallbackQuery, 56 | button: Button, 57 | manager: DialogManager, 58 | ): 59 | selected_market = manager.current_context().dialog_data.get(SELECTED_MARKET) 60 | data_for_copy = {SELECTED_MARKET: selected_market} 61 | await manager.start(states.market_db.EditMarketName.request, data=data_for_copy) 62 | 63 | 64 | async def request_name( 65 | message: Message, dialog: ManagedDialogAdapterProto, manager: DialogManager 66 | ): 67 | service: MarketService = manager.data.get("market_service") 68 | selected_market = UUID(manager.current_context().dialog_data.get(SELECTED_MARKET)) 69 | await service.patch_market(MarketPatch(id=selected_market, name=message.text)) 70 | await manager.done() 71 | 72 | 73 | async def delete_market( 74 | query: CallbackQuery, button: Button, manager: DialogManager, **kwargs 75 | ): 76 | goods_service: MarketService = manager.data.get("market_service") 77 | 78 | parent_id = manager.current_context().dialog_data.get(SELECTED_MARKET) 79 | try: 80 | await goods_service.delete_market(UUID(parent_id)) 81 | except CantDeleteWithOrders: 82 | await query.answer("Can't delete market with orders") 83 | return 84 | 85 | await query.answer("Goods deleted") 86 | await manager.dialog().switch_to(states.market_db.EditMarket.select_market) 87 | 88 | 89 | async def change_market_active_status( 90 | query: CallbackQuery, button: Button, manager: DialogManager, **kwargs 91 | ): 92 | market_service: MarketService = manager.data.get("market_service") 93 | 94 | market_id = manager.current_context().dialog_data.get(SELECTED_MARKET) 95 | market_id_as_uuid = UUID(market_id) if market_id is not None else None 96 | market = await market_service.get_market_by_id(market_id_as_uuid) 97 | await market_service.patch_market( 98 | MarketPatch(id=market_id_as_uuid, is_active=not market.is_active) 99 | ) 100 | await manager.dialog().back() 101 | 102 | 103 | async def get_selected_market( 104 | dialog_manager: DialogManager, market_service: MarketService, **kwargs 105 | ): 106 | market = dialog_manager.current_context().dialog_data.get(SELECTED_MARKET) 107 | market = await market_service.get_market_by_id(UUID(market)) 108 | return {MARKET: market} 109 | 110 | 111 | market_name_dialog = Dialog( 112 | Window( 113 | Const("Send new market name"), 114 | MessageInput(request_name), 115 | state=states.market_db.EditMarketName.request, 116 | ), 117 | on_start=copy_start_data_to_context, 118 | ) 119 | 120 | edit_goods_dialog = Dialog( 121 | Window( 122 | Const("Select market for editing:"), 123 | ScrollingGroup( 124 | Select( 125 | Format("{item.active_icon} {item.name}"), 126 | id=SELECTOR_MARKET_ID, 127 | item_id_getter=attrgetter("id"), 128 | items=MARKET, 129 | on_click=save_selected_market_id, 130 | ), 131 | id="market_scrolling", 132 | width=1, 133 | height=8, 134 | ), 135 | Button(Const("➕ Add new"), on_click=add_new_market, id="add_new_market"), 136 | Cancel(Const("❌ Close")), 137 | getter=get_markets, 138 | state=states.market_db.EditMarket.select_market, 139 | preview_add_transitions=[ 140 | Next(), 141 | Start(Const(""), id="", state=states.market_db.AddMarket.name), 142 | ], 143 | ), 144 | Window( 145 | Format(f"Selected market: {{{MARKET}.name}}"), 146 | Const("Select option"), 147 | Button( 148 | Const("Edit name"), on_click=start_edit_market_name_dialog, id="edit_name" 149 | ), 150 | Button( 151 | Const("✅ ❌ Change active status"), 152 | on_click=change_market_active_status, 153 | id="change_status", 154 | ), 155 | Button(Const("🗑️ Delete"), on_click=delete_market, id="delete_goods"), 156 | Back(Const("🔙 Back")), 157 | Cancel(Const("❌ Close")), 158 | getter=get_selected_market, 159 | state=states.market_db.EditMarket.select_action, 160 | preview_add_transitions=[ 161 | Start(Const(""), id="", state=states.market_db.EditMarketName.request) 162 | ], 163 | ), 164 | ) 165 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/market/menu.py: -------------------------------------------------------------------------------- 1 | from aiogram_dialog import Dialog, StartMode, Window 2 | from aiogram_dialog.widgets.kbd import Cancel, Start 3 | from aiogram_dialog.widgets.text import Const 4 | 5 | from app.tgbot.states.admin_menu import MarketCategory 6 | from app.tgbot.states.market_db import EditMarket 7 | 8 | market_menu_dialog = Dialog( 9 | Window( 10 | Const("Market\n\nSelect action"), 11 | Start( 12 | Const("⚙️ Add/Edit"), 13 | id="edit_market", 14 | state=EditMarket.select_market, 15 | mode=StartMode.NORMAL, 16 | ), 17 | Cancel(Const("❌ Close")), 18 | state=MarketCategory.action, 19 | ), 20 | ) 21 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/market/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram_dialog import DialogRegistry 3 | 4 | from .add import add_market_dialog 5 | from .edit import edit_goods_dialog, market_name_dialog 6 | from .menu import market_menu_dialog 7 | 8 | 9 | def register_market_db_handlers(admin_router: Router, dialog_registry: DialogRegistry): 10 | dialog_registry.register(market_menu_dialog, router=admin_router) 11 | 12 | dialog_registry.register(add_market_dialog, router=admin_router) 13 | dialog_registry.register(edit_goods_dialog, router=admin_router) 14 | dialog_registry.register(market_name_dialog, router=admin_router) 15 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/menu.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.dispatcher.fsm.state import any_state 3 | from aiogram.types import Message 4 | from aiogram_dialog import Dialog, DialogManager, StartMode, Window 5 | from aiogram_dialog.widgets.kbd import Cancel, Start 6 | from aiogram_dialog.widgets.text import Const 7 | 8 | from app.tgbot.states import admin_menu 9 | 10 | admin_menu_dialog = Dialog( 11 | Window( 12 | Const("Select category"), 13 | Start(Const("👤 User"), id="user_menu", state=admin_menu.UserCategory.action), 14 | Start(Const("📦 Goods"), id="goods_menu", state=admin_menu.GoodsCategory.action), 15 | Start( 16 | Const("🌍 Market"), id="market_menu", state=admin_menu.MarketCategory.action 17 | ), 18 | Cancel(Const("❌ Close")), 19 | state=admin_menu.AdminMenu.category, 20 | ), 21 | ) 22 | 23 | 24 | async def admin_menu_entry(message: Message, dialog_manager: DialogManager): 25 | await dialog_manager.start( 26 | admin_menu.AdminMenu.category, mode=StartMode.RESET_STACK 27 | ) 28 | 29 | 30 | def register_admin_menu(dp: Router): 31 | dp.message.register(admin_menu_entry, any_state, commands=["admin"]) 32 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram_dialog import DialogRegistry 3 | 4 | from .goods import register_goods_db_handlers 5 | from .market.setup import register_market_db_handlers 6 | from .menu import admin_menu_dialog, register_admin_menu 7 | from .user import register_user_db_handlers 8 | 9 | 10 | def register_admin_handlers(admin_router: Router, dialog_registry: DialogRegistry): 11 | register_admin_menu(admin_router) 12 | dialog_registry.register(admin_menu_dialog, router=admin_router) 13 | 14 | register_user_db_handlers(admin_router, dialog_registry) 15 | register_goods_db_handlers(admin_router, dialog_registry) 16 | register_market_db_handlers(admin_router, dialog_registry) 17 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_user_db_handlers 2 | 3 | __all__ = ["register_user_db_handlers"] 4 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/add.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | 3 | from aiogram.types import CallbackQuery, Message 4 | from aiogram.utils.text_decorations import html_decoration as fmt 5 | from aiogram_dialog import Dialog, DialogManager, Window 6 | from aiogram_dialog.manager.protocols import ManagedDialogAdapterProto 7 | from aiogram_dialog.widgets.input import MessageInput 8 | from aiogram_dialog.widgets.kbd import ( 9 | Back, 10 | Button, 11 | Cancel, 12 | Column, 13 | Multiselect, 14 | Next, 15 | Row, 16 | Select, 17 | ) 18 | from aiogram_dialog.widgets.managed import ManagedWidgetAdapter 19 | from aiogram_dialog.widgets.text import Const, Format 20 | 21 | from app.domain.access_levels.usecases.access_levels import AccessLevelsService 22 | from app.domain.user.dto.user import UserCreate 23 | from app.domain.user.exceptions.user import BlockedUserWithOtherRole, UserAlreadyExists 24 | from app.domain.user.usecases.user import UserService 25 | from app.tgbot import states 26 | from app.tgbot.constants import ALL_ACCESS_LEVELS, NO, USER_ID, YES_NO 27 | from app.tgbot.handlers.admin.user.common import ( 28 | ACCESS_LEVELS, 29 | USER_NAME, 30 | get_user_data, 31 | save_selected_access_levels, 32 | user_adding_process, 33 | ) 34 | from app.tgbot.handlers.dialogs.common import enable_send_mode, get_result 35 | 36 | 37 | async def request_id( 38 | message: Message, dialog: ManagedDialogAdapterProto, manager: DialogManager 39 | ): 40 | if not message.text.isdigit(): 41 | await message.answer("User id must be a number") 42 | return 43 | 44 | manager.current_context().dialog_data[USER_ID] = message.text 45 | await dialog.next() 46 | 47 | 48 | async def request_name( 49 | message: Message, dialog: ManagedDialogAdapterProto, manager: DialogManager 50 | ): 51 | manager.current_context().dialog_data[USER_NAME] = message.text 52 | await dialog.next() 53 | 54 | 55 | async def get_access_levels( 56 | dialog_manager: DialogManager, access_levels_service: AccessLevelsService, **kwargs 57 | ): 58 | access_levels = await access_levels_service.get_access_levels() 59 | access_levels = [(level.name.name, level.id) for level in access_levels] 60 | 61 | access_levels = { 62 | ALL_ACCESS_LEVELS: access_levels, 63 | } 64 | user_data = await get_user_data(dialog_manager, access_levels_service) 65 | 66 | return user_data | access_levels 67 | 68 | 69 | async def add_user_yes_no( 70 | query: CallbackQuery, 71 | select: ManagedWidgetAdapter[Select], 72 | manager: DialogManager, 73 | item_id: str, 74 | ): 75 | user_service: UserService = manager.data.get("user_service") 76 | data = manager.current_context().dialog_data 77 | 78 | if item_id == NO: 79 | await query.answer("User adding cancelled") 80 | await manager.done() 81 | return 82 | 83 | user = UserCreate( 84 | id=data[USER_ID], name=data[USER_NAME], access_levels=data[ACCESS_LEVELS] 85 | ) 86 | try: 87 | new_user = await user_service.add_user(user) 88 | levels_names = ", ".join((level.name.name for level in new_user.access_levels)) 89 | 90 | result = ( 91 | f"User created\n" 92 | f"id: {data[USER_ID]}\n" 93 | f"name: {fmt.quote(data[USER_NAME])}\n" 94 | f"access level: {levels_names}\n" 95 | ) 96 | data["result"] = result 97 | 98 | except UserAlreadyExists: 99 | data["result"] = "User already exist" 100 | 101 | except BlockedUserWithOtherRole: 102 | await query.answer("Blocked user can have only that role") 103 | return 104 | 105 | await manager.dialog().next() 106 | await query.answer() 107 | 108 | 109 | add_user_dialog = Dialog( 110 | Window( 111 | user_adding_process, 112 | Const("Input user id:"), 113 | MessageInput(request_id), 114 | Row(Cancel(Const("❌ Cancel")), Next(Const("➡ Next️"), when=USER_ID)), 115 | getter=get_user_data, 116 | state=states.user_db.AddUser.id, 117 | parse_mode="HTML", 118 | ), 119 | Window( 120 | user_adding_process, 121 | Format("Input user name:"), 122 | MessageInput(request_name), 123 | Row( 124 | Back(Const("⬅️ Back")), 125 | Cancel(Const("❌ Cancel")), 126 | Next(Const("➡ Next️"), when=USER_NAME), 127 | ), 128 | getter=get_user_data, 129 | state=states.user_db.AddUser.name, 130 | parse_mode="HTML", 131 | ), 132 | Window( 133 | user_adding_process, 134 | Const("Select access level"), 135 | Column( 136 | Multiselect( 137 | Format("✓ {item[0]}"), 138 | Format("{item[0]}"), 139 | id=ACCESS_LEVELS, 140 | item_id_getter=itemgetter(1), 141 | items=ALL_ACCESS_LEVELS, 142 | ) 143 | ), 144 | Button( 145 | Const("💾 Save"), 146 | id="save_access_levels", 147 | on_click=save_selected_access_levels, 148 | ), 149 | Row( 150 | Back(Const("⬅️ Back")), 151 | Cancel(Const("❌ Cancel")), 152 | Next(Const("➡ Next️"), when=ACCESS_LEVELS), 153 | ), 154 | getter=get_access_levels, 155 | state=states.user_db.AddUser.access_level, 156 | parse_mode="HTML", 157 | ), 158 | Window( 159 | user_adding_process, 160 | Const("Confirm ?"), 161 | Select( 162 | Format("{item[0]}"), 163 | id="add_yes_no", 164 | item_id_getter=itemgetter(1), 165 | items=YES_NO, 166 | on_click=add_user_yes_no, 167 | ), 168 | Row(Back(Const("⬅️ Back")), Cancel(Const("❌ Cancel"))), 169 | getter=get_user_data, 170 | state=states.user_db.AddUser.confirm, 171 | parse_mode="HTML", 172 | preview_add_transitions=[Next()], 173 | ), 174 | Window( 175 | Format("{result}"), 176 | Cancel(Const("❌ Close"), on_click=enable_send_mode), 177 | getter=get_result, 178 | state=states.user_db.AddUser.result, 179 | parse_mode="HTML", 180 | ), 181 | ) 182 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/common.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import CallbackQuery 2 | from aiogram.utils.text_decorations import html_decoration as fmt 3 | from aiogram_dialog import DialogManager 4 | from aiogram_dialog.widgets.kbd import Multiselect, Select 5 | from aiogram_dialog.widgets.managed import ManagedWidgetAdapter 6 | from aiogram_dialog.widgets.text import Format, Multi 7 | 8 | from app.domain.access_levels.usecases.access_levels import AccessLevelsService 9 | from app.domain.user.exceptions.user import UserNotExists 10 | from app.domain.user.usecases.user import UserService 11 | from app.tgbot.constants import ACCESS_LEVELS, USER, USER_ID, USER_NAME, USERS 12 | from app.tgbot.handlers.dialogs.common import when_not 13 | 14 | 15 | async def get_users(dialog_manager: DialogManager, user_service: UserService, **kwargs): 16 | users = await user_service.get_users() 17 | return {USERS: users} 18 | 19 | 20 | async def save_user_id( 21 | query: CallbackQuery, 22 | select: ManagedWidgetAdapter[Select], 23 | manager: DialogManager, 24 | item_id: str, 25 | ): 26 | manager.current_context().dialog_data[USER_ID] = item_id 27 | await manager.dialog().next() 28 | await query.answer() 29 | 30 | 31 | async def get_user(dialog_manager: DialogManager, user_service: UserService, **kwargs): 32 | user_id = dialog_manager.current_context().dialog_data[USER_ID] 33 | try: 34 | user = await user_service.get_user(int(user_id)) 35 | except UserNotExists: # ToDo check if need 36 | user = None 37 | return {USER: user} 38 | 39 | 40 | user_adding_process = Multi( 41 | Format(f"User id: {{{USER_ID}}}", when=USER_ID), 42 | Format(f"User id: ...", when=when_not(USER_ID)), 43 | Format(f"User name: {{{USER_NAME}}}", when=USER_NAME), 44 | Format(f"User name: ...", when=when_not(USER_NAME)), 45 | Format(f"Access levels: {{{ACCESS_LEVELS}}}\n", when=ACCESS_LEVELS), 46 | Format(f"Access levels: ...\n", when=when_not(ACCESS_LEVELS)), 47 | ) 48 | 49 | 50 | async def get_user_data( 51 | dialog_manager: DialogManager, access_levels_service: AccessLevelsService, **kwargs 52 | ): 53 | dialog_data = dialog_manager.current_context().dialog_data 54 | 55 | levels = [] 56 | levels_ids = dialog_data.get(ACCESS_LEVELS) 57 | if levels_ids: 58 | all_levels = await access_levels_service.get_access_levels() 59 | for level in all_levels: 60 | if str(level.id) in levels_ids: 61 | levels.append(level) 62 | 63 | return { 64 | USER_ID: dialog_data.get(USER_ID), 65 | USER_NAME: fmt.quote(dialog_data.get(USER_NAME)) 66 | if dialog_data.get(USER_NAME) 67 | else None, 68 | ACCESS_LEVELS: ", ".join((level.name.name for level in levels)), 69 | } 70 | 71 | 72 | async def save_selected_access_levels( 73 | event: CallbackQuery, button, manager: DialogManager, **kwargs 74 | ): 75 | 76 | access_levels: Multiselect = manager.dialog().find(ACCESS_LEVELS) 77 | selected_levels = access_levels.get_checked(manager) 78 | 79 | if not selected_levels: 80 | await event.answer("select at least one level") 81 | return 82 | 83 | manager.current_context().dialog_data[ACCESS_LEVELS] = selected_levels 84 | await manager.dialog().next() 85 | 86 | 87 | async def copy_start_data_to_context(_, dialog_manager: DialogManager): 88 | dialog_manager.current_context().dialog_data.update( 89 | dialog_manager.current_context().start_data 90 | ) 91 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/delete.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter, itemgetter 2 | 3 | from aiogram.types import CallbackQuery 4 | from aiogram.utils.text_decorations import html_decoration as fmt 5 | from aiogram_dialog import Dialog, DialogManager, Window 6 | from aiogram_dialog.widgets.kbd import Back, Cancel, Next, Row, ScrollingGroup, Select 7 | from aiogram_dialog.widgets.managed import ManagedWidgetAdapter 8 | from aiogram_dialog.widgets.text import Const, Format 9 | 10 | from app.domain.user.exceptions.user import CantDeleteWithOrders 11 | from app.domain.user.usecases.user import UserService 12 | from app.tgbot import states 13 | from app.tgbot.constants import NO, USER_ID, USERS, YES_NO 14 | from app.tgbot.handlers.admin.user.common import get_user, get_users, save_user_id 15 | from app.tgbot.handlers.dialogs.common import enable_send_mode, get_result 16 | 17 | 18 | async def delete_user_yes_no( 19 | query: CallbackQuery, 20 | select_: ManagedWidgetAdapter[Select], 21 | manager: DialogManager, 22 | item_id: str, 23 | ): 24 | user_service: UserService = manager.data.get("user_service") 25 | data = manager.current_context().dialog_data 26 | 27 | if item_id == NO: 28 | await query.answer("User deleting cancelled", show_alert=True) 29 | await manager.dialog().back() 30 | return 31 | try: 32 | await user_service.delete_user(int(data[USER_ID])) 33 | except CantDeleteWithOrders: 34 | await query.answer( 35 | "User can't be deleted because he has orders", show_alert=True 36 | ) 37 | await manager.dialog().back() 38 | return 39 | data["result"] = f"User {data[USER_ID]} deleted" 40 | await manager.dialog().next() 41 | 42 | await query.answer() 43 | 44 | 45 | delete_user_dialog = Dialog( 46 | Window( 47 | Const("Select user for deleting:"), 48 | ScrollingGroup( 49 | Select( 50 | Format("{item.name} {item.id}"), 51 | id=USER_ID, 52 | item_id_getter=attrgetter("id"), 53 | items=USERS, 54 | on_click=save_user_id, 55 | ), 56 | id="user_scrolling", 57 | width=1, 58 | height=5, 59 | ), 60 | Cancel(Const("❌ Cancel")), 61 | getter=get_users, 62 | state=states.user_db.DeleteUser.select_user, 63 | preview_add_transitions=[Next()], 64 | ), 65 | Window( 66 | Format("User:\n\nid: {user.id}\nname: {user.name}\n\nDelete?"), 67 | Select( 68 | Format("{item[0]}"), 69 | id="delete_yes_no", 70 | item_id_getter=itemgetter(1), 71 | items=YES_NO, 72 | on_click=delete_user_yes_no, 73 | ), 74 | Row(Back(Const("⬅️ Back")), Cancel(Const("❌ Cancel"))), 75 | getter=get_user, 76 | state=states.user_db.DeleteUser.confirm, 77 | preview_add_transitions=[Next()], 78 | ), 79 | Window( 80 | Format("{result}"), 81 | Cancel(Const("❌ Close"), on_click=enable_send_mode), 82 | getter=get_result, 83 | state=states.user_db.DeleteUser.result, 84 | parse_mode="HTML", 85 | ), 86 | ) 87 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/menu.py: -------------------------------------------------------------------------------- 1 | from aiogram_dialog import Dialog, StartMode, Window 2 | from aiogram_dialog.widgets.kbd import Cancel, Start 3 | from aiogram_dialog.widgets.text import Const 4 | 5 | from app.tgbot.states.admin_menu import UserCategory 6 | from app.tgbot.states.user_db import AddUser, DeleteUser, EditUser 7 | 8 | user_menu_dialog = Dialog( 9 | Window( 10 | Const("User\n\nSelect action"), 11 | Start(Const("➕ Add"), id="add_user", state=AddUser.id, mode=StartMode.NORMAL), 12 | Start( 13 | Const("⚙️ Edit"), 14 | id="edit_user", 15 | state=EditUser.select_user, 16 | mode=StartMode.NORMAL, 17 | ), 18 | Start( 19 | Const("🗑️ Delete"), 20 | id="delete_user", 21 | state=DeleteUser.select_user, 22 | mode=StartMode.NORMAL, 23 | ), 24 | Cancel(Const("❌ Close")), 25 | state=UserCategory.action, 26 | ), 27 | ) 28 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram_dialog import DialogRegistry 3 | 4 | from .add import add_user_dialog 5 | from .delete import delete_user_dialog 6 | from .edit import ( 7 | edit_user_dialog, 8 | user_access_levels_dialog, 9 | user_id_dialog, 10 | user_name_dialog, 11 | ) 12 | from .menu import user_menu_dialog 13 | 14 | 15 | def register_user_db_handlers(admin_router: Router, dialog_registry: DialogRegistry): 16 | dialog_registry.register(user_menu_dialog, router=admin_router) 17 | 18 | dialog_registry.register(add_user_dialog, router=admin_router) 19 | 20 | dialog_registry.register(edit_user_dialog, router=admin_router) 21 | dialog_registry.register(user_id_dialog, router=admin_router) 22 | dialog_registry.register(user_name_dialog, router=admin_router) 23 | dialog_registry.register(user_access_levels_dialog, router=admin_router) 24 | 25 | dialog_registry.register(delete_user_dialog, router=admin_router) 26 | -------------------------------------------------------------------------------- /app/tgbot/handlers/chief/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_chief_handlers 2 | 3 | __all__ = ["register_chief_handlers"] 4 | -------------------------------------------------------------------------------- /app/tgbot/handlers/chief/order_confirm.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from uuid import UUID 3 | 4 | from aiogram import Bot, Router 5 | from aiogram.dispatcher.filters.callback_data import CallbackData 6 | from aiogram.exceptions import TelegramAPIError 7 | from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup 8 | 9 | from app.domain.order.exceptions.order import OrderAlreadyConfirmed 10 | from app.domain.order.usecases.order import OrderService 11 | from app.domain.order.value_objects.confirmed_status import ConfirmedStatus 12 | from app.domain.user.dto import User 13 | from app.tgbot.handlers.message_templates import format_order_message 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class OrderConfirm(CallbackData, prefix="order_confirm"): 19 | order_id: UUID 20 | result: bool 21 | 22 | 23 | def confirm_order_keyboard(order_id: UUID): 24 | keyboard = InlineKeyboardMarkup( 25 | inline_keyboard=[ 26 | [ 27 | InlineKeyboardButton( 28 | text="✅ Confirm", 29 | callback_data=OrderConfirm(order_id=order_id, result=True).pack(), 30 | ) 31 | ], 32 | [ 33 | InlineKeyboardButton( 34 | text="❌ Cancel", 35 | callback_data=OrderConfirm(order_id=order_id, result=False).pack(), 36 | ) 37 | ], 38 | ] 39 | ) 40 | return keyboard 41 | 42 | 43 | async def confirm_order_usecase( 44 | query: CallbackQuery, 45 | order_service: OrderService, 46 | user: User, 47 | bot: Bot, 48 | order_id: UUID, 49 | result: bool, 50 | delete_reply_markup: bool, 51 | ): 52 | confirmed_status = ConfirmedStatus.YES if result else ConfirmedStatus.NO 53 | 54 | try: 55 | await order_service.change_confirm_status( 56 | order_id, confirmed_status=confirmed_status, confirmed_by=user 57 | ) 58 | except OrderAlreadyConfirmed: 59 | await query.answer("Order already confirmed") 60 | try: 61 | if delete_reply_markup: 62 | await query.message.edit_reply_markup(reply_markup=None) 63 | except TelegramAPIError: 64 | # If reply_markup is already deleted 65 | pass 66 | if result: 67 | await query.answer("Order confirmed") 68 | else: 69 | await query.answer("Order canceled") 70 | order = await order_service.get_order_by_id(order_id) 71 | 72 | for message in order.order_messages: 73 | try: 74 | await bot.edit_message_text( 75 | text=format_order_message(order), 76 | chat_id=message.chat_id, 77 | message_id=message.message_id, 78 | reply_markup=None, 79 | ) 80 | except TelegramAPIError as e: 81 | logger.error(e) 82 | continue 83 | 84 | 85 | async def confirm_order( 86 | query: CallbackQuery, 87 | callback_data: OrderConfirm, 88 | order_service: OrderService, 89 | user: User, 90 | bot: Bot, 91 | ): 92 | await confirm_order_usecase( 93 | query=query, 94 | order_service=order_service, 95 | user=user, 96 | bot=bot, 97 | order_id=callback_data.order_id, 98 | result=callback_data.result, 99 | delete_reply_markup=True, 100 | ) 101 | 102 | 103 | def register_handlers(router: Router): 104 | router.callback_query.register(confirm_order, OrderConfirm.filter()) 105 | -------------------------------------------------------------------------------- /app/tgbot/handlers/chief/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | from app.tgbot.handlers.chief.order_confirm import register_handlers 4 | 5 | 6 | def register_chief_handlers(router: Router): 7 | register_handlers(router=router) 8 | -------------------------------------------------------------------------------- /app/tgbot/handlers/dialogs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/tgbot/handlers/dialogs/__init__.py -------------------------------------------------------------------------------- /app/tgbot/handlers/dialogs/common.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import CallbackQuery 2 | from aiogram_dialog import DialogManager, ShowMode 3 | 4 | 5 | async def enable_send_mode( 6 | event: CallbackQuery, button, dialog_manager: DialogManager, **kwargs 7 | ): 8 | dialog_manager.show_mode = ShowMode.SEND 9 | 10 | 11 | async def get_result(dialog_manager: DialogManager, **kwargs): 12 | return { 13 | "result": dialog_manager.current_context().dialog_data["result"], 14 | } 15 | 16 | 17 | def when_not(key: str): 18 | def f(data, whenable, manager): 19 | return not data.get(key) 20 | 21 | return f 22 | -------------------------------------------------------------------------------- /app/tgbot/handlers/message_templates.py: -------------------------------------------------------------------------------- 1 | from aiogram.utils.text_decorations import html_decoration as fmt 2 | 3 | from app.domain.order import dto 4 | 5 | 6 | def format_order_message(order: dto.Order): 7 | result = fmt.quote( 8 | f"Id: {str(order.id)}\n" 9 | f"Created at: {order.created_at}\n" 10 | f"Creator: {order.creator.name}\n" 11 | f"Market: {order.recipient_market.name}\n" 12 | f"Comments: {order.commentary}\n\n" 13 | f"Status: {order.confirmed.value} {order.confirmed_icon}\n" 14 | f"Goods:\n" 15 | ) 16 | for line in order.order_lines: 17 | result += fmt.quote( 18 | f" Name: {line.goods.name} {line.goods.sku}\n" 19 | f" Quantity: {line.quantity}\n\n" 20 | ) 21 | result = result 22 | return result 23 | -------------------------------------------------------------------------------- /app/tgbot/handlers/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher, Router 2 | from aiogram_dialog import DialogRegistry 3 | 4 | from ...domain.access_levels.models.access_level import LevelName 5 | from ..filters import AccessLevelFilter 6 | from .admin import register_admin_handlers 7 | from .chief import register_chief_handlers 8 | from .user import register_user_handlers 9 | from .user.start import register_start 10 | 11 | 12 | def register_handlers(dp: Dispatcher, dialog_registry: DialogRegistry): 13 | register_start(dp) 14 | 15 | # admin router 16 | admin_router = Router() 17 | dp.include_router(admin_router) 18 | admin_router.message.filter( 19 | AccessLevelFilter(access_levels=LevelName.ADMINISTRATOR) 20 | ) 21 | admin_router.callback_query.filter( 22 | AccessLevelFilter(access_levels=LevelName.ADMINISTRATOR) 23 | ) 24 | 25 | register_admin_handlers(admin_router, dialog_registry) 26 | 27 | # chief router 28 | chief_router = Router() 29 | dp.include_router(chief_router) 30 | register_chief_handlers(chief_router) 31 | 32 | # user router 33 | allowed_access_levels = [ 34 | LevelName.USER, 35 | LevelName.CONFIRMATION, 36 | LevelName.ADMINISTRATOR, 37 | ] 38 | user_router = Router() 39 | user_router.message.filter(AccessLevelFilter(access_levels=allowed_access_levels)) 40 | user_router.callback_query.filter( 41 | AccessLevelFilter(access_levels=allowed_access_levels) 42 | ) 43 | dp.include_router(user_router) 44 | register_user_handlers(dp, user_router, dialog_registry) 45 | -------------------------------------------------------------------------------- /app/tgbot/handlers/user/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_user_handlers 2 | 3 | __all__ = ["register_user_handlers"] 4 | -------------------------------------------------------------------------------- /app/tgbot/handlers/user/common.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/tgbot/handlers/user/common.py -------------------------------------------------------------------------------- /app/tgbot/handlers/user/help_.py: -------------------------------------------------------------------------------- 1 | from aiogram_dialog import Dialog, Window 2 | from aiogram_dialog.widgets.kbd import Cancel 3 | from aiogram_dialog.widgets.text import Const, Format 4 | 5 | from app.tgbot.states import help_ 6 | 7 | help_dialog = Dialog( 8 | Window( 9 | Format( 10 | "📚 Help\n Coming soon...", 11 | ), 12 | Cancel(Const("❌ Close")), 13 | state=help_.Help.show, 14 | ), 15 | ) 16 | -------------------------------------------------------------------------------- /app/tgbot/handlers/user/main_menu.py: -------------------------------------------------------------------------------- 1 | from aiogram import F 2 | from aiogram_dialog import Dialog, DialogManager, Window 3 | from aiogram_dialog.widgets.kbd import Start 4 | from aiogram_dialog.widgets.text import Const 5 | 6 | from app.domain.user.dto import User 7 | from app.tgbot.states import add_order, admin_menu, help_, history, main_menu 8 | 9 | 10 | async def get_user(dialog_manager: DialogManager, user: User, **kwargs): 11 | return {"user": user} 12 | 13 | 14 | main_menu_dialog = Dialog( 15 | Window( 16 | Const("Select an option"), 17 | Start( 18 | Const("➕ Add order"), id="add_order", state=add_order.AddOrder.select_goods 19 | ), 20 | Start( 21 | Const("📜 History"), id="history", state=history.History.select_history_level 22 | ), 23 | Start( 24 | Const("🛠 Admin menu"), 25 | id="admin_menu", 26 | state=admin_menu.AdminMenu.category, 27 | when=F["user"].is_admin, 28 | ), 29 | Start(Const("❓ Help"), id="help", state=help_.Help.show), 30 | getter=get_user, 31 | state=main_menu.MainMenu.select_option, 32 | ), 33 | ) 34 | -------------------------------------------------------------------------------- /app/tgbot/handlers/user/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher, Router 2 | from aiogram_dialog import DialogRegistry 3 | 4 | from .add_order import add_order_dialog 5 | from .help_ import help_dialog 6 | from .history import history_dialog 7 | from .main_menu import main_menu_dialog 8 | 9 | 10 | def register_user_handlers( 11 | dp: Dispatcher, user_router: Router, dialog_registry: DialogRegistry 12 | ): 13 | dialog_registry.register(main_menu_dialog, router=user_router) 14 | dialog_registry.register(add_order_dialog, router=user_router) 15 | dialog_registry.register(history_dialog, router=user_router) 16 | dialog_registry.register(help_dialog, router=user_router) 17 | -------------------------------------------------------------------------------- /app/tgbot/handlers/user/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.dispatcher.filters.command import CommandStart 3 | from aiogram.dispatcher.fsm.state import any_state 4 | from aiogram.types import CallbackQuery, Message 5 | from aiogram.utils.text_decorations import html_decoration as fmt 6 | from aiogram_dialog import DialogManager, StartMode 7 | 8 | from app.domain.access_levels.models.access_level import LevelName 9 | from app.tgbot.filters import AccessLevelFilter 10 | from app.tgbot.states import main_menu 11 | 12 | 13 | async def user_start_unregistered_user(m: Message): 14 | await m.answer( 15 | f"You are not registered. Please, contact bot administrator. Your id is {fmt.pre(m.from_user.id)}" 16 | ) 17 | 18 | 19 | async def user_unregister_or_blocked(q: CallbackQuery, dialog_manager: DialogManager): 20 | await q.answer("You are unregistered. Please, contact bot administrator.") 21 | 22 | 23 | async def user_start(m: Message, dialog_manager: DialogManager): 24 | await dialog_manager.start( 25 | state=main_menu.MainMenu.select_option, mode=StartMode.RESET_STACK 26 | ) 27 | 28 | 29 | def register_start(dp: Dispatcher): 30 | dp.message.register( 31 | user_start_unregistered_user, 32 | CommandStart(), 33 | any_state, 34 | AccessLevelFilter(access_levels=[]), 35 | ) 36 | dp.message.register( 37 | user_start_unregistered_user, 38 | any_state, 39 | AccessLevelFilter(access_levels=[LevelName.BLOCKED]), 40 | ) 41 | dp.callback_query.register( 42 | user_unregister_or_blocked, AccessLevelFilter(access_levels=[LevelName.BLOCKED]) 43 | ) 44 | dp.message.register( 45 | user_start, 46 | CommandStart(), 47 | any_state, 48 | AccessLevelFilter(access_levels=list(LevelName)), 49 | ) 50 | -------------------------------------------------------------------------------- /app/tgbot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import setup_middlewares 2 | 3 | __all__ = ["setup_middlewares"] 4 | -------------------------------------------------------------------------------- /app/tgbot/middlewares/database.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Dict 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import Update 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | from app.infrastructure.database.repositories import AccessLevelReader, UserRepo 8 | from app.infrastructure.database.repositories.goods import GoodsReader, GoodsRepo 9 | from app.infrastructure.database.repositories.market import MarketReader, MarketRepo 10 | from app.infrastructure.database.repositories.order import OrderReader, OrderRepo 11 | from app.infrastructure.database.repositories.user import UserReader 12 | from app.infrastructure.database.uow import SQLAlchemyUoW 13 | 14 | 15 | class Database(BaseMiddleware): 16 | def __init__(self, sm: sessionmaker) -> None: 17 | self.Session = sm 18 | 19 | async def __call__( 20 | self, 21 | handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], 22 | event: Update, 23 | data: Dict[str, Any], 24 | ) -> Any: 25 | 26 | async with self.Session() as session: 27 | data["session"] = session 28 | data["uow"] = SQLAlchemyUoW( 29 | session=session, 30 | user_repo=UserRepo, 31 | access_level_reader=AccessLevelReader, 32 | user_reader=UserReader, 33 | goods_repo=GoodsRepo, 34 | goods_reader=GoodsReader, 35 | market_repo=MarketRepo, 36 | market_reader=MarketReader, 37 | order_repo=OrderRepo, 38 | order_reader=OrderReader, 39 | ) 40 | 41 | return await handler(event, data) 42 | -------------------------------------------------------------------------------- /app/tgbot/middlewares/services.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Dict 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import Update 5 | 6 | from app.domain.access_levels.access_policy import UserBasedAccessLevelsAccessPolicy 7 | from app.domain.access_levels.usecases.access_levels import AccessLevelsService 8 | from app.domain.goods.access_policy import UserBasedGoodsAccessPolicy 9 | from app.domain.goods.usecases.goods import GoodsService 10 | from app.domain.market.access_policy import UserBasedMarketAccessPolicy 11 | from app.domain.market.usecases.market import MarketService 12 | from app.domain.order.access_policy import UserBasedOrderAccessPolicy 13 | from app.domain.order.usecases.order import OrderService 14 | from app.domain.user.access_policy import UserBasedUserAccessPolicy 15 | from app.domain.user.dto import User 16 | from app.domain.user.usecases.user import UserService 17 | 18 | 19 | class Services(BaseMiddleware): 20 | async def __call__( 21 | self, 22 | handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], 23 | event: Update, 24 | data: Dict[str, Any], 25 | ) -> Any: 26 | event_dispatcher = data.get("event_dispatcher") 27 | uow = data.get("uow") 28 | user: User = data.get("user") 29 | 30 | data["user_service"] = UserService( 31 | uow=uow, 32 | access_policy=UserBasedUserAccessPolicy(user), 33 | event_dispatcher=event_dispatcher, 34 | ) 35 | data["access_levels_service"] = AccessLevelsService( 36 | uow=uow, 37 | access_policy=UserBasedAccessLevelsAccessPolicy(user), 38 | event_dispatcher=event_dispatcher, 39 | ) 40 | data["goods_service"] = GoodsService( 41 | uow=uow, 42 | access_policy=UserBasedGoodsAccessPolicy(user), 43 | event_dispatcher=event_dispatcher, 44 | ) 45 | data["market_service"] = MarketService( 46 | uow=uow, 47 | access_policy=UserBasedMarketAccessPolicy(user), 48 | event_dispatcher=event_dispatcher, 49 | ) 50 | data["order_service"] = OrderService( 51 | uow=uow, 52 | access_policy=UserBasedOrderAccessPolicy(user), 53 | event_dispatcher=event_dispatcher, 54 | ) 55 | 56 | return await handler(event, data) 57 | -------------------------------------------------------------------------------- /app/tgbot/middlewares/setup.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy.orm 2 | from aiogram import Dispatcher 3 | 4 | from .database import Database 5 | from .services import Services 6 | from .user import UserDB 7 | 8 | 9 | def setup_middlewares( 10 | dp: Dispatcher, 11 | sessionmaker: sqlalchemy.orm.sessionmaker, 12 | ): 13 | dp.update.outer_middleware(Database(sessionmaker)) 14 | dp.update.outer_middleware(UserDB()) 15 | dp.update.outer_middleware(Services()) 16 | -------------------------------------------------------------------------------- /app/tgbot/middlewares/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Dict 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import Update 5 | 6 | from app.domain.user.exceptions.user import UserNotExists 7 | from app.domain.user.interfaces.uow import IUserUoW 8 | from app.domain.user.usecases.user import GetUser 9 | 10 | 11 | class UserDB(BaseMiddleware): 12 | async def __call__( 13 | self, 14 | handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], 15 | event: Update, 16 | data: Dict[str, Any], 17 | ) -> Any: 18 | 19 | event_user_id = data["event_from_user"] 20 | event_dispatcher = data["event_dispatcher"] 21 | if event_user_id: 22 | from_user_id = event_user_id.id 23 | 24 | uow: IUserUoW = data["uow"] 25 | try: 26 | user = await GetUser(uow=uow, event_dispatcher=event_dispatcher)( 27 | int(from_user_id) 28 | ) 29 | except UserNotExists: 30 | user = None 31 | 32 | data["user"] = user 33 | 34 | return await handler(event, data) 35 | -------------------------------------------------------------------------------- /app/tgbot/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/app/tgbot/services/__init__.py -------------------------------------------------------------------------------- /app/tgbot/services/set_commands.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot 2 | from aiogram.types import BotCommand, BotCommandScopeChat, BotCommandScopeDefault 3 | 4 | from app.config import Settings 5 | 6 | 7 | async def set_commands(bot: Bot, settings: Settings): 8 | commands = [ 9 | BotCommand( 10 | command="start", 11 | description="Start", 12 | ), 13 | ] 14 | 15 | admin_commands = commands.copy() 16 | admin_commands.append( 17 | BotCommand( 18 | command="admin", 19 | description="Admin panel", 20 | ) 21 | ) 22 | 23 | await bot.set_my_commands(commands=commands, scope=BotCommandScopeDefault()) 24 | 25 | for admin_id in settings.tg_bot.admin_ids: 26 | await bot.set_my_commands( 27 | commands=admin_commands, 28 | scope=BotCommandScopeChat( 29 | chat_id=admin_id, 30 | ), 31 | ) 32 | -------------------------------------------------------------------------------- /app/tgbot/states/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | add_order, 3 | admin_menu, 4 | goods_db, 5 | help_, 6 | history, 7 | main_menu, 8 | market_db, 9 | user_db, 10 | ) 11 | 12 | __all__ = [ 13 | "add_order", 14 | "admin_menu", 15 | "goods_db", 16 | "main_menu", 17 | "market_db", 18 | "user_db", 19 | "history", 20 | "help_", 21 | ] 22 | -------------------------------------------------------------------------------- /app/tgbot/states/add_order.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.fsm.state import State, StatesGroup 2 | 3 | 4 | class AddOrder(StatesGroup): 5 | select_goods = State() 6 | input_quantity = State() 7 | select_market = State() 8 | input_comment = State() 9 | confirm = State() 10 | result = State() 11 | -------------------------------------------------------------------------------- /app/tgbot/states/admin_menu.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.fsm.state import State, StatesGroup 2 | 3 | 4 | class AdminMenu(StatesGroup): 5 | category = State() 6 | 7 | 8 | class UserCategory(StatesGroup): 9 | action = State() 10 | 11 | 12 | class GoodsCategory(StatesGroup): 13 | action = State() 14 | 15 | 16 | class MarketCategory(StatesGroup): 17 | action = State() 18 | -------------------------------------------------------------------------------- /app/tgbot/states/goods_db.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.fsm.state import State, StatesGroup 2 | 3 | 4 | class AddGoods(StatesGroup): 5 | name = State() 6 | type = State() 7 | sku = State() 8 | confirm = State() 9 | result = State() 10 | 11 | 12 | class EditGoods(StatesGroup): 13 | select_goods = State() 14 | 15 | 16 | class EditSelectedGoods(StatesGroup): 17 | select_action = State() 18 | 19 | 20 | class EditGoodsName(StatesGroup): 21 | request = State() 22 | 23 | 24 | class EditGoodsType(StatesGroup): 25 | request = State() 26 | 27 | 28 | class EditGoodsSKU(StatesGroup): 29 | request = State() 30 | 31 | 32 | class EditGoodsActiveStatus(StatesGroup): 33 | request = State() 34 | -------------------------------------------------------------------------------- /app/tgbot/states/help_.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.fsm.state import State, StatesGroup 2 | 3 | 4 | class Help(StatesGroup): 5 | show = State() 6 | -------------------------------------------------------------------------------- /app/tgbot/states/history.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.fsm.state import State, StatesGroup 2 | 3 | 4 | class History(StatesGroup): 5 | select_history_level = State() 6 | show = State() 7 | -------------------------------------------------------------------------------- /app/tgbot/states/main_menu.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.fsm.state import State, StatesGroup 2 | 3 | 4 | class MainMenu(StatesGroup): 5 | select_option = State() 6 | -------------------------------------------------------------------------------- /app/tgbot/states/market_db.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.fsm.state import State, StatesGroup 2 | 3 | 4 | class AddMarket(StatesGroup): 5 | name = State() 6 | confirm = State() 7 | result = State() 8 | 9 | 10 | class EditMarket(StatesGroup): 11 | select_market = State() 12 | select_action = State() 13 | result = State() 14 | 15 | 16 | class EditMarketName(StatesGroup): 17 | request = State() 18 | -------------------------------------------------------------------------------- /app/tgbot/states/user_db.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.fsm.state import State, StatesGroup 2 | 3 | 4 | class AddUser(StatesGroup): 5 | id = State() 6 | name = State() 7 | access_level = State() 8 | confirm = State() 9 | result = State() 10 | 11 | 12 | class DeleteUser(StatesGroup): 13 | select_user = State() 14 | confirm = State() 15 | result = State() 16 | 17 | 18 | class EditUser(StatesGroup): 19 | select_user = State() 20 | select_field = State() 21 | result = State() 22 | 23 | 24 | class EditUserId(StatesGroup): 25 | request = State() 26 | 27 | 28 | class EditUserName(StatesGroup): 29 | request = State() 30 | 31 | 32 | class EditAccessLevel(StatesGroup): 33 | request = State() 34 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | redis: 4 | image: redis:6-alpine 5 | restart: "unless-stopped" 6 | environment: 7 | REDIS_HOST: ${REDIS__HOST} 8 | VOLUMES_DIR: ${VOLUMES_DIR} 9 | volumes: 10 | - "~/${VOLUMES_DIR}/redis-config:/usr/local/etc/redis" 11 | - "~/${VOLUMES_DIR}/redis-data:/data" 12 | ports: 13 | - "6379:6379" 14 | command: "redis-server /usr/local/etc/redis/redis.conf" 15 | db: 16 | image: postgres:14-alpine 17 | restart: "unless-stopped" 18 | environment: 19 | POSTGRES_USER: ${DB__USER} 20 | POSTGRES_PASSWORD: ${DB__PASSWORD} 21 | POSTGRES_DB: ${DB__NAME} 22 | VOLUMES_DIR: ${VOLUMES_DIR} 23 | volumes: 24 | - "~/${VOLUMES_DIR}/pg-data:/var/lib/postgresql/data" 25 | ports: 26 | - "5432:5432" 27 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | redis: 4 | image: redis:6-alpine 5 | restart: "unless-stopped" 6 | environment: 7 | REDIS_HOST: ${REDIS__HOST} 8 | VOLUMES_DIR: ${TEST_VOLUMES_DIR} 9 | volumes: 10 | - "~/${TEST_VOLUMES_DIR}/redis-data:/data" 11 | ports: 12 | - "6379:6379" 13 | db: 14 | image: postgres:14-alpine 15 | restart: "unless-stopped" 16 | environment: 17 | POSTGRES_USER: ${DB__USER} 18 | POSTGRES_PASSWORD: ${DB__PASSWORD} 19 | POSTGRES_DB: ${DB__NAME} 20 | VOLUMES_DIR: ${TEST_VOLUMES_DIR} 21 | volumes: 22 | - "~/${TEST_VOLUMES_DIR}/pg-data:/var/lib/postgresql/data" 23 | ports: 24 | - "15432:5432" 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | redis: 4 | image: redis:6-alpine 5 | restart: "unless-stopped" 6 | environment: 7 | REDIS_HOST: ${REDIS__HOST} 8 | VOLUMES_DIR: ${VOLUMES_DIR} 9 | volumes: 10 | - "~/${VOLUMES_DIR}/redis-config:/usr/local/etc/redis" 11 | - "~/${VOLUMES_DIR}/redis-data:/data" 12 | ports: 13 | - "16379:6379" 14 | command: "redis-server /usr/local/etc/redis/redis.conf" 15 | db: 16 | image: postgres:14-alpine 17 | restart: "unless-stopped" 18 | environment: 19 | POSTGRES_USER: ${DB__USER} 20 | POSTGRES_PASSWORD: ${DB__PASSWORD} 21 | POSTGRES_DB: ${DB__NAME} 22 | VOLUMES_DIR: ${VOLUMES_DIR} 23 | volumes: 24 | - "~/${VOLUMES_DIR}/pg-data:/var/lib/postgresql/data" 25 | - "~/${VOLUMES_DIR}/backups:/backups" 26 | ports: 27 | - "15432:5432" 28 | healthcheck: 29 | test: "exit 0" 30 | db_migration: 31 | build: 32 | context: . 33 | restart: "on-failure" 34 | depends_on: 35 | - db 36 | env_file: .env 37 | command: ["/wait-for-it/wait-for-it.sh", "db:5432", "-t", "2", "--", "python", "-m", "alembic", "upgrade", "head"] 38 | dbbackup: 39 | image: prodrigestivill/postgres-backup-local:14-alpine 40 | restart: always 41 | 42 | volumes: 43 | - "~/${VOLUMES_DIR}/backups:/backups" 44 | links: 45 | - db 46 | depends_on: 47 | db: 48 | condition: service_healthy 49 | environment: 50 | - POSTGRES_HOST=db 51 | - POSTGRES_DB=${DB__NAME} 52 | - POSTGRES_USER=${DB__USER} 53 | - POSTGRES_PASSWORD=${DB__PASSWORD} 54 | - POSTGRES_EXTRA_OPTS=-Z6 --schema=public --blobs 55 | - SCHEDULE=@daily 56 | - HEALTHCHECK_PORT=8080 57 | bot: 58 | build: 59 | context: . 60 | stop_signal: SIGINT 61 | restart: "unless-stopped" 62 | env_file: .env 63 | depends_on: 64 | - db 65 | - db_migration 66 | - redis 67 | -------------------------------------------------------------------------------- /prepare_volumes.sh: -------------------------------------------------------------------------------- 1 | # script to copy redis.conf to VOLUMES_DIR from environment variable 2 | # 3 | # Usage: prepare_volumes.sh 4 | # 5 | 6 | # check if enviroment variable is set 7 | if [ -z "$VOLUMES_DIR" ]; then 8 | echo "VOLUMES_DIR is not set" 9 | exit 1 10 | fi 11 | 12 | # create VOLUMES_DIR if it doesn't exist 13 | if [ ! -d "${HOME}/${VOLUMES_DIR}" ]; then 14 | mkdir -p "${HOME}/${VOLUMES_DIR}" 15 | fi 16 | 17 | 18 | # create in VOLUMES_DIR pg-data directory if it doesn't exist 19 | if [ ! -d "${HOME}/${VOLUMES_DIR}/pg-data" ]; then 20 | mkdir -p "${HOME}/${VOLUMES_DIR}/pg-data" 21 | fi 22 | 23 | # create in VOLUMES_DIR redis-data directory if it doesn't exist 24 | if [ ! -d "${HOME}/${VOLUMES_DIR}/redis-data" ]; then 25 | mkdir -p "${HOME}/${VOLUMES_DIR}/redis-data" 26 | fi 27 | 28 | # create in VOLUMES_DIR redis-config directory if it doesn't exist 29 | if [ ! -d "${HOME}/${VOLUMES_DIR}/redis-config" ]; then 30 | mkdir -p "${HOME}/${VOLUMES_DIR}/redis-config" 31 | fi 32 | 33 | # copy redis.conf to VOLUMES_DIR 34 | cp ./redis.conf "${HOME}/${VOLUMES_DIR}/redis-config/redis.conf" 35 | 36 | 37 | # check if redis.conf was copied 38 | if [ -f "${HOME}/${VOLUMES_DIR}/redis-config/redis.conf" ]; then 39 | echo "redis.conf was copied to VOLUMES_DIR" 40 | else 41 | echo "redis.conf was not copied to VOLUMES_DIR" 42 | exit 1 43 | fi -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "orders_bot" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["darksidecat "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.10" 9 | aiogram = {version = "=3.0.0b3", allow-prereleases = true} 10 | aiogram_dialog = {git = "https://github.com/darksidecat/aiogram_dialog.git", branch = "aiogram3-quick-fixes"} 11 | SQLAlchemy = "^1.4.36" 12 | aioredis = "^2.0.1" 13 | alembic = "^1.7.7" 14 | asyncpg = "^0.25.0" 15 | attrs = "^21.4.0" 16 | pydantic = "^1.9.1" 17 | redis = "^4.3.1" 18 | python-dotenv = "^0.20.0" 19 | 20 | 21 | [tool.poetry.dev-dependencies] 22 | pytest-asyncio = "^0.18.3" 23 | mypy = "^0.950" 24 | sqlalchemy2-stubs = "^0.0.2-alpha.22" 25 | black = "^22.3.0" 26 | isort = "^5.10.1" 27 | pytest = "^7.1.2" 28 | 29 | [build-system] 30 | requires = ["poetry-core>=1.0.0"] 31 | build-backend = "poetry.core.masonry.api" 32 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto -------------------------------------------------------------------------------- /redis.conf: -------------------------------------------------------------------------------- 1 | port 6379 2 | save 600 1 3 | dbfilename redis_dump.rdb -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from pytest import fixture 4 | 5 | from app.config import load_config 6 | 7 | from .fixtures.db import * 8 | from .fixtures.repo import * 9 | 10 | 11 | @fixture(scope="session") 12 | def config(): 13 | return load_config(env_file=".env.test") 14 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/db.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import alembic 4 | import alembic.config 5 | from alembic import command 6 | from alembic.script import ScriptDirectory 7 | from pytest import fixture 8 | from sqlalchemy import event 9 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine 10 | from sqlalchemy.orm import clear_mappers 11 | 12 | from app.infrastructure.database.db import make_connection_string, sa_sessionmaker 13 | from app.infrastructure.database.models import map_tables 14 | 15 | __all__ = [ 16 | "session_factory", 17 | "db_wipe", 18 | "test_db", 19 | "db_session", 20 | ] 21 | 22 | 23 | @fixture(scope="session") 24 | def session_factory(config): 25 | return sa_sessionmaker(config.db, echo=True) 26 | 27 | 28 | @fixture(scope="session") 29 | def db_wipe(session_factory): 30 | loop = asyncio.get_event_loop_policy().new_event_loop() 31 | loop.run_until_complete(wipe_db(session_factory)) 32 | loop.close() 33 | 34 | 35 | @fixture(scope="session", autouse=True) 36 | def test_db(session_factory, config, db_wipe) -> None: 37 | cfg = alembic.config.Config() 38 | cfg.set_main_option("script_location", "app/infrastructure/database/alembic") 39 | cfg.set_main_option( 40 | "sqlalchemy.url", make_connection_string(config.db, async_fallback=True) 41 | ) 42 | 43 | revisions_dir = ScriptDirectory.from_config(cfg) 44 | 45 | # Get & sort migrations, from first to last 46 | revisions = list(revisions_dir.walk_revisions("base", "heads")) 47 | revisions.reverse() 48 | for revision in revisions: 49 | command.upgrade(cfg, revision.revision) 50 | command.downgrade(cfg, revision.down_revision or "-1") 51 | command.upgrade(cfg, revision.revision) 52 | 53 | 54 | @fixture(scope="function") 55 | async def db_session(session_factory, config) -> AsyncSession: 56 | clear_mappers() 57 | map_tables() 58 | 59 | async with create_async_engine( 60 | make_connection_string(db=config.db) 61 | ).connect() as connect: 62 | transaction = await connect.begin() 63 | async_session: AsyncSession = session_factory(bind=connect) 64 | await async_session.begin_nested() 65 | 66 | @event.listens_for(async_session.sync_session, "after_transaction_end") 67 | def reopen_nested_transaction(session, transaction): 68 | if connect.closed: 69 | return 70 | 71 | if not connect.in_nested_transaction(): 72 | connect.sync_connection.begin_nested() 73 | 74 | yield async_session 75 | await async_session.close() 76 | if transaction.is_active: 77 | await transaction.rollback() 78 | 79 | 80 | async def wipe_db(session_factory, schema: str = "public") -> None: 81 | async with session_factory() as session: 82 | await session.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE;") 83 | await session.commit() 84 | await session.execute(f"CREATE SCHEMA {schema};") 85 | await session.commit() 86 | -------------------------------------------------------------------------------- /tests/fixtures/repo.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture 2 | 3 | from app.infrastructure.database.repositories import ( 4 | AccessLevelReader, 5 | GoodsReader, 6 | GoodsRepo, 7 | MarketReader, 8 | MarketRepo, 9 | OrderReader, 10 | OrderRepo, 11 | UserReader, 12 | UserRepo, 13 | ) 14 | 15 | __all__ = [ 16 | "access_level_reader", 17 | "goods_reader", 18 | "goods_repo", 19 | "order_repo", 20 | "order_reader", 21 | "market_repo", 22 | "market_reader", 23 | "user_repo", 24 | "user_reader", 25 | ] 26 | 27 | 28 | @fixture 29 | def goods_reader(db_session): 30 | return GoodsReader(session=db_session) 31 | 32 | 33 | @fixture 34 | def goods_repo(db_session): 35 | return GoodsRepo(session=db_session) 36 | 37 | 38 | @fixture 39 | def order_repo(db_session): 40 | return OrderRepo(session=db_session) 41 | 42 | 43 | @fixture 44 | def order_reader(db_session): 45 | return OrderReader(session=db_session) 46 | 47 | 48 | @fixture 49 | def market_repo(db_session): 50 | return MarketRepo(session=db_session) 51 | 52 | 53 | @fixture 54 | def market_reader(db_session): 55 | return MarketReader(session=db_session) 56 | 57 | 58 | @fixture 59 | def user_repo(db_session): 60 | return UserRepo(session=db_session) 61 | 62 | 63 | @fixture 64 | def user_reader(db_session): 65 | return UserReader(session=db_session) 66 | 67 | 68 | @fixture 69 | def access_level_reader(db_session): 70 | return AccessLevelReader(session=db_session) 71 | -------------------------------------------------------------------------------- /tests/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/tests/infrastructure/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darksidecat/orders_bot/20d45d42c231926e9f315331c2aea8fe3e3f0a44/tests/infrastructure/repositories/__init__.py -------------------------------------------------------------------------------- /tests/infrastructure/repositories/conftest.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from pytest import fixture 4 | 5 | from app.domain.access_levels.models.access_level import LevelName 6 | from app.domain.access_levels.models.helper import name_to_access_levels 7 | from app.domain.goods.models.goods import Goods 8 | from app.domain.goods.models.goods_type import GoodsType 9 | from app.domain.market.models.market import Market 10 | from app.domain.order.dto import OrderCreate, OrderLineCreate 11 | from app.domain.order.models.order import Order 12 | from app.domain.user.models.user import TelegramUser 13 | from app.infrastructure.database.repositories import ( 14 | MarketReader, 15 | MarketRepo, 16 | OrderRepo, 17 | UserRepo, 18 | ) 19 | from app.infrastructure.database.repositories.goods import GoodsRepo 20 | 21 | 22 | @dataclass 23 | class OrderWithRelatedData: 24 | order: Order 25 | market: Market 26 | user: TelegramUser 27 | goods: Goods 28 | 29 | 30 | @fixture 31 | async def added_order( 32 | market_repo: MarketRepo, 33 | market_reader: MarketReader, 34 | order_repo: OrderRepo, 35 | goods_repo: GoodsRepo, 36 | user_repo: UserRepo, 37 | ): 38 | ukraine_market = Market.create(name="Ukraine") 39 | await market_repo.add_market(ukraine_market) 40 | await market_repo.session.commit() 41 | 42 | goods = Goods.create( 43 | name="Good", 44 | type=GoodsType.GOODS, 45 | parent=None, 46 | sku="12345", 47 | ) 48 | await goods_repo.add_goods(goods) 49 | await goods_repo.session.commit() 50 | 51 | user = TelegramUser.create( 52 | id=1, 53 | name="User", 54 | access_levels=name_to_access_levels([LevelName.USER, LevelName.ADMINISTRATOR]), 55 | ) 56 | await user_repo.add_user(user) 57 | await user_repo.session.commit() 58 | 59 | order = await order_repo.create_order( 60 | OrderCreate( 61 | order_lines=[ 62 | OrderLineCreate( 63 | goods_id=goods.id, 64 | goods_type=goods.type, 65 | quantity=1, 66 | ) 67 | ], 68 | creator_id=user.id, 69 | recipient_market_id=ukraine_market.id, 70 | commentary="commentary", 71 | ) 72 | ) 73 | await order_repo.session.commit() 74 | 75 | return OrderWithRelatedData( 76 | order=order, 77 | market=ukraine_market, 78 | user=user, 79 | goods=goods, 80 | ) 81 | -------------------------------------------------------------------------------- /tests/infrastructure/repositories/test_access_levels.py: -------------------------------------------------------------------------------- 1 | from app.domain.access_levels import dto 2 | from app.domain.access_levels.models.access_level import LevelName 3 | from app.domain.access_levels.models.helper import Levels, name_to_access_levels 4 | from app.domain.user.models.user import TelegramUser 5 | from app.infrastructure.database.repositories import UserRepo 6 | from app.infrastructure.database.repositories.access_level import AccessLevelReader 7 | 8 | 9 | class TestAccessLevelReader: 10 | async def test_all_access_levels(self, access_level_reader: AccessLevelReader): 11 | access_levels = await access_level_reader.all_access_levels() 12 | assert len(access_levels) == 4 13 | assert dto.AccessLevel.from_orm(Levels.BLOCKED.value) in access_levels 14 | assert dto.AccessLevel.from_orm(Levels.USER.value) in access_levels 15 | assert dto.AccessLevel.from_orm(Levels.ADMINISTRATOR.value) in access_levels 16 | assert dto.AccessLevel.from_orm(Levels.CONFIRMATION.value) in access_levels 17 | 18 | async def test_user_access_levels( 19 | self, access_level_reader: AccessLevelReader, user_repo: UserRepo 20 | ): 21 | await user_repo.add_user( 22 | TelegramUser( 23 | id=1, 24 | name="John", 25 | access_levels=name_to_access_levels( 26 | [LevelName.USER, LevelName.ADMINISTRATOR] 27 | ), 28 | ) 29 | ) 30 | await user_repo.session.commit() 31 | 32 | access_levels = await access_level_reader.user_access_levels(1) 33 | assert len(access_levels) == 2 34 | assert dto.AccessLevel.from_orm(Levels.USER.value) in access_levels 35 | assert dto.AccessLevel.from_orm(Levels.ADMINISTRATOR.value) in access_levels 36 | -------------------------------------------------------------------------------- /tests/infrastructure/repositories/test_market.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from uuid import UUID 3 | 4 | import pytest 5 | 6 | from app.domain.market import dto 7 | from app.domain.market.exceptions.market import ( 8 | CantDeleteWithOrders, 9 | MarketAlreadyExists, 10 | MarketNotExists, 11 | ) 12 | from app.domain.market.models.market import Market 13 | from app.infrastructure.database.repositories import MarketReader, MarketRepo 14 | from tests.infrastructure.repositories.conftest import OrderWithRelatedData 15 | 16 | 17 | class TestMarketReader: 18 | async def test_all_markets( 19 | self, market_reader: MarketReader, market_repo: MarketRepo 20 | ): 21 | markets = await market_reader.all_markets() 22 | assert len(markets) == 0 23 | 24 | ukraine_market = Market.create(name="Ukraine") 25 | poland_market = Market.create(name="Poland") 26 | await market_repo.add_market(ukraine_market) 27 | await market_repo.add_market(poland_market) 28 | 29 | await market_repo.session.commit() 30 | 31 | markets = await market_reader.all_markets() 32 | assert len(markets) == 2 33 | 34 | assert dto.Market.from_orm(ukraine_market) in markets 35 | assert dto.Market.from_orm(poland_market) in markets 36 | 37 | async def test_all_market_only_active( 38 | self, market_reader: MarketReader, market_repo: MarketRepo 39 | ): 40 | ukraine_market = Market.create(name="Ukraine") 41 | poland_market = Market.create(name="Poland") 42 | poland_market.is_active = False 43 | await market_repo.add_market(ukraine_market) 44 | await market_repo.add_market(poland_market) 45 | 46 | await market_repo.session.commit() 47 | 48 | markets = await market_reader.all_markets(only_active=True) 49 | assert len(markets) == 1 50 | assert dto.Market.from_orm(ukraine_market) in markets 51 | 52 | async def test_market_by_id( 53 | self, market_reader: MarketReader, market_repo: MarketRepo 54 | ): 55 | ukraine_market = Market.create(name="Ukraine") 56 | await market_repo.add_market(ukraine_market) 57 | await market_repo.session.commit() 58 | 59 | market = await market_reader.market_by_id(ukraine_market.id) 60 | assert dto.Market.from_orm(ukraine_market) == market 61 | 62 | async def test_market_by_id_not_found( 63 | self, market_reader: MarketReader, market_repo: MarketRepo 64 | ): 65 | with pytest.raises(MarketNotExists): 66 | await market_reader.market_by_id( 67 | UUID("00000000-0000-0000-0000-000000000000") 68 | ) 69 | 70 | 71 | class TestMarketRepo: 72 | async def test_add_market(self, market_repo: MarketRepo, market_reader): 73 | ukraine_market = Market.create(name="Ukraine") 74 | await market_repo.add_market(ukraine_market) 75 | await market_repo.session.commit() 76 | 77 | market = await market_repo.market_by_id(ukraine_market.id) 78 | assert market is ukraine_market 79 | 80 | # ignore identity key conflict 81 | @pytest.mark.filterwarnings("ignore::sqlalchemy.exc.SAWarning") 82 | async def test_add_market_already_exists( 83 | self, market_repo: MarketRepo, market_reader 84 | ): 85 | ukraine_market = Market.create(name="Ukraine") 86 | await market_repo.add_market(ukraine_market) 87 | await market_repo.session.commit() 88 | 89 | with pytest.raises(MarketAlreadyExists): 90 | await market_repo.add_market(Market(id=ukraine_market.id, name="Ukraine")) 91 | 92 | async def test_market_by_id(self, market_repo: MarketRepo, market_reader): 93 | ukraine_market = Market.create(name="Ukraine") 94 | await market_repo.add_market(ukraine_market) 95 | await market_repo.session.commit() 96 | 97 | market = await market_repo.market_by_id(ukraine_market.id) 98 | assert market is ukraine_market 99 | 100 | async def test_market_by_id_not_found(self, market_repo: MarketRepo, market_reader): 101 | with pytest.raises(MarketNotExists): 102 | await market_repo.market_by_id(UUID("00000000-0000-0000-0000-000000000000")) 103 | 104 | async def test_delete_market(self, market_repo: MarketRepo, market_reader): 105 | ukraine_market = Market.create(name="Ukraine") 106 | await market_repo.add_market(ukraine_market) 107 | await market_repo.session.commit() 108 | 109 | await market_repo.delete_market(ukraine_market.id) 110 | await market_repo.session.commit() 111 | 112 | with pytest.raises(MarketNotExists): 113 | await market_reader.market_by_id(ukraine_market.id) 114 | 115 | async def test_update_market(self, market_repo: MarketRepo, market_reader): 116 | ukraine_market = Market.create(name="Kyivan Rus") 117 | await market_repo.add_market(ukraine_market) 118 | await market_repo.session.commit() 119 | 120 | ukraine_market.name = "Ukraine" 121 | await market_repo.edit_market(ukraine_market) 122 | await market_repo.session.commit() 123 | 124 | market = await market_reader.market_by_id(ukraine_market.id) 125 | assert market.name == "Ukraine" 126 | 127 | async def test_update_market_not_found( 128 | self, market_repo: MarketRepo, market_reader 129 | ): 130 | market = await market_repo.add_market( 131 | Market(id=UUID("00000000-0000-0000-0000-000000000000"), name="Ukraine") 132 | ) 133 | market_2 = await market_repo.add_market( 134 | Market(id=uuid.uuid4(), name="Australia") 135 | ) 136 | await market_repo.session.commit() 137 | 138 | market_2.id = market.id 139 | with pytest.raises(MarketAlreadyExists): 140 | await market_repo.edit_market(market_2) 141 | await market_repo.session.commit() 142 | 143 | async def test_cant_delete_market_with_orders( 144 | self, market_repo: MarketRepo, added_order: OrderWithRelatedData 145 | ): 146 | with pytest.raises(CantDeleteWithOrders): 147 | await market_repo.delete_market(added_order.market.id) 148 | await market_repo.session.commit() 149 | -------------------------------------------------------------------------------- /tests/infrastructure/repositories/test_order.py: -------------------------------------------------------------------------------- 1 | from app.domain.access_levels.models.access_level import LevelName 2 | from app.domain.goods.models.goods import Goods 3 | from app.domain.goods.models.goods_type import GoodsType 4 | from app.domain.market.models.market import Market 5 | from app.domain.order.dto import OrderCreate, OrderLineCreate 6 | from app.domain.order.models.order import Order, OrderLine 7 | from app.domain.order.models.user import AccessLevel 8 | from app.domain.user.models.user import TelegramUser 9 | 10 | 11 | class TestOrderRepo: 12 | async def test_add_order(self, order_repo, market_repo, goods_repo, user_repo): 13 | market = await market_repo.add_market(Market.create(name="MarketName")) 14 | market2 = await market_repo.add_market(Market.create(name="MarketName2")) 15 | goods = await goods_repo.add_goods( 16 | Goods.create(type=GoodsType.GOODS, name="A-Goods1", sku="A-SKU1") 17 | ) 18 | user = await user_repo.add_user( 19 | TelegramUser.create( 20 | id=1, 21 | name="UserName", 22 | access_levels=[AccessLevel(id=-1, name=LevelName.BLOCKED)], 23 | ) 24 | ) 25 | 26 | order: Order = await order_repo.create_order( 27 | OrderCreate( 28 | order_lines=[ 29 | OrderLineCreate( 30 | goods_id=goods.id, goods_type=goods.type, quantity=100 31 | ) 32 | ], 33 | creator_id=user.id, 34 | recipient_market_id=market.id, 35 | commentary="Commentary", 36 | ) 37 | ) 38 | 39 | await order_repo.session.commit() 40 | order.recipient_market_id = market2.id 41 | await order_repo.edit_order(order) 42 | assert order.recipient_market_id == market2.id 43 | assert order.recipient_market == market2 44 | await order_repo.session.commit() 45 | 46 | await order_repo.session.delete(order) 47 | await order_repo.session.commit() 48 | 49 | order_repo.session.expunge_all() 50 | 51 | assert await order_repo.session.get(Order, order.id) is None 52 | assert await order_repo.session.get(OrderLine, order.order_lines[0].id) is None 53 | assert await order_repo.session.get(Market, market.id) is not None 54 | assert await order_repo.session.get(Market, market2.id) is not None 55 | assert await order_repo.session.get(Goods, goods.id) is not None 56 | assert await order_repo.session.get(TelegramUser, user.id) is not None 57 | --------------------------------------------------------------------------------