This commit is contained in:
Artem Tsyrulnikov
2025-11-08 16:16:04 +03:00
commit 3800c72662
36 changed files with 2136 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import uuid
from shared.postgres.postgres_helper import DatabaseConfig, DatabaseHelper
from src import domain
class Postgres(DatabaseHelper):
def __init__(self, config: DatabaseConfig, migrations_path: str = 'migration/versions') -> None:
super().__init__(config, migrations_path)
async def get_user(self, user_id: uuid.UUID) -> domain.User | None:
async with self.session_getter() as session:
return await session.get(domain.User, user_id)

View File

@@ -0,0 +1,6 @@
from shared.telegram import TelegramHelper
class Telegram(TelegramHelper):
async def send_message(self, chat_id: int, text: str) -> None:
await self.bot.send_message(chat_id=chat_id, text=text)

19
src/config.py Normal file
View File

@@ -0,0 +1,19 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from shared.config_helper import load_settings
from shared.logger import LoggerConfig
from shared.postgres import DatabaseConfig
from shared.telegram.telegram_bot import TelegramConfig
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file='.env', case_sensitive=False, env_nested_delimiter='__'
)
db: DatabaseConfig
logger: LoggerConfig
telegram: TelegramConfig
settings = load_settings(Settings)

View File

@@ -0,0 +1,17 @@
import uuid
from fastapi import APIRouter, Depends
from src import dto
from src.dependencies import get_usecase
from src.usecase.usecase import Usecase
user_router = APIRouter(prefix='/users', dependencies=[Depends(get_usecase)])
@user_router.get('/{user_id}')
async def get_user(
user_id: uuid.UUID,
usecase: Usecase = Depends(get_usecase),
) -> dto.GetUserOutput:
return await usecase.get_user(user_id)

View File

@@ -0,0 +1,83 @@
import uuid
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from src.dependencies import get_usecase
telegram_router = Router()
@telegram_router.message(Command('start'))
async def cmd_start(message: Message) -> None:
await message.answer(
'Привет! Я бот для работы с пользователями.\n\n'
'Доступные команды:\n'
'/getuser <user_id> - получить информацию о пользователе\n'
'/notify <user_id> <message> - отправить уведомление пользователю'
)
@telegram_router.message(Command('getuser'))
async def cmd_get_user(message: Message) -> None:
usecase = get_usecase()
if not message.text:
await message.answer('Ошибка: не удалось прочитать текст команды')
return
args = message.text.split()
if len(args) < 2:
await message.answer(
'Использование: /getuser <user_id>\n' 'Пример: /getuser 123e4567-e89b-12d3-a456-426614174000'
)
return
try:
user_id = uuid.UUID(args[1])
except ValueError:
await message.answer('Ошибка: неверный формат UUID')
return
try:
user = await usecase.get_user(user_id)
await message.answer(
f'Пользователь найден:\n'
f'ID: {user.id}\n'
f'Имя: {user.name}\n'
f'Создан: {user.created_at}\n'
f'Обновлён: {user.updated_at}'
)
except Exception as e:
await message.answer(f'Ошибка при получении пользователя: {str(e)}')
@telegram_router.message(Command('notify'))
async def cmd_notify_user(message: Message) -> None:
usecase = get_usecase()
if not message.text or not message.from_user:
await message.answer('Ошибка: не удалось прочитать команду')
return
args = message.text.split(maxsplit=2)
if len(args) < 3:
await message.answer(
'Использование: /notify <user_id> <message>\n'
'Пример: /notify 123e4567-e89b-12d3-a456-426614174000 Привет!'
)
return
try:
user_id = uuid.UUID(args[1])
notification_text = args[2]
except ValueError:
await message.answer('Ошибка: неверный формат UUID')
return
try:
await usecase.notify_user(user_id, message.from_user.id, notification_text)
await message.answer('Уведомление отправлено!')
except Exception as e:
await message.answer(f'Ошибка при отправке уведомления: {str(e)}')

36
src/dependencies.py Normal file
View File

