├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── handlers │ │ ├── __init__.py │ │ ├── requests │ │ │ ├── __init__.py │ │ │ └── user.py │ │ ├── responses │ │ │ ├── __init__.py │ │ │ ├── user.py │ │ │ ├── base.py │ │ │ ├── access_levels.py │ │ │ └── errors.py │ │ ├── access_levels.py │ │ └── user.py │ ├── middlewares │ │ ├── __init__.py │ │ └── db_session.py │ ├── providers │ │ ├── __init__.py │ │ └── uow.py │ ├── pyproject.toml │ ├── Dockerfile │ └── main.py ├── domain │ ├── __init__.py │ ├── order │ │ ├── __init__.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── order.py │ │ ├── usecases │ │ │ └── __init__.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ └── order.py │ │ └── interfaces │ │ │ └── __init__.py │ ├── user │ │ ├── __init__.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ └── user.py │ │ ├── interfaces │ │ │ ├── __init__.py │ │ │ ├── uow.py │ │ │ └── persistence.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── user.py │ │ ├── usecases │ │ │ ├── __init__.py │ │ │ └── user.py │ │ ├── dto │ │ │ ├── __init__.py │ │ │ └── user.py │ │ └── access_policy.py │ ├── common │ │ ├── __init__.py │ │ ├── dto │ │ │ ├── __init__.py │ │ │ └── base.py │ │ ├── events │ │ │ ├── __init__.py │ │ │ ├── event.py │ │ │ ├── base.py │ │ │ ├── middleware.py │ │ │ ├── dispatcher.py │ │ │ └── observer.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── entity.py │ │ │ ├── value_object.py │ │ │ └── aggregate.py │ │ ├── usecases │ │ │ └── __init__.py │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ └── repo.py │ │ └── interfaces │ │ │ ├── __init__.py │ │ │ └── uow.py │ ├── department │ │ └── __init__.py │ ├── policy │ │ └── __init__.py │ └── access_levels │ │ ├── __init__.py │ │ ├── models │ │ ├── __init__.py │ │ ├── access_level.py │ │ └── helper.py │ │ ├── exceptions │ │ ├── __init__.py │ │ └── access_levels.py │ │ ├── interfaces │ │ ├── __init__.py │ │ ├── uow.py │ │ └── persistence.py │ │ ├── usecases │ │ ├── __init__.py │ │ └── access_levels.py │ │ ├── dto │ │ ├── __init__.py │ │ └── access_level.py │ │ └── access_policy.py ├── tgbot │ ├── __init__.py │ ├── keyboards │ │ └── __init__.py │ ├── services │ │ ├── __init__.py │ │ └── set_commands.py │ ├── handlers │ │ ├── dialogs │ │ │ ├── __init__.py │ │ │ └── common.py │ │ ├── admin │ │ │ ├── department │ │ │ │ ├── __init__.py │ │ │ │ ├── edit.py │ │ │ │ ├── delete.py │ │ │ │ ├── setup.py │ │ │ │ ├── menu.py │ │ │ │ └── add.py │ │ │ ├── __init__.py │ │ │ ├── user │ │ │ │ ├── __init__.py │ │ │ │ ├── menu.py │ │ │ │ ├── setup.py │ │ │ │ ├── delete.py │ │ │ │ ├── common.py │ │ │ │ ├── add.py │ │ │ │ └── edit.py │ │ │ ├── setup.py │ │ │ └── menu.py │ │ ├── __init__.py │ │ ├── chief │ │ │ ├── __init__.py │ │ │ └── setup.py │ │ ├── user │ │ │ ├── __init__.py │ │ │ ├── setup.py │ │ │ └── start.py │ │ └── setup.py │ ├── middlewares │ │ ├── __init__.py │ │ ├── setup.py │ │ ├── user.py │ │ └── database.py │ ├── states │ │ ├── __init__.py │ │ ├── admin_menu.py │ │ ├── department.py │ │ └── user_db.py │ ├── filters │ │ ├── __init__.py │ │ └── access_level.py │ ├── constants.py │ ├── pyproject.toml │ ├── Dockerfile │ └── __main__.py ├── infrastructure │ ├── __init__.py │ ├── database │ │ ├── __init__.py │ │ ├── alembic │ │ │ ├── __init__.py │ │ │ ├── versions │ │ │ │ ├── __init__.py │ │ │ │ └── 0d18dd8b3ec9_init.py │ │ │ ├── README │ │ │ ├── script.py.mako │ │ │ └── env.py │ │ ├── repositories │ │ │ ├── __init__.py │ │ │ ├── repo.py │ │ │ ├── access_level.py │ │ │ └── user.py │ │ ├── models │ │ │ ├── base.py │ │ │ ├── __init__.py │ │ │ ├── order.py │ │ │ ├── user.py │ │ │ └── confirmation_path.py │ │ ├── exception_mapper.py │ │ ├── uow.py │ │ └── db.py │ └── event_dispatcher.py └── config.py ├── tests ├── __init__.py ├── conftest.py ├── test_api │ └── __init__.py ├── test_tgbot │ └── __init__.py ├── test_domain │ ├── __init__.py │ ├── order │ │ ├── __init__.py │ │ └── models │ │ │ ├── __init__.py │ │ │ └── order.py │ └── user │ │ ├── __init__.py │ │ └── models │ │ ├── __init__.py │ │ └── user │ │ ├── __init__.py │ │ └── test_user.py └── test_infrastructure │ └── __init__.py ├── deployment ├── redis.conf ├── tgbot.bat ├── .env.example ├── alembic.ini ├── docker-compose-dev.yml └── docker-compose.yml ├── .gitattributes ├── .gitignore ├── pyproject.toml ├── Makefile └── LICENSE /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tgbot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/order/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_tgbot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/dto/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/department/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/policy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tgbot/keyboards/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tgbot/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/handlers/requests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/handlers/responses/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/access_levels/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/events/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/usecases/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/order/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/order/usecases/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/user/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/user/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/user/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/user/usecases/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tgbot/handlers/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_domain/order/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_domain/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/access_levels/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/order/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/order/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/infrastructure/database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_domain/order/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_domain/user/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/access_levels/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/access_levels/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/access_levels/usecases/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/department/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_domain/user/models/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/versions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/domain/common/events/event.py: -------------------------------------------------------------------------------- 1 | class Event: 2 | pass 3 | -------------------------------------------------------------------------------- /app/tgbot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_handlers 2 | -------------------------------------------------------------------------------- /app/tgbot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import setup_middlewares 2 | -------------------------------------------------------------------------------- /app/tgbot/states/__init__.py: -------------------------------------------------------------------------------- 1 | from . import admin_menu, department, user_db 2 | -------------------------------------------------------------------------------- /deployment/redis.conf: -------------------------------------------------------------------------------- 1 | port 6379 2 | save 600 1 3 | dbfilename redis_dump.rdb -------------------------------------------------------------------------------- /app/domain/access_levels/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .access_level import AccessLevel 2 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_admin_handlers 2 | -------------------------------------------------------------------------------- /app/tgbot/handlers/chief/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_chief_handlers 2 | -------------------------------------------------------------------------------- /app/tgbot/handlers/user/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_user_handlers 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import register_user_db_handlers 2 | -------------------------------------------------------------------------------- /app/domain/user/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import PatchUserData, User, UserCreate, UserPatch 2 | -------------------------------------------------------------------------------- /app/domain/common/models/entity.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | 3 | entity = define(slots=False, kw_only=True) 4 | -------------------------------------------------------------------------------- /app/tgbot/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from .access_level import AccessLevelFilter 2 | 3 | __all__ = ("AccessLevelFilter",) 4 | -------------------------------------------------------------------------------- /deployment/tgbot.bat: -------------------------------------------------------------------------------- 1 | for /f "delims== tokens=1,2" %%G in (./deployment/.env.dev) do set %%G=%%H 2 | python -m app.tgbot 3 | -------------------------------------------------------------------------------- /app/infrastructure/database/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | from .access_level import AccessLevelReader 2 | from .user import UserRepo 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /venv/ 3 | bot.ini 4 | *.pyc 5 | /database.db 6 | */__pycache__/ 7 | /deployment/.env 8 | /deployment/.env.dev 9 | -------------------------------------------------------------------------------- /app/tgbot/handlers/chief/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | 3 | 4 | def register_chief_handlers(dp: Dispatcher): 5 | pass 6 | -------------------------------------------------------------------------------- /app/domain/common/exceptions/base.py: -------------------------------------------------------------------------------- 1 | class AppException(Exception): 2 | """Base Exception""" 3 | 4 | 5 | class AccessDenied(AppException): 6 | """Access Denied""" 7 | -------------------------------------------------------------------------------- /app/domain/common/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/tgbot/handlers/user/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | 3 | from .start import register_start 4 | 5 | 6 | def register_user_handlers(dp: Dispatcher): 7 | register_start(dp) 8 | -------------------------------------------------------------------------------- /app/api/handlers/requests/user.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class UserCreateRequest(BaseModel): 7 | name: str 8 | access_levels: List[int] 9 | -------------------------------------------------------------------------------- /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/api/handlers/responses/user.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | from app.domain.user.dto.user import User 6 | 7 | 8 | class Users(BaseModel): 9 | users: List[User] 10 | -------------------------------------------------------------------------------- /app/api/handlers/responses/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class ApiError(BaseModel): 5 | error: str 6 | message: str 7 | 8 | 9 | class ErrorResponse(BaseModel): 10 | error = ApiError 11 | -------------------------------------------------------------------------------- /app/domain/common/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/infrastructure/database/models/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import MetaData 2 | from sqlalchemy.orm import declarative_base, registry 3 | 4 | Base = declarative_base() 5 | 6 | 7 | mapper_registry = registry() 8 | metadata = MetaData() 9 | -------------------------------------------------------------------------------- /app/domain/user/interfaces/uow.py: -------------------------------------------------------------------------------- 1 | from app.domain.common.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/common/dto/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Extra 2 | 3 | 4 | class DTO(BaseModel): 5 | class Config: 6 | use_enum_values = True 7 | extra = Extra.forbid 8 | frozen = True 9 | orm_mode = True 10 | -------------------------------------------------------------------------------- /app/domain/access_levels/interfaces/uow.py: -------------------------------------------------------------------------------- 1 | from app.domain.access_levels.interfaces.persistence import IAccessLevelReader 2 | from app.domain.common.interfaces.uow import IUoW 3 | 4 | 5 | class IAccessLevelUoW(IUoW): 6 | access_level_reader: IAccessLevelReader 7 | -------------------------------------------------------------------------------- /app/api/handlers/responses/access_levels.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | from app.domain.access_levels.dto.access_level import AccessLevel 6 | 7 | 8 | class AccessLevels(BaseModel): 9 | access_levels: List[AccessLevel] 10 | -------------------------------------------------------------------------------- /app/api/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from .uow import ( 2 | access_policy, 3 | access_policy_provider, 4 | event_dispatcher_provider, 5 | uow, 6 | uow_provider, 7 | user, 8 | user_provider, 9 | user_service, 10 | user_service_provider, 11 | ) 12 | -------------------------------------------------------------------------------- /app/domain/common/exceptions/repo.py: -------------------------------------------------------------------------------- 1 | from app.domain.common.exceptions.base import AppException 2 | 3 | 4 | class RepositoryError(AppException): 5 | """Base repository error""" 6 | 7 | 8 | class UniqueViolationError(RepositoryError): 9 | """Violation of unique constraint""" 10 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from .confirmation_path import ( 3 | ConfirmationPathChiefEntry, 4 | ConfirmationPathEntry, 5 | CostEntry, 6 | DepartmentEntry, 7 | InformLevelEntry, 8 | ) 9 | from .order import CurrencyEntry, OrderEntry 10 | -------------------------------------------------------------------------------- /app/domain/access_levels/access_policy.py: -------------------------------------------------------------------------------- 1 | from app.domain.user.models.user import TelegramUser 2 | 3 | 4 | class AccessLevelsAccessPolicy: 5 | def __init__(self, user: TelegramUser): 6 | self.user = user 7 | 8 | def read_access_levels(self): 9 | return not self.user.is_blocked 10 | -------------------------------------------------------------------------------- /app/domain/access_levels/dto/access_level.py: -------------------------------------------------------------------------------- 1 | from app.domain.access_levels.models.access_level import LevelName 2 | from app.domain.common.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/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 DepartmentCategory(StatesGroup): 13 | action = State() 14 | -------------------------------------------------------------------------------- /app/domain/common/models/aggregate.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from attrs import Factory, field 4 | 5 | from app.domain.common.events.event import Event 6 | from app.domain.common.models.entity import entity 7 | 8 | 9 | @entity 10 | class Aggregate: 11 | events: List[Event] = field(default=Factory(list)) 12 | -------------------------------------------------------------------------------- /app/domain/access_levels/exceptions/access_levels.py: -------------------------------------------------------------------------------- 1 | from app.domain.common.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/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/tgbot/middlewares/setup.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy.orm 2 | from aiogram import Dispatcher 3 | 4 | from .database import Database 5 | from .user import UserDB 6 | 7 | 8 | def setup_middlewares( 9 | dp: Dispatcher, 10 | sessionmaker: sqlalchemy.orm.sessionmaker, 11 | ): 12 | dp.update.outer_middleware(Database(sessionmaker)) 13 | dp.update.outer_middleware(UserDB()) 14 | -------------------------------------------------------------------------------- /app/domain/access_levels/models/access_level.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | from app.domain.common.models.value_object import value_object 4 | 5 | 6 | @unique 7 | class LevelName(Enum): 8 | BLOCKED = "BLOCKED" 9 | USER = "USER" 10 | ADMINISTRATOR = "ADMINISTRATOR" 11 | 12 | 13 | @value_object 14 | class AccessLevel: 15 | id: int 16 | name: LevelName 17 | -------------------------------------------------------------------------------- /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 | ACCESS_LEVELS = "access_levels" 11 | FIELD = "field" 12 | ALL_ACCESS_LEVELS = "all_access_levels" 13 | DEPARTMENT_NAME = "department_name" 14 | -------------------------------------------------------------------------------- /app/domain/order/exceptions/order.py: -------------------------------------------------------------------------------- 1 | from app.domain.common.exceptions.base import AppException 2 | 3 | 4 | class OrderException(AppException): 5 | """Base Order Exception""" 6 | 7 | 8 | class ConfirmationAlreadyProcessed(OrderException): 9 | """Order already confirmed, denied and can't be processed again""" 10 | 11 | 12 | class OrderNotConfirmed(OrderException): 13 | """Order not confirmed yet""" 14 | -------------------------------------------------------------------------------- /deployment/.env.example: -------------------------------------------------------------------------------- 1 | # tg_bot 2 | TG_BOT__TOKEN=123:abc 3 | TG_BOT__ADMIN_IDS=[1,2] 4 | TG_BOT__USE_REDIS=true 5 | 6 | # database 7 | DB__HOST=localhost 8 | DB__PORT=5432 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 18 | VOLUMES_DIR=cost_confirmation_bot/volumes/ 19 | -------------------------------------------------------------------------------- /app/domain/common/events/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Awaitable, Callable, Dict, Union 4 | 5 | from app.domain.common.events.event import Event 6 | from app.domain.common.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/tgbot/states/department.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.fsm.state import State, StatesGroup 2 | 3 | 4 | class AddDepartment(StatesGroup): 5 | name = State() 6 | confirm = State() 7 | result = State() 8 | 9 | 10 | class DeleteDepartment(StatesGroup): 11 | select_department = State() 12 | # confirm = State() 13 | # result = State() 14 | 15 | 16 | class EditDepartment(StatesGroup): 17 | select_department = State() 18 | # result = State() 19 | -------------------------------------------------------------------------------- /app/tgbot/handlers/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher, Router 2 | from aiogram_dialog import DialogRegistry 3 | 4 | from .admin import register_admin_handlers 5 | from .chief import register_chief_handlers 6 | from .user import register_user_handlers 7 | 8 | 9 | def register_handlers( 10 | dp: Dispatcher, admin_router: Router, dialog_registry: DialogRegistry 11 | ): 12 | 13 | register_admin_handlers(admin_router, dialog_registry) 14 | register_chief_handlers(dp) 15 | register_user_handlers(dp) 16 | -------------------------------------------------------------------------------- /app/domain/user/exceptions/user.py: -------------------------------------------------------------------------------- 1 | from app.domain.common.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 have only that role""" 22 | -------------------------------------------------------------------------------- /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.common.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.UniqueViolationError from err 16 | 17 | return wrapped 18 | -------------------------------------------------------------------------------- /app/api/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cost-confirmation-api" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["darksidecat "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.9" 9 | SQLAlchemy = "^1.4.22" 10 | fastapi = "^0.70.0" 11 | uvicorn = "^0.15.0" 12 | asyncpg = "^0.25.0" 13 | 14 | [tool.poetry.dev-dependencies] 15 | sqlalchemy2-stubs = "^0.0.2-alpha.8" 16 | mypy = "^0.910" 17 | black = "^21.7b0" 18 | isort = "^5.9.3" 19 | flake8 = "^3.9.2" 20 | 21 | 22 | [build-system] 23 | requires = ["poetry-core>=1.0.0"] 24 | build-backend = "poetry.core.masonry.api" 25 | -------------------------------------------------------------------------------- /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/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/event_dispatcher.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict 3 | 4 | from app.domain.common.events.dispatcher import EventDispatcher 5 | from app.domain.user.models.user import UserCreated 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | async def user_created_handler(event: UserCreated, data: Dict[str, Any]): 11 | logger.info("Created user %s", event.user) 12 | 13 | 14 | def configure_dispatch(): 15 | event_dispatcher = EventDispatcher() 16 | event_dispatcher.register_notify(UserCreated, user_created_handler) 17 | event_dispatcher.register_notify(UserCreated, user_created_handler) 18 | return event_dispatcher 19 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/department/edit.py: -------------------------------------------------------------------------------- 1 | from aiogram_dialog import Dialog, Window 2 | from aiogram_dialog.widgets.input import MessageInput 3 | from aiogram_dialog.widgets.kbd import Cancel, Next, Row 4 | from aiogram_dialog.widgets.text import Const 5 | 6 | from app.tgbot import states 7 | 8 | 9 | async def request_name(args): 10 | pass 11 | 12 | 13 | edit_department_dialog = Dialog( 14 | Window( 15 | Const("Input department name:"), 16 | MessageInput(request_name), 17 | Row(Cancel()), 18 | # getter=get_user_data, 19 | state=states.department.EditDepartment.select_department, 20 | parse_mode="HTML", 21 | ), 22 | ) 23 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/department/delete.py: -------------------------------------------------------------------------------- 1 | from aiogram_dialog import Dialog, Window 2 | from aiogram_dialog.widgets.input import MessageInput 3 | from aiogram_dialog.widgets.kbd import Cancel, Next, Row 4 | from aiogram_dialog.widgets.text import Const 5 | 6 | from app.tgbot import states 7 | 8 | 9 | async def request_name(args): 10 | pass 11 | 12 | 13 | delete_department_dialog = Dialog( 14 | Window( 15 | Const("Input department name:"), 16 | MessageInput(request_name), 17 | Row(Cancel()), 18 | # getter=get_user_data, 19 | state=states.department.DeleteDepartment.select_department, 20 | parse_mode="HTML", 21 | ), 22 | ) 23 | -------------------------------------------------------------------------------- /app/api/handlers/responses/errors.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from app.api.handlers.responses.base import ApiError 4 | 5 | 6 | class UserAlreadyExistError(ApiError): 7 | error = Field("UserAlreadyExists", const=True) 8 | message = Field("User with this id already exist", const=True) 9 | user_id: int 10 | 11 | 12 | class AccessLevelNotFoundError(ApiError): 13 | error = Field("AccessLevelNotFound", const=True) 14 | message = Field("Access level for creating user not found", const=True) 15 | 16 | 17 | class UserNotFoundError(ApiError): 18 | error = Field("UserNotFound", const=True) 19 | message = Field("User with this id not found", const=True) 20 | user_id: int 21 | -------------------------------------------------------------------------------- /app/domain/user/access_policy.py: -------------------------------------------------------------------------------- 1 | from app.domain.user.models.user import TelegramUser 2 | 3 | 4 | class UserAccessPolicy: 5 | def __init__(self, user: TelegramUser): 6 | self.user = user 7 | 8 | def read_access_levels(self): 9 | return not self.user.is_blocked 10 | 11 | def read_user_policy(self, internal: bool = False): 12 | if internal: 13 | return True 14 | elif self.user.is_blocked: 15 | return False 16 | else: 17 | return self.user.is_admin 18 | 19 | def modify_user(self): 20 | return self.user.is_admin 21 | 22 | def read_user_self(self, user_id: int): 23 | return self.user.id == user_id 24 | -------------------------------------------------------------------------------- /app/tgbot/handlers/user/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.dispatcher.fsm.context import FSMContext 3 | from aiogram.dispatcher.fsm.state import any_state 4 | from aiogram.types import Message 5 | from aiogram.utils.text_decorations import html_decoration as fmt 6 | 7 | from app.infrastructure.database.models import TelegramUserEntry 8 | 9 | 10 | async def user_start(m: Message, user: TelegramUserEntry, state: FSMContext): 11 | await state.clear() 12 | await m.reply( 13 | f"Hello, {fmt.quote(user.name if user else m.from_user.full_name)}!", 14 | ) 15 | 16 | 17 | def register_start(dp: Dispatcher): 18 | dp.message.register(user_start, any_state, commands=["start"]) 19 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/department/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram_dialog import DialogRegistry 3 | 4 | from .add import add_department_dialog 5 | from .delete import delete_department_dialog 6 | from .edit import edit_department_dialog 7 | from .menu import department_menu_dialog 8 | 9 | 10 | def register_department_handlers(admin_router: Router, dialog_registry: DialogRegistry): 11 | dialog_registry.register(department_menu_dialog, router=admin_router) 12 | dialog_registry.register(add_department_dialog, router=admin_router) 13 | dialog_registry.register(edit_department_dialog, router=admin_router) 14 | dialog_registry.register(delete_department_dialog, router=admin_router) 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/api/handlers/access_levels.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from app.api import providers 4 | from app.api.handlers.responses.access_levels import AccessLevels 5 | from app.domain.access_levels.interfaces.uow import IAccessLevelUoW 6 | from app.domain.access_levels.usecases.access_levels import GetAccessLevels 7 | 8 | access_levels_router = APIRouter( 9 | prefix="/access_level", 10 | tags=["access_level"], 11 | ) 12 | 13 | 14 | @access_levels_router.get("/", response_model=AccessLevels) 15 | async def get_access_levels( 16 | uow: IAccessLevelUoW = Depends(providers.uow_provider), 17 | ) -> AccessLevels: 18 | access_levels = await GetAccessLevels(uow)() 19 | return AccessLevels(access_levels=access_levels) 20 | -------------------------------------------------------------------------------- /app/domain/common/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.common.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/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 user_by_id(self, user_id: int) -> UserDTO: 12 | ... 13 | 14 | 15 | class IUserRepo(Protocol): 16 | async def add_user(self, user: TelegramUser) -> TelegramUser: 17 | ... 18 | 19 | async def user_by_id(self, user_id: int) -> TelegramUser: 20 | ... 21 | 22 | async def delete_user(self, user_id: int) -> None: 23 | ... 24 | 25 | async def edit_user(self, user: TelegramUser) -> TelegramUser: 26 | ... 27 | -------------------------------------------------------------------------------- /app/tgbot/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cost-confirmation-bot" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["darksidecat "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.9" 9 | aiogram = {version = "^3.0.0b1", allow-prereleases = true} 10 | aiogram_dialog = {git = "https://github.com/darksidecat/aiogram_dialog.git", branch = "aiogram3-setup"} 11 | SQLAlchemy = "^1.4.22" 12 | aioredis = "~2.0.0b1" 13 | alembic = "^1.6.5" 14 | asyncpg = "^0.25.0" 15 | diagrams = "^0.20.0" 16 | 17 | 18 | [tool.poetry.dev-dependencies] 19 | sqlalchemy2-stubs = "^0.0.2-alpha.8" 20 | black = "^21.7b0" 21 | mypy = "^0.910" 22 | isort = "^5.9.3" 23 | flake8 = "^3.9.2" 24 | 25 | 26 | [build-system] 27 | requires = ["poetry-core>=1.0.0"] 28 | build-backend = "poetry.core.masonry.api" 29 | -------------------------------------------------------------------------------- /app/api/middlewares/db_session.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable 2 | 3 | from fastapi import Request, Response 4 | from sqlalchemy.orm import sessionmaker 5 | from starlette.middleware.base import BaseHTTPMiddleware 6 | from starlette.types import ASGIApp 7 | 8 | 9 | class DatabaseSessionMiddleware(BaseHTTPMiddleware): 10 | def __init__(self, app: ASGIApp, session_factory: sessionmaker) -> None: 11 | super().__init__(app) 12 | self.session_factory = session_factory 13 | 14 | async def dispatch( 15 | self, 16 | request: Request, 17 | call_next: Callable[[Request], Awaitable[Response]], 18 | ) -> Response: 19 | async with self.session_factory() as session: 20 | request.state.db_session = session 21 | return await call_next(request) 22 | -------------------------------------------------------------------------------- /deployment/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 | prepend_sys_path = . 8 | 9 | [loggers] 10 | keys = root,sqlalchemy,alembic 11 | 12 | [handlers] 13 | keys = console 14 | 15 | [formatters] 16 | keys = generic 17 | 18 | [logger_root] 19 | level = WARN 20 | handlers = console 21 | qualname = 22 | 23 | [logger_sqlalchemy] 24 | level = WARN 25 | handlers = 26 | qualname = sqlalchemy.engine 27 | 28 | [logger_alembic] 29 | level = INFO 30 | handlers = 31 | qualname = alembic 32 | 33 | [handler_console] 34 | class = StreamHandler 35 | args = (sys.stderr,) 36 | level = NOTSET 37 | formatter = generic 38 | 39 | [formatter_generic] 40 | format = %(levelname)-5.5s [%(name)s] %(message)s 41 | datefmt = %H:%M:%S 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cost-confirmation-bot" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["darksidecat "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.9" 9 | aiogram = {version = "^3.0.0b1", allow-prereleases = true} 10 | aiogram_dialog = {git = "https://github.com/darksidecat/aiogram_dialog.git", branch = "aiogram3-setup"} 11 | pydantic = "^1.8.2" 12 | SQLAlchemy = "^1.4.22" 13 | aioredis = "~2.0.0b1" 14 | alembic = "^1.6.5" 15 | asyncpg = "^0.25.0" 16 | diagrams = "^0.20.0" 17 | fastapi = "^0.70.0" 18 | uvicorn = "^0.15.0" 19 | attrs = "^21.4.0" 20 | 21 | 22 | [tool.poetry.dev-dependencies] 23 | sqlalchemy2-stubs = "^0.0.2-alpha.8" 24 | mypy = "^0.910" 25 | black = "^21.7b0" 26 | isort = "^5.9.3" 27 | flake8 = "^3.9.2" 28 | pytest = "^6.2.5" 29 | pytest-asyncio = "^0.16.0" 30 | 31 | 32 | [build-system] 33 | requires = ["poetry-core>=1.0.0"] 34 | build-backend = "poetry.core.masonry.api" 35 | -------------------------------------------------------------------------------- /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 | 13 | 14 | def id_to_access_levels(level_ids: Iterable[int]): 15 | levels_map = {level.value.id: level.value for level in Levels} 16 | 17 | if not set(level_ids).issubset(levels_map): 18 | not_found_levels = set(level_ids).difference(levels_map) 19 | raise AccessLevelNotExist( 20 | f"Access levels with ids: {not_found_levels} not found" 21 | ) 22 | else: 23 | return list(levels_map[level] for level in level_ids) 24 | -------------------------------------------------------------------------------- /deployment/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 | -------------------------------------------------------------------------------- /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\n Select 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(), 25 | state=UserCategory.action, 26 | ), 27 | ) 28 | -------------------------------------------------------------------------------- /app/domain/common/events/dispatcher.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | from app.domain.common.events.event import Event 4 | from app.domain.common.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/tgbot/handlers/admin/setup.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram_dialog import DialogRegistry 3 | 4 | from app.domain.access_levels.models.access_level import LevelName 5 | 6 | from ...filters import AccessLevelFilter 7 | from .department.setup import register_department_handlers 8 | from .menu import admin_menu_dialog, register_admin_menu 9 | from .user import register_user_db_handlers 10 | 11 | 12 | def register_admin_handlers(admin_router: Router, dialog_registry: DialogRegistry): 13 | admin_router.message.filter( 14 | AccessLevelFilter(access_levels=LevelName.ADMINISTRATOR) 15 | ) 16 | admin_router.callback_query.filter( 17 | AccessLevelFilter(access_levels=LevelName.ADMINISTRATOR) 18 | ) 19 | 20 | register_admin_menu(admin_router) 21 | dialog_registry.register(admin_menu_dialog, router=admin_router) 22 | 23 | register_user_db_handlers(admin_router, dialog_registry) 24 | register_department_handlers(admin_router, dialog_registry) 25 | -------------------------------------------------------------------------------- /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/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 | if event_user_id: 21 | from_user_id = event_user_id.id 22 | 23 | uow: IUserUoW = data["uow"] 24 | try: 25 | user = await GetUser(uow)(int(from_user_id)) 26 | except UserNotExists: 27 | user = None 28 | 29 | data["user"] = user 30 | 31 | return await handler(event, data) 32 | -------------------------------------------------------------------------------- /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.common.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.value in self.access_levels 38 | 39 | @property 40 | def is_admin(self) -> bool: 41 | return Levels.ADMINISTRATOR.value in self.access_levels 42 | -------------------------------------------------------------------------------- /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.uow import SQLAlchemyUoW 9 | 10 | 11 | class Database(BaseMiddleware): 12 | def __init__(self, sm: sessionmaker) -> None: 13 | self.Session = sm 14 | 15 | async def __call__( 16 | self, 17 | handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], 18 | event: Update, 19 | data: Dict[str, Any], 20 | ) -> Any: 21 | 22 | async with self.Session() as session: 23 | data["session"] = session 24 | data["uow"] = SQLAlchemyUoW( 25 | session=session, 26 | user_repo=UserRepo, 27 | access_level_repo=AccessLevelReader, 28 | ) 29 | 30 | return await handler(event, data) 31 | -------------------------------------------------------------------------------- /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.models.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 self.access_levels is LevelName.UNREGISTERED: 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/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 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="admin_menu", state=admin_menu.UserCategory.action), 14 | Start( 15 | Const("Department"), 16 | id="department", 17 | state=admin_menu.DepartmentCategory.action, 18 | ), 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=["is_admin"]) 32 | -------------------------------------------------------------------------------- /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: dev-docker 25 | dev-docker: 26 | docker compose -f=./deployment/docker-compose-dev.yml --env-file=./deployment/.env.dev up 27 | 28 | .PHONY: dev-alembic 29 | dev-alembic: 30 | $(call setup_env, ./deployment/.env.dev) 31 | alembic -c ./deployment/alembic.ini upgrade head 32 | 33 | .PHONY: dev-api 34 | dev-api: 35 | $(py) uvicorn app.api.main:api --reload --env-file ./deployment/.env.dev 36 | 37 | .PHONY: dev-bot 38 | dev-bot: 39 | $(call setup_env, ./deployment/.env.dev) 40 | python -m app.tgbot --init_admin_db 41 | 42 | .PHONY: prod 43 | prod: 44 | docker compose -f=./deployment/docker-compose.yml --env-file=./deployment/.env.dev up 45 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/department/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 DepartmentCategory 6 | from app.tgbot.states.department import AddDepartment, DeleteDepartment, EditDepartment 7 | 8 | department_menu_dialog = Dialog( 9 | Window( 10 | Const("Department\n\n Select action"), 11 | Start( 12 | Const("Add"), 13 | id="add_department", 14 | state=AddDepartment.name, 15 | mode=StartMode.NORMAL, 16 | ), 17 | Start( 18 | Const("Edit"), 19 | id="edit_department", 20 | state=EditDepartment.select_department, 21 | mode=StartMode.NORMAL, 22 | ), 23 | Start( 24 | Const("Delete"), 25 | id="delete_department", 26 | state=DeleteDepartment.select_department, 27 | mode=StartMode.NORMAL, 28 | ), 29 | Cancel(), 30 | state=DepartmentCategory.action, 31 | ), 32 | ) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 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 | -------------------------------------------------------------------------------- /app/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-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 app/api/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 ./app /app/app 37 | CMD ["uvicorn", "app.api.main:api", "--host", "0.0.0.0", "--port", "80"] 38 | -------------------------------------------------------------------------------- /app/tgbot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-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 app/tgbot/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 ./deployment/alembic.ini /app/alembic.ini 37 | COPY ./app /app/app 38 | CMD ["python", "-m", "app.tgbot", "--init_admin_db"] 39 | -------------------------------------------------------------------------------- /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/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.common.interfaces.uow import IUoW 8 | from app.domain.user.interfaces.persistence import IUserReader, IUserRepo 9 | from app.domain.user.interfaces.uow import IUserUoW 10 | from app.infrastructure.database.exception_mapper import exception_mapper 11 | 12 | 13 | class SQLAlchemyBaseUoW(IUoW): 14 | def __init__(self, session: AsyncSession): 15 | self._session = session 16 | 17 | @exception_mapper 18 | async def commit(self) -> None: 19 | await self._session.commit() 20 | 21 | async def rollback(self) -> None: 22 | await self._session.rollback() 23 | 24 | 25 | class SQLAlchemyUoW(SQLAlchemyBaseUoW, IUserUoW, IAccessLevelUoW): 26 | user: IUserRepo 27 | user_reader = IUserReader 28 | access_level_reader: IAccessLevelReader 29 | 30 | def __init__( 31 | self, 32 | session: AsyncSession, 33 | user_repo: Type[IUserRepo], 34 | user_reader: Type[IUserReader], 35 | access_level_reader: Type[IAccessLevelReader], 36 | ): 37 | self.user = user_repo(session) 38 | self.user_reader = user_reader(session) 39 | self.access_level_reader = access_level_reader(session) 40 | super().__init__(session) 41 | -------------------------------------------------------------------------------- /app/api/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import FastAPI 4 | 5 | from app.api import providers 6 | from app.api.handlers import access_levels, user 7 | from app.api.middlewares.db_session import DatabaseSessionMiddleware 8 | from app.config import load_config 9 | from app.infrastructure.database.db import sa_sessionmaker 10 | from app.infrastructure.database.models.user import map_tables 11 | from app.infrastructure.event_dispatcher import configure_dispatch 12 | 13 | 14 | def api(): 15 | logging.basicConfig( 16 | level=logging.INFO, 17 | format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", 18 | ) 19 | 20 | app = FastAPI() 21 | 22 | config = load_config() 23 | session_factory = sa_sessionmaker(config.db) 24 | map_tables() 25 | 26 | app.add_middleware(DatabaseSessionMiddleware, session_factory=session_factory) 27 | app.include_router(user.user_router) 28 | app.include_router(access_levels.access_levels_router) 29 | 30 | event_dispatcher = configure_dispatch() 31 | 32 | app.dependency_overrides[providers.uow_provider] = providers.uow 33 | app.dependency_overrides[providers.user_provider] = providers.user 34 | app.dependency_overrides[providers.access_policy_provider] = providers.access_policy 35 | app.dependency_overrides[ 36 | providers.event_dispatcher_provider 37 | ] = lambda: event_dispatcher 38 | app.dependency_overrides[providers.user_service_provider] = providers.user_service 39 | 40 | return app 41 | -------------------------------------------------------------------------------- /app/domain/common/events/observer.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Any, Awaitable, Callable, Dict, List, Type 3 | 4 | from app.domain.common.events.base import MiddlewareType, NextMiddlewareType 5 | from app.domain.common.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/infrastructure/database/models/order.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BIGINT, BOOLEAN, INT, REAL, TEXT, TIMESTAMP, Column, ForeignKey 2 | 3 | from .base import Base 4 | 5 | 6 | class CurrencyEntry(Base): 7 | __tablename__ = "currency" 8 | 9 | id = Column(INT, primary_key=True, autoincrement=True) 10 | name = Column(TEXT, nullable=False) 11 | 12 | 13 | class OrderEntry(Base): 14 | __tablename__ = "order" 15 | 16 | id = Column(BIGINT, primary_key=True, autoincrement=True) 17 | user_id = Column(BIGINT, ForeignKey("user.id"), nullable=False) 18 | confirmation_path_id = Column( 19 | INT, ForeignKey("confirmation_path.id"), nullable=False 20 | ) 21 | amount = Column(REAL, nullable=False) 22 | vat = Column(BOOLEAN, nullable=False) 23 | currency = Column(INT, ForeignKey("currency.id"), nullable=False) 24 | cost_id = Column(INT, ForeignKey("cost.id"), nullable=False) 25 | comment = Column(TEXT, nullable=False) 26 | chief_confirm = Column(BOOLEAN) 27 | date = Column(TIMESTAMP, nullable=False) 28 | date_confirm = Column(TIMESTAMP) 29 | 30 | def __init__( 31 | self, 32 | user_id, 33 | confirmation_path_id, 34 | amount, 35 | vat, 36 | currency, 37 | cost_id, 38 | comments, 39 | date, 40 | ): 41 | self.user_id = user_id 42 | self.confirmation_path_id = confirmation_path_id 43 | self.amount = amount 44 | self.vat = vat 45 | self.currency = currency 46 | self.cost_id = cost_id 47 | self.comments = comments 48 | self.chief_confirm = None 49 | self.date = date 50 | self.date_confirm = None 51 | 52 | def __repr__(self): 53 | return f"" 54 | -------------------------------------------------------------------------------- /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_encoding = 'utf-8' 36 | env_nested_delimiter = '__'""" 37 | 38 | 39 | class SettingsExtractor(BaseSettings): 40 | # tg_bot 41 | TG_BOT__TOKEN: str 42 | TG_BOT__ADMIN_IDS: list[int] 43 | TG_BOT__USE_REDIS: bool 44 | 45 | # database 46 | DB__HOST: str 47 | DB__PORT: int 48 | DB__NAME: str 49 | DB__USER: str 50 | DB__PASSWORD: str 51 | 52 | # redis 53 | REDIS__HOST: str 54 | REDIS__DB: int 55 | 56 | 57 | class Settings(BaseSettings): 58 | tg_bot: TgBot 59 | db: DB 60 | redis: Redis 61 | 62 | 63 | def load_config() -> Settings: 64 | settings = SettingsExtractor() 65 | 66 | return Settings( 67 | tg_bot=TgBot( 68 | token=settings.TG_BOT__TOKEN, 69 | admin_ids=settings.TG_BOT__ADMIN_IDS, 70 | use_redis=settings.TG_BOT__USE_REDIS, 71 | ), 72 | db=DB( 73 | host=settings.DB__HOST, 74 | port=settings.DB__PORT, 75 | name=settings.DB__NAME, 76 | user=settings.DB__USER, 77 | password=settings.DB__PASSWORD, 78 | ), 79 | redis=Redis(host=settings.REDIS__HOST, db=settings.REDIS__DB), 80 | ) 81 | -------------------------------------------------------------------------------- /deployment/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 | ports: 26 | - "15432:5432" 27 | db_migration: 28 | build: 29 | context: .. 30 | dockerfile: ../app/tgbot/Dockerfile 31 | restart: "on-failure" 32 | depends_on: 33 | - db 34 | env_file: .env 35 | command: ["/wait-for-it/wait-for-it.sh", "db:5432", "-t", "2", "--", "python", "-m", "alembic", "upgrade", "head"] 36 | bot: 37 | build: 38 | context: .. 39 | dockerfile: ../app/tgbot/Dockerfile 40 | stop_signal: SIGINT 41 | restart: "unless-stopped" 42 | env_file: .env 43 | depends_on: 44 | - db 45 | - db_migration 46 | - redis 47 | api: 48 | build: 49 | context: .. 50 | dockerfile: ../app/api/Dockerfile 51 | stop_signal: SIGINT 52 | restart: "unless-stopped" 53 | env_file: .env 54 | depends_on: 55 | - db 56 | - db_migration 57 | ports: 58 | - "8000:80" 59 | -------------------------------------------------------------------------------- /app/domain/user/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import List, Set 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.common.events.event import Event 9 | from app.domain.common.models.aggregate import Aggregate 10 | from app.domain.common.models.entity import entity 11 | from app.domain.user.exceptions.user import ( 12 | BlockedUserWithOtherRole, 13 | UserWithNoAccessLevels, 14 | ) 15 | from app.domain.user import dto 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/tgbot/handlers/admin/department/add.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | 3 | from aiogram.types import Message 4 | from aiogram_dialog import Dialog, DialogManager, Window 5 | from aiogram_dialog.manager.protocols import ManagedDialogAdapterProto 6 | from aiogram_dialog.widgets.input import MessageInput 7 | from aiogram_dialog.widgets.kbd import Back, Cancel, Next, Row, Select 8 | from aiogram_dialog.widgets.text import Const, Format 9 | 10 | from app.tgbot import states 11 | from app.tgbot.constants import DEPARTMENT_NAME, YES_NO 12 | from app.tgbot.handlers.dialogs.common import enable_send_mode, get_result 13 | 14 | 15 | async def request_name( 16 | message: Message, dialog: ManagedDialogAdapterProto, manager: DialogManager 17 | ): 18 | manager.current_context().dialog_data[DEPARTMENT_NAME] = message.text 19 | await dialog.next() 20 | 21 | 22 | async def add_department_yes_no(args): 23 | pass 24 | 25 | 26 | async def get_department_data(args): 27 | pass 28 | 29 | 30 | add_department_dialog = Dialog( 31 | Window( 32 | Const("Input department name:"), 33 | MessageInput(request_name), 34 | Row(Cancel()), 35 | # getter=get_user_data, 36 | state=states.department.AddDepartment.name, 37 | parse_mode="HTML", 38 | preview_add_transitions=[ 39 | Next(), 40 | ], 41 | ), 42 | Window( 43 | Const("Confirm ?"), 44 | Select( 45 | Format("{item[0]}"), 46 | id="add_yes_no", 47 | item_id_getter=itemgetter(1), 48 | items=YES_NO, 49 | on_click=add_department_yes_no, 50 | ), 51 | Row(Back(), Cancel()), 52 | getter=get_department_data, 53 | state=states.department.AddDepartment.confirm, 54 | parse_mode="HTML", 55 | preview_add_transitions=[Next()], 56 | ), 57 | Window( 58 | Format("{result}"), 59 | Cancel(Const("Close"), on_click=enable_send_mode), 60 | getter=get_result, 61 | state=states.department.AddDepartment.result, 62 | parse_mode="HTML", 63 | ), 64 | ) 65 | -------------------------------------------------------------------------------- /app/api/providers/uow.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, Request 2 | 3 | from app.domain.access_levels.models.access_level import AccessLevel, LevelName 4 | from app.domain.common.events.dispatcher import EventDispatcher 5 | from app.domain.common.interfaces.uow import IUoW 6 | from app.domain.user.access_policy import UserAccessPolicy 7 | from app.domain.user.models.user import TelegramUser 8 | from app.domain.user.usecases.user import UserService 9 | from app.infrastructure.database.repositories.access_level import AccessLevelReader 10 | from app.infrastructure.database.repositories.user import UserReader, UserRepo 11 | from app.infrastructure.database.uow import SQLAlchemyUoW 12 | 13 | 14 | def uow_provider(request: Request) -> IUoW: 15 | ... 16 | 17 | 18 | def uow(request: Request) -> SQLAlchemyUoW: 19 | return SQLAlchemyUoW( 20 | request.state.db_session, 21 | user_repo=UserRepo, 22 | user_reader=UserReader, 23 | access_level_reader=AccessLevelReader, 24 | ) 25 | 26 | 27 | def user_provider() -> TelegramUser: 28 | ... 29 | 30 | 31 | def user() -> TelegramUser: 32 | return TelegramUser( 33 | id=1, 34 | name="Test", 35 | access_levels=[AccessLevel(id=1, name=LevelName.ADMINISTRATOR)], 36 | ) 37 | 38 | 39 | def access_policy_provider( 40 | from_user: TelegramUser = Depends(user_provider), 41 | ) -> UserAccessPolicy: 42 | ... 43 | 44 | 45 | def access_policy(from_user: TelegramUser = Depends(user_provider)) -> UserAccessPolicy: 46 | return UserAccessPolicy(user=from_user) 47 | 48 | 49 | def user_service_provider( 50 | user_uow: TelegramUser = Depends(uow_provider), 51 | user_access_policy: UserAccessPolicy = Depends(access_policy_provider), 52 | ) -> UserService: 53 | ... 54 | 55 | 56 | def event_dispatcher_provider() -> EventDispatcher: 57 | ... 58 | 59 | 60 | def user_service( 61 | user_uow: SQLAlchemyUoW = Depends(uow_provider), 62 | user_access_policy: UserAccessPolicy = Depends(access_policy_provider), 63 | event_dicpatcher: EventDispatcher = Depends(event_dispatcher_provider), 64 | ) -> UserService: 65 | return UserService( 66 | uow=user_uow, 67 | access_policy=user_access_policy, 68 | event_dispatcher=event_dicpatcher, 69 | ) 70 | -------------------------------------------------------------------------------- /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.common.events.dispatcher import EventDispatcher 7 | from app.domain.common.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/infrastructure/database/models/user.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from sqlalchemy import BIGINT, INT, TEXT, Column 4 | from sqlalchemy import Enum as SQLEnum 5 | from sqlalchemy import ForeignKey, Table 6 | from sqlalchemy.orm import relationship 7 | 8 | from app.domain.access_levels.models import helper 9 | from app.domain.access_levels.models.access_level import AccessLevel, LevelName 10 | from app.domain.user.models.user import TelegramUser 11 | 12 | from .base import mapper_registry 13 | 14 | user_access_levels = Table( 15 | "user_access_levels", 16 | mapper_registry.metadata, 17 | Column( 18 | "user_id", 19 | ForeignKey("user.id", ondelete="CASCADE", onupdate="CASCADE"), 20 | primary_key=True, 21 | ), 22 | Column( 23 | "access_level_id", 24 | ForeignKey("access_level.id", ondelete="CASCADE", onupdate="CASCADE"), 25 | primary_key=True, 26 | ), 27 | ) 28 | 29 | access_level_table = Table( 30 | "access_level", 31 | mapper_registry.metadata, 32 | Column("id", INT, primary_key=True, autoincrement=True), 33 | Column("name", SQLEnum(LevelName), nullable=False), 34 | ) 35 | 36 | user_table = Table( 37 | "user", 38 | mapper_registry.metadata, 39 | Column("id", BIGINT, primary_key=True), 40 | Column("name", TEXT, nullable=False), 41 | ) 42 | 43 | 44 | def map_tables(): 45 | mapper_registry.map_imperatively( 46 | TelegramUser, 47 | user_table, 48 | properties={ 49 | "access_levels": relationship( 50 | AccessLevel, 51 | secondary=user_access_levels, 52 | back_populates="users", 53 | lazy="selectin", 54 | ) 55 | }, 56 | ) 57 | mapper_registry.map_imperatively( 58 | AccessLevel, 59 | access_level_table, 60 | properties={ 61 | "users": relationship( 62 | TelegramUser, 63 | secondary=user_access_levels, 64 | back_populates="access_levels", 65 | ) 66 | }, 67 | ) 68 | 69 | class UpdatedLevels(Enum): # ToDo 70 | BLOCKED = AccessLevel(id=-1, name=LevelName.BLOCKED) 71 | ADMINISTRATOR = AccessLevel(id=1, name=LevelName.ADMINISTRATOR) 72 | USER = AccessLevel(id=2, name=LevelName.USER) 73 | 74 | helper.Levels = UpdatedLevels 75 | -------------------------------------------------------------------------------- /tests/test_domain/order/models/order.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | import pytest 5 | 6 | from app.domain.order.models.order import ( 7 | Confirmation, 8 | ConfirmationPath, 9 | ConfirmationPaths, 10 | ConfirmationPathType, 11 | Cost, 12 | Currency, 13 | Department, 14 | Order, 15 | OrderDetails, 16 | User, 17 | ) 18 | 19 | 20 | @pytest.fixture() 21 | def order(): 22 | return Order( 23 | id=1, 24 | confirmation_path=ConfirmationPath( 25 | creator=User(id=1, name="Fake"), 26 | cost=Cost( 27 | id=1, 28 | name="Car maintenance", 29 | department=Department(id=1, name="Administration"), 30 | ), 31 | confirmation_paths=( 32 | ConfirmationPaths( 33 | id=1, user=User(id=1, name="Fake"), type=ConfirmationPathType.CHIEF 34 | ), 35 | ), 36 | ), 37 | order_details=OrderDetails( 38 | date=datetime.now(), 39 | amount=Decimal("103.11"), 40 | vat=True, 41 | currency=Currency(id=1, name="UAH"), 42 | comment="expense", 43 | ), 44 | confirmation=Confirmation( 45 | date=None, 46 | status=None, 47 | ), 48 | ) 49 | 50 | 51 | class TestOrder: 52 | def test_confirm(self, order): 53 | assert not order.confirmation.processed 54 | order.confirm() 55 | assert order.confirmation.status is True 56 | assert order.confirmation.date is not None 57 | 58 | def test_deny(self, order): 59 | assert not order.confirmation.processed 60 | order.deny() 61 | assert order.confirmation.status is False 62 | assert order.confirmation.date is not None 63 | 64 | def test_invert_confirmation_status(self, order): 65 | order.confirm() 66 | 67 | old_status = order.confirmation.status 68 | old_date = order.confirmation.date 69 | assert order.confirmation.processed 70 | order.invert_status() 71 | assert order.confirmation.status is not old_status 72 | assert order.confirmation.date is old_date 73 | 74 | def test_clean(self, order): 75 | order.confirm() 76 | order.clean_confirmation_status() 77 | 78 | assert not order.confirmation.processed 79 | -------------------------------------------------------------------------------- /app/infrastructure/database/db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.exc import IntegrityError 2 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine 3 | from sqlalchemy.orm import sessionmaker 4 | 5 | from app.config import DB, Settings 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.user.models.user import TelegramUser 9 | from app.infrastructure.database.repositories.user import logger 10 | 11 | 12 | def make_connection_string(db: DB, async_fallback: bool = False) -> str: 13 | result = ( 14 | f"postgresql+asyncpg://{db.user}:{db.password}@{db.host}:{db.port}/{db.name}" 15 | ) 16 | if async_fallback: 17 | result += "?async_fallback=True" 18 | return result 19 | 20 | 21 | def sa_sessionmaker(db: DB, echo: bool = False) -> sessionmaker: 22 | """ 23 | Make sessionmaker 24 | :param driver: dialect+driver 25 | :param db_path: database path and credential 26 | :return: sessionmaker 27 | :rtype: sqlalchemy.orm.sessionmaker 28 | """ 29 | engine = create_async_engine(make_connection_string(db), echo=True) 30 | return sessionmaker( 31 | bind=engine, 32 | expire_on_commit=False, 33 | class_=AsyncSession, 34 | future=True, 35 | autoflush=False, 36 | ) 37 | 38 | 39 | async def add_initial_admin(sm: sessionmaker, config: Settings): 40 | try: 41 | async with sm() as session: 42 | administrator_level = AccessLevel( 43 | id=Levels.ADMINISTRATOR.value.id, name=Levels.ADMINISTRATOR.value.name 44 | ) 45 | session.add(administrator_level) 46 | session.add( 47 | AccessLevel(id=Levels.USER.value.id, name=Levels.USER.value.name) 48 | ) 49 | session.add( 50 | AccessLevel(id=Levels.BLOCKED.value.id, name=Levels.BLOCKED.value.name) 51 | ) 52 | 53 | for user_id in config.tg_bot.admin_ids: 54 | user = TelegramUser( 55 | id=user_id, 56 | name="Administrator", 57 | ) 58 | user.access_levels.add(administrator_level) 59 | session.add(user) 60 | 61 | await session.commit() 62 | logger.info("Admins added to database") 63 | except IntegrityError: 64 | logger.info("Admins already added") 65 | -------------------------------------------------------------------------------- /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 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | from app.config import load_config 9 | from app.infrastructure.database.db import make_connection_string 10 | from app.infrastructure.database.models import Base 11 | 12 | config = context.config 13 | 14 | config.set_main_option( 15 | "sqlalchemy.url", make_connection_string(load_config().db, async_fallback=True) 16 | ) 17 | 18 | # Interpret the config file for Python logging. 19 | # This line sets up loggers basically. 20 | fileConfig(config.config_file_name) 21 | 22 | # add your model's MetaData object here 23 | # for 'autogenerate' support 24 | # from myapp import mymodel 25 | # target_metadata = mymodel.Base.metadata 26 | target_metadata = Base.metadata 27 | 28 | # other values from the config, defined by the needs of env.py, 29 | # can be acquired: 30 | # my_important_option = config.get_main_option("my_important_option") 31 | # ... etc. 32 | 33 | 34 | def run_migrations_offline(): 35 | """Run migrations in 'offline' mode. 36 | 37 | This configures the context with just a URL 38 | and not an Engine, though an Engine is acceptable 39 | here as well. By skipping the Engine creation 40 | we don't even need a DBAPI to be available. 41 | 42 | Calls to context.execute() here emit the given string to the 43 | script output. 44 | 45 | """ 46 | url = config.get_main_option("sqlalchemy.url") 47 | context.configure( 48 | url=url, 49 | target_metadata=target_metadata, 50 | literal_binds=True, 51 | dialect_opts={"paramstyle": "named"}, 52 | ) 53 | 54 | with context.begin_transaction(): 55 | context.run_migrations() 56 | 57 | 58 | def run_migrations_online(): 59 | """Run migrations in 'online' mode. 60 | 61 | In this scenario we need to create an Engine 62 | and associate a connection with the context. 63 | 64 | """ 65 | connectable = engine_from_config( 66 | config.get_section(config.config_ini_section), 67 | prefix="sqlalchemy.", 68 | poolclass=pool.NullPool, 69 | ) 70 | 71 | with connectable.connect() as connection: 72 | context.configure(connection=connection, target_metadata=target_metadata) 73 | 74 | with context.begin_transaction(): 75 | context.run_migrations() 76 | 77 | 78 | if context.is_offline_mode(): 79 | run_migrations_offline() 80 | else: 81 | run_migrations_online() 82 | -------------------------------------------------------------------------------- /app/infrastructure/database/models/confirmation_path.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BIGINT, INT, TEXT, Column, ForeignKey, UniqueConstraint 2 | from sqlalchemy.orm import relationship 3 | 4 | from .base import Base 5 | 6 | 7 | class DepartmentEntry(Base): 8 | __tablename__ = "department" 9 | 10 | id = Column(INT, primary_key=True, autoincrement=True) 11 | name = Column(TEXT, nullable=False) 12 | 13 | costs = relationship("CostEntry", back_populates="department") 14 | 15 | 16 | class InformLevelEntry(Base): 17 | __tablename__ = "inform_level" 18 | 19 | id = Column(INT, primary_key=True, autoincrement=True) 20 | name = Column(TEXT, nullable=False) 21 | 22 | confirmation_path_chief = relationship( 23 | "ConfirmationPathChiefEntry", back_populates="inform_level" 24 | ) 25 | 26 | 27 | class CostEntry(Base): 28 | __tablename__ = "cost" 29 | 30 | id = Column(INT, primary_key=True, autoincrement=True) 31 | department_id = Column(INT, ForeignKey("department.id")) 32 | name = Column(TEXT, nullable=False) 33 | 34 | department = relationship("DepartmentEntry", back_populates="costs") 35 | confirmation_paths = relationship("ConfirmationPathEntry", back_populates="cost") 36 | 37 | 38 | class ConfirmationPathEntry(Base): 39 | __tablename__ = "confirmation_path" 40 | 41 | id = Column(INT, primary_key=True, autoincrement=True) 42 | cost_id = Column(INT, ForeignKey("cost.id"), nullable=False) 43 | user_id = Column(BIGINT, ForeignKey("user.id"), nullable=False) 44 | 45 | cost = relationship("CostEntry", back_populates="confirmation_paths") 46 | user = relationship("TelegramUserEntry", back_populates="confirmation_path") 47 | confirmation_path_chief = relationship( 48 | "ConfirmationPathChiefEntry", back_populates="confirmation_path" 49 | ) 50 | 51 | __table_args__ = (UniqueConstraint("cost_id", "user_id", name="_user_cost"),) 52 | 53 | 54 | class ConfirmationPathChiefEntry(Base): 55 | __tablename__ = "confirmation_path_chief" 56 | 57 | id = Column(INT, primary_key=True, autoincrement=True) 58 | confirmation_path_id = Column( 59 | INT, ForeignKey("confirmation_path.id"), nullable=False 60 | ) 61 | chief_id = Column(BIGINT, ForeignKey("user.id"), nullable=False) 62 | inform_level_id = Column(INT, ForeignKey("inform_level.id"), nullable=False) 63 | 64 | confirmation_path = relationship( 65 | "ConfirmationPathEntry", back_populates="confirmation_path_chief" 66 | ) 67 | chief = relationship("TelegramUserEntry", back_populates="confirmation_path_chief") 68 | inform_level = relationship( 69 | "InformLevelEntry", back_populates="confirmation_path_chief" 70 | ) 71 | -------------------------------------------------------------------------------- /app/tgbot/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import logging 4 | 5 | from aiogram import Bot, Dispatcher, Router 6 | from aiogram.dispatcher.fsm.storage.memory import MemoryStorage 7 | from aiogram.dispatcher.fsm.storage.redis import DefaultKeyBuilder, RedisStorage 8 | from aiogram_dialog import DialogRegistry 9 | 10 | from app.config import load_config 11 | from app.infrastructure.database.db import add_initial_admin, sa_sessionmaker 12 | from app.tgbot.handlers import register_handlers 13 | from app.tgbot.middlewares import setup_middlewares 14 | from app.tgbot.services.set_commands import set_commands 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def parse_args(): 20 | parser = argparse.ArgumentParser() 21 | parser.add_argument( 22 | "--dev", 23 | help="Run bot in dev mode", 24 | action="store", 25 | nargs="*", 26 | ) 27 | parser.add_argument( 28 | "--init_admin_db", 29 | action="store", 30 | help="Add admins to database from config file", 31 | nargs="*", 32 | ) 33 | return parser.parse_args() 34 | 35 | 36 | async def main(): 37 | args = parse_args() 38 | 39 | logging.basicConfig( 40 | level=logging.INFO, 41 | format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", 42 | ) 43 | logger.error("Starting bot") 44 | config = load_config() 45 | 46 | if config.tg_bot.use_redis: 47 | storage = RedisStorage.from_url( 48 | url=f"redis://{config.redis.host}", 49 | connection_kwargs={ 50 | "db": config.redis.db, 51 | }, 52 | key_builder=DefaultKeyBuilder(with_destiny=True), 53 | ) 54 | else: 55 | storage = MemoryStorage() 56 | 57 | session_factory = sa_sessionmaker(config.db, echo=False) 58 | 59 | bot = Bot(token=config.tg_bot.token, parse_mode="HTML") 60 | dp = Dispatcher(storage=storage, isolate_events=True) 61 | admin_router = Router() 62 | dp.include_router(admin_router) 63 | 64 | dialog_registry = DialogRegistry(dp) 65 | 66 | setup_middlewares( 67 | dp=dp, 68 | sessionmaker=session_factory, 69 | ) 70 | 71 | register_handlers(dp=dp, admin_router=admin_router, dialog_registry=dialog_registry) 72 | 73 | if args.init_admin_db is not None: 74 | await add_initial_admin(session_factory, config) 75 | 76 | try: 77 | await set_commands(bot, config) 78 | await dp.start_polling(bot, config=config) 79 | finally: 80 | await dp.fsm.storage.close() 81 | await bot.session.close() 82 | 83 | 84 | try: 85 | asyncio.run(main()) 86 | except (KeyboardInterrupt, SystemExit): 87 | logger.error("Bot stopped!") 88 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/delete.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter, itemgetter 2 | 3 | from aiogram.types import CallbackQuery 4 | from aiogram_dialog import Dialog, DialogManager, Window 5 | from aiogram_dialog.widgets.kbd import Back, Cancel, Next, Row, ScrollingGroup, Select 6 | from aiogram_dialog.widgets.managed import ManagedWidgetAdapter 7 | from aiogram_dialog.widgets.text import Const, Format 8 | 9 | from app.domain.user.interfaces.uow import IUserUoW 10 | from app.domain.user.usecases.user import DeleteUser 11 | from app.tgbot import states 12 | from app.tgbot.constants import NO, USER_ID, USERS, YES_NO 13 | from app.tgbot.handlers.admin.user.common import get_user, get_users, save_user_id 14 | from app.tgbot.handlers.dialogs.common import enable_send_mode, get_result 15 | 16 | 17 | async def delete_user_yes_no( 18 | query: CallbackQuery, 19 | select_: ManagedWidgetAdapter[Select], 20 | manager: DialogManager, 21 | item_id: str, 22 | ): 23 | uow: IUserUoW = manager.data.get("uow") 24 | data = manager.current_context().dialog_data 25 | 26 | if item_id == NO: 27 | data["result"] = "User deleting cancelled" 28 | await manager.dialog().next() 29 | return 30 | 31 | await DeleteUser(uow)(int(data[USER_ID])) 32 | data["result"] = f"User {data[USER_ID]} deleted" 33 | await manager.dialog().next() 34 | 35 | await query.answer() 36 | 37 | 38 | delete_user_dialog = Dialog( 39 | Window( 40 | Const("Select user for deleting:"), 41 | ScrollingGroup( 42 | Select( 43 | Format("{item.name} {item.id}"), 44 | id=USER_ID, 45 | item_id_getter=attrgetter("id"), 46 | items=USERS, 47 | on_click=save_user_id, 48 | ), 49 | id="user_scrolling", 50 | width=1, 51 | height=5, 52 | ), 53 | Cancel(), 54 | getter=get_users, 55 | state=states.user_db.DeleteUser.select_user, 56 | preview_add_transitions=[Next()], 57 | ), 58 | Window( 59 | Format("User:\n\n id: {user.id}\n name: {user.name}\n\nDelete?"), 60 | Select( 61 | Format("{item[0]}"), 62 | id="delete_yes_no", 63 | item_id_getter=itemgetter(1), 64 | items=YES_NO, 65 | on_click=delete_user_yes_no, 66 | ), 67 | Row(Back(), Cancel()), 68 | getter=get_user, 69 | state=states.user_db.DeleteUser.confirm, 70 | preview_add_transitions=[Next()], 71 | ), 72 | Window( 73 | Format("{result}"), 74 | Cancel(Const("Close"), on_click=enable_send_mode), 75 | getter=get_result, 76 | state=states.user_db.DeleteUser.result, 77 | parse_mode="HTML", 78 | ), 79 | ) 80 | -------------------------------------------------------------------------------- /tests/test_domain/user/models/user/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.domain.access_levels.models.helper import Levels 4 | from app.domain.user.exceptions.user import ( 5 | BlockedUserWithOtherRole, 6 | UserWithNoAccessLevels, 7 | ) 8 | from app.domain.user.models.user import TelegramUser 9 | 10 | 11 | class TestUser: 12 | @pytest.mark.parametrize( 13 | "user", 14 | [ 15 | TelegramUser( 16 | id=1, 17 | name="U", 18 | access_levels=[ 19 | Levels.USER.value, 20 | ], 21 | ), 22 | TelegramUser( 23 | id=1, 24 | name="U", 25 | access_levels=[Levels.USER.value, Levels.ADMINISTRATOR.value], 26 | ), 27 | ], 28 | ) 29 | def test_block_user(self, user): 30 | user.block_user() 31 | assert user.access_levels == [ 32 | Levels.BLOCKED.value, 33 | ] 34 | 35 | @pytest.mark.parametrize( 36 | "user, result", 37 | [ 38 | [ 39 | TelegramUser( 40 | id=1, 41 | name="U", 42 | access_levels=[Levels.USER.value, Levels.ADMINISTRATOR.value], 43 | ), 44 | False, 45 | ], 46 | [ 47 | TelegramUser( 48 | id=1, 49 | name="U", 50 | access_levels=[ 51 | Levels.BLOCKED.value, 52 | ], 53 | ), 54 | True, 55 | ], 56 | [ 57 | TelegramUser( 58 | id=1, 59 | name="U", 60 | access_levels=[ 61 | Levels.BLOCKED.value, 62 | ], 63 | ), 64 | True, 65 | ], 66 | ], 67 | ) 68 | def test_blocked(self, user, result): 69 | assert user.is_blocked is result 70 | 71 | def test_not_empty_access_levels(self): 72 | with pytest.raises(UserWithNoAccessLevels): 73 | TelegramUser(id=1, name="U", access_levels=()) 74 | 75 | def test_duplicate_access_levels(self): 76 | user = TelegramUser( 77 | id=1, name="U", access_levels=[Levels.USER.value, Levels.USER.value] 78 | ) 79 | assert user.access_levels == [ 80 | Levels.USER.value, 81 | ] 82 | 83 | user.access_levels = [Levels.USER.value, Levels.USER.value] 84 | assert user.access_levels == [ 85 | Levels.USER.value, 86 | ] 87 | 88 | def test_blocked_role(self): 89 | with pytest.raises(BlockedUserWithOtherRole): 90 | TelegramUser( 91 | id=1, name="U", access_levels=[Levels.BLOCKED.value, Levels.USER.value] 92 | ) 93 | -------------------------------------------------------------------------------- /app/domain/order/models/order.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | from enum import Enum 4 | from typing import Optional, Tuple 5 | 6 | from app.domain.common.models.entity import entity 7 | from app.domain.common.models.value_object import value_object 8 | from app.domain.order.exceptions.order import ( 9 | ConfirmationAlreadyProcessed, 10 | OrderNotConfirmed, 11 | ) 12 | 13 | 14 | class ConfirmationPathType(Enum): 15 | CHIEF = "CHIEF" 16 | INFORM = "INFORM" 17 | 18 | 19 | @entity 20 | class User: 21 | id: int 22 | name: str 23 | 24 | 25 | @entity 26 | class Department: 27 | id: int 28 | name: str 29 | 30 | 31 | @entity 32 | class Cost: 33 | id: int 34 | name: str 35 | department: Department 36 | 37 | 38 | @entity 39 | class ConfirmationPaths: 40 | id: int 41 | user: User 42 | type: ConfirmationPathType 43 | 44 | 45 | @entity 46 | class ConfirmationPath: 47 | creator: User 48 | cost: Cost 49 | confirmation_paths: Tuple[ConfirmationPaths] 50 | 51 | 52 | @entity 53 | class Currency: 54 | id: int 55 | name: str 56 | 57 | 58 | @value_object 59 | class OrderDetails: 60 | date: datetime 61 | amount: Decimal 62 | vat: bool 63 | currency: Currency 64 | comment: str 65 | 66 | 67 | @entity 68 | class Confirmation: 69 | date: Optional[datetime] 70 | status: Optional[bool] 71 | 72 | @property 73 | def processed(self): 74 | return self.date and self.status is not None 75 | 76 | def confirm(self): 77 | if self.status is not None: 78 | raise ConfirmationAlreadyProcessed() 79 | 80 | self.date = datetime.now() 81 | self.status = True 82 | 83 | def deny(self): 84 | if self.status is not None: 85 | raise ConfirmationAlreadyProcessed() 86 | 87 | self.date = datetime.now() 88 | self.status = False 89 | 90 | def invert_status(self): 91 | if self.status is None: 92 | raise OrderNotConfirmed() 93 | 94 | self.status = not self.status 95 | 96 | def clean(self): 97 | self.date = None 98 | self.status = None 99 | 100 | 101 | @entity 102 | class Order: 103 | id: int 104 | confirmation_path: ConfirmationPath 105 | order_details: OrderDetails 106 | confirmation: Confirmation 107 | 108 | def change_confirmation_path(self, new_confirmation_path: ConfirmationPath): 109 | self.confirmation_path = new_confirmation_path 110 | 111 | def change_order_details(self, new_order_details: OrderDetails): 112 | self.order_details = new_order_details 113 | 114 | def confirm(self): 115 | self.confirmation.confirm() 116 | 117 | def deny(self): 118 | self.confirmation.deny() 119 | 120 | def invert_status(self): 121 | self.confirmation.invert_status() 122 | 123 | def clean_confirmation_status(self): 124 | self.confirmation.clean() 125 | -------------------------------------------------------------------------------- /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 | 7 | from app.domain.access_levels.exceptions.access_levels import AccessLevelNotExist 8 | from app.domain.access_levels.models.access_level import AccessLevel 9 | from app.domain.user import dto 10 | from app.domain.user.exceptions.user import UserNotExists 11 | from app.domain.user.interfaces.persistence import IUserReader, IUserRepo 12 | from app.domain.user.models.user import TelegramUser 13 | from app.infrastructure.database.exception_mapper import exception_mapper 14 | from app.infrastructure.database.repositories.repo import SQLAlchemyRepo 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class UserReader(SQLAlchemyRepo, IUserReader): 20 | @exception_mapper 21 | async def all_users(self) -> List[dto.User]: 22 | query = select(TelegramUser) 23 | 24 | result = await self.session.execute(query) 25 | users = result.scalars().all() 26 | 27 | return parse_obj_as(List[dto.User], users) 28 | 29 | @exception_mapper 30 | async def user_by_id(self, user_id: int) -> dto.User: 31 | user = await self.session.get(TelegramUser, user_id) 32 | 33 | if not user: 34 | raise UserNotExists 35 | 36 | return dto.User.from_orm(user) 37 | 38 | 39 | class UserRepo(SQLAlchemyRepo, IUserRepo): 40 | @exception_mapper 41 | async def _user(self, user_id: int) -> TelegramUser: 42 | user = await self.session.get(TelegramUser, user_id) 43 | 44 | if not user: 45 | raise UserNotExists 46 | 47 | return user 48 | 49 | @exception_mapper 50 | async def _populate_access_levels(self, user: TelegramUser) -> TelegramUser: 51 | access_levels = [] 52 | 53 | for level in user.access_levels: 54 | lvl = await self.session.get(AccessLevel, level.id) 55 | 56 | if level in self.session: 57 | self.session.expunge(level) 58 | if lvl is not None: 59 | access_levels.append(lvl) 60 | else: 61 | raise AccessLevelNotExist(f"Access level with id {level.id} not found") 62 | 63 | user.access_levels = access_levels 64 | return user 65 | 66 | @exception_mapper 67 | async def add_user(self, user: TelegramUser) -> TelegramUser: 68 | await self._populate_access_levels(user) 69 | self.session.add(user) 70 | await self.session.flush() 71 | 72 | return user 73 | 74 | @exception_mapper 75 | async def user_by_id(self, user_id: int) -> TelegramUser: 76 | return await self._user(user_id) 77 | 78 | @exception_mapper 79 | async def delete_user(self, user_id: int) -> None: 80 | user = await self._user(user_id) 81 | await self.session.delete(user) 82 | await self.session.flush() 83 | 84 | @exception_mapper 85 | async def edit_user(self, user: TelegramUser) -> TelegramUser: 86 | await self._populate_access_levels(user) 87 | await self.session.flush() 88 | 89 | return user 90 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/common.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import CallbackQuery 2 | from aiogram_dialog import DialogManager 3 | from aiogram_dialog.widgets.kbd import Multiselect, Select 4 | from aiogram_dialog.widgets.managed import ManagedWidgetAdapter 5 | from aiogram_dialog.widgets.text import Format, Multi 6 | 7 | from app.domain.access_levels.interfaces.uow import IAccessLevelUoW 8 | from app.domain.access_levels.usecases.access_levels import GetAccessLevels 9 | from app.domain.user.exceptions.user import UserNotExists 10 | from app.domain.user.interfaces.uow import IUserUoW 11 | from app.domain.user.usecases.user import GetUser, GetUsers 12 | from app.tgbot.constants import ACCESS_LEVELS, USER, USER_ID, USER_NAME, USERS 13 | from app.tgbot.handlers.dialogs.common import when_not 14 | 15 | 16 | async def get_users(dialog_manager: DialogManager, uow: IUserUoW, **kwargs): 17 | users = await GetUsers(uow)() 18 | return {USERS: users} 19 | 20 | 21 | async def save_user_id( 22 | query: CallbackQuery, 23 | select: ManagedWidgetAdapter[Select], 24 | manager: DialogManager, 25 | item_id: str, 26 | ): 27 | manager.current_context().dialog_data[USER_ID] = item_id 28 | await manager.dialog().next() 29 | await query.answer() 30 | 31 | 32 | async def get_user(dialog_manager: DialogManager, uow: IUserUoW, **kwargs): 33 | user_id = dialog_manager.current_context().dialog_data[USER_ID] 34 | try: 35 | user = await GetUser(uow)(int(user_id)) 36 | except UserNotExists: # ToDo check if need 37 | user = None 38 | return {USER: user} 39 | 40 | 41 | user_adding_process = Multi( 42 | Format(f"
User id:       \u007b{USER_ID}\u007d
", when=USER_ID), 43 | Format(f"
User id:       ...
", when=when_not(USER_ID)), 44 | Format(f"
User name:     \u007b{USER_NAME}\u007d
", when=USER_NAME), 45 | Format(f"
User name:     ...
", when=when_not(USER_NAME)), 46 | Format( 47 | f"
Access levels: \u007b{ACCESS_LEVELS}\u007d
\n", when=ACCESS_LEVELS 48 | ), 49 | Format(f"
Access levels: ...
\n", when=when_not(ACCESS_LEVELS)), 50 | ) 51 | 52 | 53 | async def get_user_data(dialog_manager: DialogManager, uow: IAccessLevelUoW, **kwargs): 54 | dialog_data = dialog_manager.current_context().dialog_data 55 | 56 | levels = [] 57 | levels_ids = dialog_data.get(ACCESS_LEVELS) 58 | if levels_ids: 59 | all_levels = await GetAccessLevels(uow)() 60 | for level in all_levels: 61 | if str(level.id) in levels_ids: 62 | levels.append(level) 63 | 64 | return { 65 | USER_ID: dialog_data.get(USER_ID), 66 | USER_NAME: dialog_data.get(USER_NAME), 67 | ACCESS_LEVELS: ", ".join((level.name.name for level in levels)), 68 | } 69 | 70 | 71 | async def save_selected_access_levels( 72 | event: CallbackQuery, button, manager: DialogManager, **kwargs 73 | ): 74 | 75 | access_levels: Multiselect = manager.dialog().find(ACCESS_LEVELS) 76 | selected_levels = access_levels.get_checked(manager) 77 | 78 | if not selected_levels: 79 | await event.answer("select at least one level") 80 | return 81 | 82 | manager.current_context().dialog_data[ACCESS_LEVELS] = selected_levels 83 | await manager.dialog().next() 84 | 85 | 86 | async def copy_start_data_to_context(_, dialog_manager: DialogManager): 87 | dialog_manager.current_context().dialog_data.update( 88 | dialog_manager.current_context().start_data 89 | ) 90 | -------------------------------------------------------------------------------- /app/api/handlers/user.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from fastapi import APIRouter, Depends, Response, status 4 | 5 | from app.api import providers 6 | from app.api.handlers.requests.user import UserCreateRequest 7 | from app.api.handlers.responses.errors import ( 8 | AccessLevelNotFoundError, 9 | UserAlreadyExistError, 10 | UserNotFoundError, 11 | ) 12 | from app.api.handlers.responses.user import Users 13 | from app.domain.access_levels.exceptions.access_levels import AccessLevelNotExist 14 | from app.domain.user.dto.user import PatchUserData, User, UserCreate, UserPatch 15 | from app.domain.user.exceptions.user import UserAlreadyExists, UserNotExists 16 | from app.domain.user.usecases.user import UserService 17 | 18 | user_router = APIRouter( 19 | prefix="/users", 20 | tags=["users"], 21 | ) 22 | 23 | 24 | @user_router.get( 25 | "/", response_model=Users, description="Return users with their access levels" 26 | ) 27 | async def get_users( 28 | user_service: UserService = Depends(providers.user_service_provider), 29 | ): 30 | users = await user_service.get_users() 31 | return Users( 32 | users=users, 33 | ) 34 | 35 | 36 | @user_router.post( 37 | "/{user_id}", 38 | responses={ 39 | status.HTTP_201_CREATED: {"model": User}, 40 | status.HTTP_400_BAD_REQUEST: { 41 | "model": Union[UserAlreadyExistError, AccessLevelNotFoundError] 42 | }, 43 | }, 44 | status_code=status.HTTP_201_CREATED, 45 | ) 46 | async def create_user( 47 | response: Response, 48 | user_id: int, 49 | user: UserCreateRequest, 50 | user_service: UserService = Depends(providers.user_service_provider), 51 | ): 52 | try: 53 | user = await user_service.add_user(UserCreate(id=user_id, **user.dict())) 54 | return user 55 | except UserAlreadyExists: 56 | response.status_code = status.HTTP_400_BAD_REQUEST 57 | return UserAlreadyExistError(user_id=user_id) 58 | except AccessLevelNotExist: 59 | response.status_code = status.HTTP_400_BAD_REQUEST 60 | return AccessLevelNotFoundError() 61 | 62 | 63 | @user_router.delete( 64 | "/{user_id}", 65 | responses={ 66 | status.HTTP_204_NO_CONTENT: {"model": None}, 67 | status.HTTP_400_BAD_REQUEST: {"model": UserNotFoundError}, 68 | }, 69 | status_code=status.HTTP_204_NO_CONTENT, 70 | ) 71 | async def delete_user( 72 | user_id: int, 73 | response: Response, 74 | user_service: UserService = Depends(providers.user_service_provider), 75 | ): 76 | try: 77 | await user_service.delete_user(user_id) 78 | return Response(status_code=status.HTTP_204_NO_CONTENT) 79 | except UserNotExists: 80 | response.status_code = status.HTTP_400_BAD_REQUEST 81 | return UserNotFoundError(user_id=user_id) 82 | 83 | 84 | @user_router.get( 85 | "/{user_id}", 86 | responses={ 87 | status.HTTP_200_OK: {"model": User}, 88 | status.HTTP_400_BAD_REQUEST: {"model": UserNotFoundError}, 89 | }, 90 | ) 91 | async def get_user( 92 | user_id: int, 93 | response: Response, 94 | user_service: UserService = Depends(providers.user_service_provider), 95 | ): 96 | try: 97 | return await user_service.get_user(user_id=user_id) 98 | except UserNotExists: 99 | response.status_code = status.HTTP_400_BAD_REQUEST 100 | return UserNotFoundError(user_id=user_id) 101 | 102 | 103 | @user_router.patch( 104 | "/{user_id}", 105 | responses={ 106 | status.HTTP_200_OK: {"model": User}, 107 | status.HTTP_400_BAD_REQUEST: { 108 | "model": Union[ 109 | UserNotFoundError, AccessLevelNotFoundError, UserAlreadyExistError 110 | ] 111 | }, 112 | }, 113 | ) 114 | async def patch_user( 115 | response: Response, 116 | user_id: int, 117 | user_data: PatchUserData, 118 | user_service: UserService = Depends(providers.user_service_provider), 119 | ): 120 | try: 121 | user = await user_service.patch_user(UserPatch(id=user_id, user_data=user_data)) 122 | except UserNotExists: 123 | response.status_code = status.HTTP_400_BAD_REQUEST 124 | return UserNotFoundError(user_id=user_id) 125 | except AccessLevelNotExist: 126 | response.status_code = status.HTTP_400_BAD_REQUEST 127 | return AccessLevelNotFoundError() 128 | except UserAlreadyExists: 129 | response.status_code = status.HTTP_400_BAD_REQUEST 130 | return UserAlreadyExistError(user_id=user_data.id) 131 | return user 132 | -------------------------------------------------------------------------------- /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.interfaces.uow import IAccessLevelUoW 22 | from app.domain.access_levels.usecases.access_levels import GetAccessLevels 23 | from app.domain.user.dto.user import UserCreate 24 | from app.domain.user.exceptions.user import UserAlreadyExists 25 | from app.domain.user.interfaces.uow import IUserUoW 26 | from app.domain.user.usecases.user import AddUser 27 | from app.tgbot import states 28 | from app.tgbot.constants import ALL_ACCESS_LEVELS, NO, USER_ID, YES_NO 29 | from app.tgbot.handlers.admin.user.common import ( 30 | ACCESS_LEVELS, 31 | USER_NAME, 32 | get_user_data, 33 | save_selected_access_levels, 34 | user_adding_process, 35 | ) 36 | from app.tgbot.handlers.dialogs.common import enable_send_mode, get_result 37 | 38 | 39 | async def request_id( 40 | message: Message, dialog: ManagedDialogAdapterProto, manager: DialogManager 41 | ): 42 | if not message.text.isdigit(): 43 | await message.answer("User id value must be digit") 44 | return 45 | 46 | manager.current_context().dialog_data[USER_ID] = message.text 47 | await dialog.next() 48 | 49 | 50 | async def request_name( 51 | message: Message, dialog: ManagedDialogAdapterProto, manager: DialogManager 52 | ): 53 | manager.current_context().dialog_data[USER_NAME] = message.text 54 | await dialog.next() 55 | 56 | 57 | async def get_access_levels( 58 | dialog_manager: DialogManager, uow: IAccessLevelUoW, **kwargs 59 | ): 60 | access_levels = await GetAccessLevels(uow)() 61 | access_levels = [(level.name.name, level.id) for level in access_levels] 62 | 63 | access_levels = { 64 | ALL_ACCESS_LEVELS: access_levels, 65 | } 66 | user_data = await get_user_data(dialog_manager, uow) 67 | 68 | return user_data | access_levels 69 | 70 | 71 | async def add_user_yes_no( 72 | query: CallbackQuery, 73 | select: ManagedWidgetAdapter[Select], 74 | manager: DialogManager, 75 | item_id: str, 76 | ): 77 | uow: IUserUoW = manager.data.get("uow") 78 | data = manager.current_context().dialog_data 79 | 80 | if item_id == NO: 81 | data["result"] = "User adding cancelled" 82 | await manager.done() 83 | return 84 | 85 | user = UserCreate( 86 | id=data[USER_ID], name=data[USER_NAME], access_levels=data[ACCESS_LEVELS] 87 | ) 88 | try: 89 | new_user = await AddUser(uow=uow)(user) 90 | levels_names = ", ".join((level.name.name for level in new_user.access_levels)) 91 | 92 | result = fmt.quote( 93 | f"User created\n" 94 | f"id: {data[USER_ID]}\n" 95 | f"name: {fmt.quote(data[USER_NAME])}\n" 96 | f"access level: {levels_names}\n" 97 | ) 98 | data["result"] = result 99 | 100 | except UserAlreadyExists: 101 | data["result"] = "User already exist" 102 | 103 | await manager.dialog().next() 104 | await query.answer() 105 | 106 | 107 | add_user_dialog = Dialog( 108 | Window( 109 | user_adding_process, 110 | Const("Input user id:"), 111 | MessageInput(request_id), 112 | Row(Cancel(), Next(when=USER_ID)), 113 | getter=get_user_data, 114 | state=states.user_db.AddUser.id, 115 | parse_mode="HTML", 116 | ), 117 | Window( 118 | user_adding_process, 119 | Format("Input user name:"), 120 | MessageInput(request_name), 121 | Row(Back(), Cancel(), Next(when=USER_NAME)), 122 | getter=get_user_data, 123 | state=states.user_db.AddUser.name, 124 | parse_mode="HTML", 125 | ), 126 | Window( 127 | user_adding_process, 128 | Const("Select access level"), 129 | Column( 130 | Multiselect( 131 | Format("✓ {item[0]}"), 132 | Format("{item[0]}"), 133 | id=ACCESS_LEVELS, 134 | item_id_getter=itemgetter(1), 135 | items=ALL_ACCESS_LEVELS, 136 | ) 137 | ), 138 | Button( 139 | Const("Save"), 140 | id="save_access_levels", 141 | on_click=save_selected_access_levels, 142 | ), 143 | Row(Back(), Cancel(), Next(when=ACCESS_LEVELS)), 144 | getter=get_access_levels, 145 | state=states.user_db.AddUser.access_level, 146 | parse_mode="HTML", 147 | ), 148 | Window( 149 | user_adding_process, 150 | Const("Confirm ?"), 151 | Select( 152 | Format("{item[0]}"), 153 | id="add_yes_no", 154 | item_id_getter=itemgetter(1), 155 | items=YES_NO, 156 | on_click=add_user_yes_no, 157 | ), 158 | Row(Back(), Cancel()), 159 | getter=get_user_data, 160 | state=states.user_db.AddUser.confirm, 161 | parse_mode="HTML", 162 | preview_add_transitions=[Next()], 163 | ), 164 | Window( 165 | Format("{result}"), 166 | Cancel(Const("Close"), on_click=enable_send_mode), 167 | getter=get_result, 168 | state=states.user_db.AddUser.result, 169 | parse_mode="HTML", 170 | ), 171 | ) 172 | -------------------------------------------------------------------------------- /app/infrastructure/database/alembic/versions/0d18dd8b3ec9_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 0d18dd8b3ec9 4 | Revises: 5 | Create Date: 2021-12-10 03:01:42.710026 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "0d18dd8b3ec9" 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "access_level", 22 | sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), 23 | sa.Column( 24 | "name", 25 | sa.Enum("UNREGISTERED", "BLOCKED", "USER", "ADMINISTRATOR", name="levels"), 26 | nullable=False, 27 | ), 28 | sa.PrimaryKeyConstraint("id"), 29 | ) 30 | op.create_table( 31 | "currency", 32 | sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), 33 | sa.Column("name", sa.TEXT(), nullable=False), 34 | sa.PrimaryKeyConstraint("id"), 35 | ) 36 | op.create_table( 37 | "department", 38 | sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), 39 | sa.Column("name", sa.TEXT(), nullable=False), 40 | sa.PrimaryKeyConstraint("id"), 41 | ) 42 | op.create_table( 43 | "inform_level", 44 | sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), 45 | sa.Column("name", sa.TEXT(), nullable=False), 46 | sa.PrimaryKeyConstraint("id"), 47 | ) 48 | op.create_table( 49 | "user", 50 | sa.Column("id", sa.BIGINT(), nullable=False), 51 | sa.Column("name", sa.TEXT(), nullable=False), 52 | sa.PrimaryKeyConstraint("id"), 53 | ) 54 | op.create_table( 55 | "cost", 56 | sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), 57 | sa.Column("department_id", sa.INTEGER(), nullable=True), 58 | sa.Column("name", sa.TEXT(), nullable=False), 59 | sa.ForeignKeyConstraint( 60 | ["department_id"], 61 | ["department.id"], 62 | ), 63 | sa.PrimaryKeyConstraint("id"), 64 | ) 65 | op.create_table( 66 | "user_access_levels", 67 | sa.Column("user_id", sa.BIGINT(), nullable=False), 68 | sa.Column("access_level_id", sa.INTEGER(), nullable=False), 69 | sa.ForeignKeyConstraint( 70 | ["access_level_id"], 71 | ["access_level.id"], 72 | onupdate="CASCADE", 73 | ondelete="CASCADE", 74 | ), 75 | sa.ForeignKeyConstraint( 76 | ["user_id"], ["user.id"], onupdate="CASCADE", ondelete="CASCADE" 77 | ), 78 | sa.PrimaryKeyConstraint("user_id", "access_level_id"), 79 | ) 80 | op.create_table( 81 | "confirmation_path", 82 | sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), 83 | sa.Column("cost_id", sa.INTEGER(), nullable=False), 84 | sa.Column("user_id", sa.BIGINT(), nullable=False), 85 | sa.ForeignKeyConstraint( 86 | ["cost_id"], 87 | ["cost.id"], 88 | ), 89 | sa.ForeignKeyConstraint( 90 | ["user_id"], 91 | ["user.id"], 92 | ), 93 | sa.PrimaryKeyConstraint("id"), 94 | sa.UniqueConstraint("cost_id", "user_id", name="_user_cost"), 95 | ) 96 | op.create_table( 97 | "confirmation_path_chief", 98 | sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), 99 | sa.Column("confirmation_path_id", sa.INTEGER(), nullable=False), 100 | sa.Column("chief_id", sa.BIGINT(), nullable=False), 101 | sa.Column("inform_level_id", sa.INTEGER(), nullable=False), 102 | sa.ForeignKeyConstraint( 103 | ["chief_id"], 104 | ["user.id"], 105 | ), 106 | sa.ForeignKeyConstraint( 107 | ["confirmation_path_id"], 108 | ["confirmation_path.id"], 109 | ), 110 | sa.ForeignKeyConstraint( 111 | ["inform_level_id"], 112 | ["inform_level.id"], 113 | ), 114 | sa.PrimaryKeyConstraint("id"), 115 | ) 116 | op.create_table( 117 | "order", 118 | sa.Column("id", sa.BIGINT(), autoincrement=True, nullable=False), 119 | sa.Column("user_id", sa.BIGINT(), nullable=False), 120 | sa.Column("confirmation_path_id", sa.INTEGER(), nullable=False), 121 | sa.Column("amount", sa.REAL(), nullable=False), 122 | sa.Column("vat", sa.BOOLEAN(), nullable=False), 123 | sa.Column("currency", sa.INTEGER(), nullable=False), 124 | sa.Column("cost_id", sa.INTEGER(), nullable=False), 125 | sa.Column("comment", sa.TEXT(), nullable=False), 126 | sa.Column("chief_confirm", sa.BOOLEAN(), nullable=True), 127 | sa.Column("date", sa.TIMESTAMP(), nullable=False), 128 | sa.Column("date_confirm", sa.TIMESTAMP(), nullable=True), 129 | sa.ForeignKeyConstraint( 130 | ["confirmation_path_id"], 131 | ["confirmation_path.id"], 132 | ), 133 | sa.ForeignKeyConstraint( 134 | ["cost_id"], 135 | ["cost.id"], 136 | ), 137 | sa.ForeignKeyConstraint( 138 | ["currency"], 139 | ["currency.id"], 140 | ), 141 | sa.ForeignKeyConstraint( 142 | ["user_id"], 143 | ["user.id"], 144 | ), 145 | sa.PrimaryKeyConstraint("id"), 146 | ) 147 | # ### end Alembic commands ### 148 | 149 | 150 | def downgrade(): 151 | # ### commands auto generated by Alembic - please adjust! ### 152 | op.drop_table("order") 153 | op.drop_table("confirmation_path_chief") 154 | op.drop_table("confirmation_path") 155 | op.drop_table("user_access_levels") 156 | op.drop_table("cost") 157 | op.drop_table("user") 158 | op.drop_table("inform_level") 159 | op.drop_table("department") 160 | op.drop_table("currency") 161 | op.drop_table("access_level") 162 | # ### end Alembic commands ### 163 | -------------------------------------------------------------------------------- /app/domain/user/usecases/user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC 3 | from typing import List 4 | 5 | from app.domain.access_levels.models.helper import id_to_access_levels 6 | from app.domain.common.events.dispatcher import EventDispatcher 7 | from app.domain.common.exceptions.base import AccessDenied 8 | from app.domain.common.exceptions.repo import UniqueViolationError 9 | from app.domain.user.access_policy import UserAccessPolicy 10 | from app.domain.user import dto 11 | from app.domain.user.exceptions.user import UserAlreadyExists 12 | from app.domain.user.interfaces.uow import IUserUoW 13 | from app.domain.user.models.user import TelegramUser 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class UserUseCase(ABC): 19 | def __init__(self, uow: IUserUoW, event_dispatcher: EventDispatcher) -> None: 20 | self.uow = uow 21 | self.event_dispatcher = event_dispatcher 22 | 23 | 24 | class GetUsers(UserUseCase): 25 | async def __call__(self) -> List[dto.User]: 26 | users = await self.uow.user_reader.all_users() 27 | return users 28 | 29 | 30 | class GetUser(UserUseCase): 31 | async def __call__(self, user_id: int) -> dto.User: 32 | """ 33 | Args: 34 | user_id: 35 | 36 | Returns: 37 | user 38 | Raises: 39 | UserNotExists - if user doesnt exist 40 | """ 41 | user = await self.uow.user_reader.user_by_id(user_id) 42 | return user 43 | 44 | 45 | class AddUser(UserUseCase): 46 | async def __call__(self, user: dto.UserCreate) -> dto.User: 47 | """ 48 | Args: 49 | user: payload for user creation 50 | 51 | Returns: 52 | created user 53 | Raises: 54 | UserAlreadyExists - if user already exist 55 | AccessLevelNotExist - if user access level not exist 56 | """ 57 | user = TelegramUser.create( 58 | id=user.id, 59 | name=user.name, 60 | access_levels=id_to_access_levels(user.access_levels), 61 | ) 62 | 63 | try: 64 | user = await self.uow.user.add_user(user=user) 65 | 66 | await self.event_dispatcher.publish_events(user.events) 67 | await self.uow.commit() 68 | await self.event_dispatcher.publish_notifications(user.events) 69 | user.events.clear() 70 | 71 | logger.info("User persisted: id=%s, %s", user.id, user) 72 | 73 | except UniqueViolationError: 74 | await self.uow.rollback() 75 | raise UserAlreadyExists 76 | 77 | return dto.User.from_orm(user) 78 | 79 | 80 | class DeleteUser(UserUseCase): 81 | async def __call__(self, user_id: int) -> None: 82 | """ 83 | 84 | Args: 85 | user_id: user id for deleting 86 | 87 | Raises: 88 | UserNotExists - if user for deleting doesnt exist 89 | 90 | 91 | """ 92 | await self.uow.user.delete_user(user_id) 93 | await self.uow.commit() 94 | 95 | logger.info("User deleted: id=%s,", user_id) 96 | 97 | 98 | class PatchUser(UserUseCase): 99 | async def __call__(self, new_user: dto.UserPatch) -> dto.User: 100 | """ 101 | Use for partially update User data 102 | 103 | Args: 104 | new_user: data for user editing 105 | 106 | Returns: 107 | edited user 108 | 109 | Raises: 110 | UserNotExists - if user for editing doesn't exist 111 | AccessLevelNotExist - if user access level not exist 112 | UserAlreadyExists - if already exist user with new user id 113 | """ 114 | user = await self.uow.user.user_by_id(user_id=new_user.id) 115 | 116 | if new_user.user_data.id: 117 | user.id = new_user.user_data.id 118 | if new_user.user_data.name: 119 | user.name = new_user.user_data.name 120 | if new_user.user_data.access_levels: 121 | user.access_levels = id_to_access_levels(new_user.user_data.access_levels) 122 | try: 123 | updated_user = await self.uow.user.edit_user(user=user) 124 | await self.uow.commit() 125 | except UniqueViolationError: 126 | await self.uow.rollback() 127 | raise UserAlreadyExists 128 | 129 | logger.info("User edited: id=%s,", updated_user.id) 130 | 131 | return dto.User.from_orm(updated_user) 132 | 133 | 134 | class UserService: 135 | def __init__( 136 | self, 137 | uow: IUserUoW, 138 | access_policy: UserAccessPolicy, 139 | event_dispatcher: EventDispatcher, 140 | ) -> None: 141 | self.uow = uow 142 | self.access_policy = access_policy 143 | self.event_dispatcher = event_dispatcher 144 | 145 | async def get_users(self) -> List[dto.User]: 146 | if not self.access_policy.read_user_policy(): 147 | raise AccessDenied() 148 | return await GetUsers(uow=self.uow, event_dispatcher=self.event_dispatcher)() 149 | 150 | async def get_user(self, user_id: int) -> dto.User: 151 | if not ( 152 | self.access_policy.read_user_policy() 153 | or self.access_policy.read_user_self(user_id) 154 | ): 155 | raise AccessDenied() 156 | return await GetUser(uow=self.uow, event_dispatcher=self.event_dispatcher)( 157 | user_id=user_id 158 | ) 159 | 160 | async def add_user(self, user: dto.UserCreate) -> dto.User: 161 | if not self.access_policy.modify_user(): 162 | raise AccessDenied() 163 | return await AddUser(uow=self.uow, event_dispatcher=self.event_dispatcher)( 164 | user=user 165 | ) 166 | 167 | async def delete_user(self, user_id: int) -> None: 168 | if not self.access_policy.modify_user(): 169 | raise AccessDenied() 170 | return await DeleteUser(uow=self.uow, event_dispatcher=self.event_dispatcher)( 171 | user_id=user_id 172 | ) 173 | 174 | async def patch_user(self, new_user: dto.UserPatch) -> dto.User: 175 | if not self.access_policy.modify_user(): 176 | raise AccessDenied() 177 | return await PatchUser(uow=self.uow, event_dispatcher=self.event_dispatcher)( 178 | new_user=new_user 179 | ) 180 | -------------------------------------------------------------------------------- /app/tgbot/handlers/admin/user/edit.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter, itemgetter 2 | from typing import Any, Union 3 | 4 | from aiogram.types import CallbackQuery, Message 5 | from aiogram.utils.text_decorations import html_decoration as fmt 6 | from aiogram_dialog import Data, Dialog, DialogManager, Window 7 | from aiogram_dialog.manager.protocols import ManagedDialogAdapterProto 8 | from aiogram_dialog.widgets.input import MessageInput 9 | from aiogram_dialog.widgets.kbd import ( 10 | Button, 11 | Cancel, 12 | Column, 13 | Multiselect, 14 | Next, 15 | ScrollingGroup, 16 | Select, 17 | SwitchTo, 18 | ) 19 | from aiogram_dialog.widgets.managed import ManagedWidgetAdapter 20 | from aiogram_dialog.widgets.text import Const, Format, Multi 21 | 22 | from app.domain.access_levels.interfaces.uow import IAccessLevelUoW 23 | from app.domain.access_levels.usecases.access_levels import ( 24 | GetAccessLevels, 25 | GetUserAccessLevels, 26 | ) 27 | from app.domain.user.dto.user import PatchUserData, UserPatch 28 | from app.domain.user.exceptions.user import UserNotExists 29 | from app.domain.user.interfaces.uow import IUserUoW 30 | from app.domain.user.usecases.user import GetUser, PatchUser 31 | from app.infrastructure.database.models import TelegramUserEntry 32 | from app.tgbot import states 33 | from app.tgbot.constants import ( 34 | ACCESS_LEVELS, 35 | ALL_ACCESS_LEVELS, 36 | FIELD, 37 | OLD_USER_ID, 38 | USER, 39 | USER_ID, 40 | USER_NAME, 41 | USERS, 42 | ) 43 | from app.tgbot.handlers.admin.user.common import ( 44 | copy_start_data_to_context, 45 | get_user_data, 46 | get_users, 47 | user_adding_process, 48 | ) 49 | from app.tgbot.handlers.dialogs.common import enable_send_mode, get_result 50 | 51 | IUserAccessLevelUoW = Union[IUserUoW, IAccessLevelUoW] 52 | 53 | 54 | async def save_user_id( 55 | query: CallbackQuery, 56 | select: ManagedWidgetAdapter[Select], 57 | manager: DialogManager, 58 | item_id: str, 59 | ): 60 | manager.current_context().dialog_data[OLD_USER_ID] = item_id 61 | await manager.dialog().next() 62 | await query.answer() 63 | 64 | 65 | async def get_old_user(dialog_manager: DialogManager, uow: IUserUoW, **kwargs): 66 | user_id = dialog_manager.current_context().dialog_data[OLD_USER_ID] 67 | try: 68 | user = await GetUser(uow)(int(user_id)) 69 | except UserNotExists: # ToDo check if need 70 | user = None 71 | return {USER: user} 72 | 73 | 74 | async def request_id( 75 | message: Message, dialog: ManagedDialogAdapterProto, manager: DialogManager 76 | ): 77 | if not message.text.isdigit(): 78 | await message.answer("User id value must be digit") 79 | return 80 | 81 | await manager.done({USER_ID: message.text}) 82 | 83 | 84 | async def request_name( 85 | message: Message, dialog: ManagedDialogAdapterProto, manager: DialogManager 86 | ): 87 | await manager.done({USER_NAME: message.text}) 88 | 89 | 90 | COLUMN_STATES = { 91 | TelegramUserEntry.id.name: states.user_db.EditUserId.request, 92 | TelegramUserEntry.name.name: states.user_db.EditUserName.request, 93 | TelegramUserEntry.access_levels.key: states.user_db.EditAccessLevel.request, 94 | } 95 | 96 | 97 | async def on_field_selected( 98 | query: CallbackQuery, 99 | select: ManagedWidgetAdapter[Select], 100 | manager: DialogManager, 101 | item_id: str, 102 | ): 103 | await manager.start( 104 | state=COLUMN_STATES[item_id], 105 | data=manager.current_context().dialog_data.copy(), 106 | ) 107 | await query.answer() 108 | 109 | 110 | async def get_user_edit_data( 111 | dialog_manager: DialogManager, uow: IUserAccessLevelUoW, **kwargs 112 | ): 113 | user_id = dialog_manager.current_context().dialog_data[OLD_USER_ID] 114 | 115 | user = await GetUser(uow)(int(user_id)) 116 | fields = TelegramUserEntry.__table__.columns.keys() 117 | fields.append(TelegramUserEntry.access_levels.key) 118 | fields = [(f, f) for f in fields] 119 | 120 | dialog_manager.current_context().dialog_data[USER] = user.json() 121 | 122 | user_data = await get_user_data(dialog_manager, uow) 123 | 124 | return {USER: user, "fields": fields} | user_data 125 | 126 | 127 | async def process_result(start_data: Data, result: Any, dialog_manager: DialogManager): 128 | if result.get(USER_ID): 129 | dialog_manager.current_context().dialog_data[USER_ID] = result[USER_ID] 130 | if result.get(USER_NAME): 131 | dialog_manager.current_context().dialog_data[USER_NAME] = result[USER_NAME] 132 | if result.get(ACCESS_LEVELS): 133 | dialog_manager.current_context().dialog_data[ACCESS_LEVELS] = result[ 134 | ACCESS_LEVELS 135 | ] 136 | 137 | 138 | async def get_access_levels( 139 | dialog_manager: DialogManager, uow: IUserAccessLevelUoW, **kwargs 140 | ): 141 | 142 | user_id = dialog_manager.current_context().dialog_data[OLD_USER_ID] 143 | access_levels = await GetAccessLevels(uow)() 144 | 145 | init_check = dialog_manager.current_context().dialog_data.get("init_check") 146 | if init_check is None: 147 | user_access_levels = await GetUserAccessLevels(uow)(int(user_id)) 148 | checked = dialog_manager.current_context().widget_data.setdefault( 149 | ACCESS_LEVELS, [] 150 | ) 151 | checked.extend(map(str, (level.id for level in user_access_levels))) 152 | dialog_manager.current_context().dialog_data["init_check"] = True 153 | 154 | access_levels = { 155 | ALL_ACCESS_LEVELS: [(level.name.name, level.id) for level in access_levels], 156 | } 157 | user_data = await get_old_user(dialog_manager, uow) 158 | 159 | return user_data | access_levels 160 | 161 | 162 | async def save_access_levels( 163 | query: CallbackQuery, button, dialog_manager: DialogManager, **kwargs 164 | ): 165 | access_levels: Multiselect = dialog_manager.dialog().find(ACCESS_LEVELS) 166 | selected_levels = access_levels.get_checked(dialog_manager) 167 | 168 | if not selected_levels: 169 | await query.answer("select at least one level") 170 | return 171 | 172 | await dialog_manager.done({ACCESS_LEVELS: selected_levels}) 173 | 174 | 175 | async def save_edited_user( 176 | query: CallbackQuery, button, dialog_manager: DialogManager, **kwargs 177 | ): 178 | uow: IUserUoW = dialog_manager.data.get("uow") 179 | data = dialog_manager.current_context().dialog_data 180 | 181 | user = UserPatch( 182 | id=data[OLD_USER_ID], 183 | user_data=PatchUserData( 184 | id=data.get(USER_ID), 185 | name=data.get(USER_NAME), 186 | access_levels=data.get(ACCESS_LEVELS), 187 | ), 188 | ) 189 | 190 | new_user = await PatchUser(uow=uow)(user) 191 | levels_names = ", ".join((level.name.name for level in new_user.access_levels)) 192 | 193 | result = fmt.quote( 194 | f"User {data[OLD_USER_ID]} edited\n" 195 | f"id: {new_user.id}\n" 196 | f"name: {new_user.name}\n" 197 | f"access level: {levels_names}\n" 198 | ) 199 | data["result"] = result 200 | 201 | await dialog_manager.dialog().next() 202 | await query.answer() 203 | 204 | 205 | user_id_dialog = Dialog( 206 | Window( 207 | Format("Input new id for {user.id}"), 208 | MessageInput(request_id), 209 | getter=get_old_user, 210 | state=states.user_db.EditUserId.request, 211 | ), 212 | on_start=copy_start_data_to_context, 213 | ) 214 | 215 | user_name_dialog = Dialog( 216 | Window( 217 | Format("Input new name for {user.id}"), 218 | MessageInput(request_name), 219 | getter=get_old_user, 220 | state=states.user_db.EditUserName.request, 221 | ), 222 | on_start=copy_start_data_to_context, 223 | ) 224 | 225 | user_access_levels_dialog = Dialog( 226 | Window( 227 | Format("Select access levels for {user.id}"), 228 | Column( 229 | Multiselect( 230 | Format("✓ {item[0]}"), 231 | Format("{item[0]}"), 232 | id=ACCESS_LEVELS, 233 | item_id_getter=itemgetter(1), 234 | items=ALL_ACCESS_LEVELS, 235 | ) 236 | ), 237 | Button( 238 | Const("Save"), 239 | id="save_access_levels", 240 | on_click=save_access_levels, 241 | ), 242 | getter=get_access_levels, 243 | state=states.user_db.EditAccessLevel.request, 244 | ), 245 | on_start=copy_start_data_to_context, 246 | ) 247 | 248 | 249 | edit_user_dialog = Dialog( 250 | Window( 251 | Const("Select user for editing:"), 252 | ScrollingGroup( 253 | Select( 254 | Format("{item.name} {item.id}"), 255 | id=OLD_USER_ID, 256 | item_id_getter=attrgetter("id"), 257 | items="users", 258 | on_click=save_user_id, 259 | ), 260 | id="user_scrolling", 261 | width=1, 262 | height=5, 263 | ), 264 | Cancel(), 265 | getter=get_users, 266 | state=states.user_db.EditUser.select_user, 267 | preview_add_transitions=[Next()], 268 | ), 269 | Window( 270 | Multi( 271 | Format("Selected user: {user.id}\nName: {user.name}\n\n"), 272 | user_adding_process, 273 | ), 274 | Column( 275 | Select( 276 | Format("{item[0]}"), 277 | id=FIELD, 278 | item_id_getter=itemgetter(1), 279 | items="fields", 280 | on_click=on_field_selected, 281 | ), 282 | Button(Const("Save"), id="save", on_click=save_edited_user), 283 | Cancel(), 284 | ), 285 | getter=get_user_edit_data, 286 | state=states.user_db.EditUser.select_field, 287 | parse_mode="HTML", 288 | preview_add_transitions=[ 289 | SwitchTo(Const(""), id="", state=states.user_db.EditUserId.request), 290 | SwitchTo(Const(""), id="", state=states.user_db.EditUserName.request), 291 | SwitchTo(Const(""), id="", state=states.user_db.EditAccessLevel.request), 292 | Next(), 293 | ], 294 | ), 295 | Window( 296 | Format("{result}"), 297 | Cancel(Const("Close"), on_click=enable_send_mode), 298 | getter=get_result, 299 | state=states.user_db.EditUser.result, 300 | parse_mode="HTML", 301 | ), 302 | on_process_result=process_result, 303 | ) 304 | --------------------------------------------------------------------------------