@@ -0,0 +1,36 @@
from collections.abc import AsyncGenerator, Callable
from contextlib import AbstractAsyncContextManager
from sqlalchemy.ext.asyncio import AsyncSession
from src.usecase.usecase import Usecase
_usecase_instance: Usecase | None = None
_session_context_manager: Callable[[], AbstractAsyncContextManager[AsyncSession]] | None = None
def set_usecase(usecase: Usecase) -> None:
global _usecase_instance
_usecase_instance = usecase
def get_usecase() -> Usecase:
if _usecase_instance is None:
raise RuntimeError('Usecase not initialized. Call set_usecase() first')
return _usecase_instance
def set_get_session(
session_context_manager: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
global _session_context_manager
_session_context_manager = session_context_manager
async def get_session() -> AsyncGenerator[AsyncSession]:
if _session_context_manager is None:
raise RuntimeError('get_session not initialized. Call set_get_session() first')
async with _session_context_manager() as session:
yield session

5
src/domain/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
__all__ = ('Base', 'User', 'UserNotFound')
from .base import Base
from .error import UserNotFound
from .user import User

21
src/domain/base.py Normal file
View File

@@ -0,0 +1,21 @@
import datetime
import uuid
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
class Base(DeclarativeBase):
__abstract__ = True
# Авто имя таблиц по названию класса
@declared_attr.directive
def __tablename__(cls) -> str:
return f'{cls.__name__.lower()}s'
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime.datetime] = mapped_column(
server_default=func.now(), onupdate=func.now()
)
deleted_at: Mapped[datetime.datetime | None]

7
src/domain/error.py Normal file
View File

@@ -0,0 +1,7 @@
import uuid
from fastapi import HTTPException, status
def UserNotFound(user_id: uuid.UUID) -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found')

7
src/domain/user.py Normal file
View File

@@ -0,0 +1,7 @@
from sqlalchemy.orm import Mapped
from src import domain
class User(domain.Base):
name: Mapped[str]

3
src/dto/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
__all__ = ('GetUserInput', 'GetUserOutput')
from .get_user import GetUserInput, GetUserOutput

12
src/dto/get_user.py Normal file
View File

@@ -0,0 +1,12 @@
import uuid
import pydantic
class GetUserInput(pydantic.BaseModel):
user_id: uuid.UUID
class GetUserOutput(pydantic.BaseModel):
user_id: uuid.UUID
name: str

51
src/main.py Normal file
View File

@@ -0,0 +1,51 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.routing import APIRouter
from shared import logger
from src import dependencies
from src.adapter.postgres.postgres import Postgres
from src.adapter.telegram.telegram import Telegram
from src.config import settings
from src.controller.http.user import user_router
from src.controller.telegram.bot import telegram_router
from src.usecase.usecase import Usecase
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
await postgres.connect()
await telegram.start_polling()
yield
await telegram.stop_polling()
await postgres.close()
logger.init(settings.logger)
# Dependencies
postgres = Postgres(settings.db)
dependencies.set_get_session(postgres.session_getter)
telegram = Telegram(settings.telegram, telegram_router)
usecase = Usecase(
database=postgres,
telegram_writer=telegram,
)
dependencies.set_usecase(usecase)
app: FastAPI = FastAPI(lifespan=lifespan, version=settings.logger.APP_VERSION)
# Routing
api_router = APIRouter(prefix='/api')
v1_router = APIRouter(prefix='/v1')
v1_router.include_router(user_router)
api_router.include_router(v1_router)
app.include_router(api_router)

18
src/usecase/get_user.py Normal file
View File

@@ -0,0 +1,18 @@
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from src.usecase.usecase import Usecase
async def get_user(self: 'Usecase', user_id: uuid.UUID) -> dto.GetUserOutput:
user: domain.User | None = await self.database.get_user(user_id=user_id)
if user is None:
raise domain.UserNotFound(user_id)
return dto.GetUserOutput(
user_id=user.id,
name=user.name,
)

View File

@@ -0,0 +1,22 @@
import uuid
from typing import TYPE_CHECKING
from src import domain
if TYPE_CHECKING:
from src.usecase.usecase import Usecase
async def notify_user(self: 'Usecase', user_id: uuid.UUID, chat_id: int, message: str) -> None:
"""Send notification to user via Telegram"""
if self.telegram_writer is None:
raise RuntimeError('TelegramWriter not configured')
# Get user from database
user = await self.database.get_user(user_id)
if user is None:
raise domain.UserNotFound(user_id)
# Send personalized message
text = f'Привет, {user.name}!\n\n{message}'
await self.telegram_writer.send_message(chat_id=chat_id, text=text)

25
src/usecase/usecase.py Normal file
View File

@@ -0,0 +1,25 @@
import uuid
from dataclasses import dataclass
from typing import Protocol
from src import domain
from .get_user import get_user
from .notify_user import notify_user
class Database(Protocol):
async def get_user(self, user_id: uuid.UUID) -> domain.User | None: ...
class TelegramWriter(Protocol):
async def send_message(self, chat_id: int, text: str) -> None: ...
@dataclass
class Usecase:
database: Database
telegram_writer: TelegramWriter
get_user = get_user
notify_user = notify_user