diff --git a/backend/alembic/versions/002_add_premium_fields.py b/backend/alembic/versions/002_add_premium_fields.py new file mode 100644 index 0000000..313a824 --- /dev/null +++ b/backend/alembic/versions/002_add_premium_fields.py @@ -0,0 +1,28 @@ +"""Add premium fields to users + +Revision ID: 002 +Revises: 001 +Create Date: 2024-01-02 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '002' +down_revision = '001' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('users', sa.Column('is_premium', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('users', sa.Column('premium_until', sa.DateTime(), nullable=True)) + op.add_column('users', sa.Column('questions_used', sa.Integer(), nullable=False, server_default='0')) + + +def downgrade() -> None: + op.drop_column('users', 'questions_used') + op.drop_column('users', 'premium_until') + op.drop_column('users', 'is_premium') + diff --git a/backend/requirements.txt b/backend/requirements.txt index 05f1776..21388be 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -fastapi==0.104.1 +fastapi==0.100.1 uvicorn[standard]==0.24.0 sqlalchemy[asyncio]==2.0.23 asyncpg==0.29.0 @@ -9,7 +9,7 @@ python-multipart==0.0.6 httpx==0.25.2 PyMuPDF==1.23.8 Pillow==10.2.0 -dishka==1.7.2 +dishka==0.7.0 numpy==1.26.4 sentence-transformers==2.7.0 qdrant-client==1.9.0 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..87ff6db --- /dev/null +++ b/backend/run.py @@ -0,0 +1,23 @@ + + +import sys +import os +from pathlib import Path + +backend_dir = Path(__file__).parent +sys.path.insert(0, str(backend_dir)) + +if __name__ == "__main__": + import uvicorn + + + uvicorn.run( + "src.presentation.main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) + + + diff --git a/backend/src/application/services/cache_service.py b/backend/src/application/services/cache_service.py index 5d3c561..3cf4a77 100644 --- a/backend/src/application/services/cache_service.py +++ b/backend/src/application/services/cache_service.py @@ -26,7 +26,7 @@ class CacheService: "question": question, "answer": answer } - await self.reids_client.set_json(key, value, ttl or self.default_ttl) + await self.redis_client.set_json(key, value, ttl or self.default_ttl) async def invalidate_collection_cache(self, collection_id: UUID): pattern = f"rag:answer:{collection_id}:*" diff --git a/backend/src/application/use_cases/collection_use_cases.py b/backend/src/application/use_cases/collection_use_cases.py index 6e1b9f8..2c0f3c7 100644 --- a/backend/src/application/use_cases/collection_use_cases.py +++ b/backend/src/application/use_cases/collection_use_cases.py @@ -138,4 +138,68 @@ class CollectionUseCases: all_collections = {c.collection_id: c for c in owned + public + accessed_collections} return list(all_collections.values())[skip:skip+limit] + + async def list_collection_access(self, collection_id: UUID, user_id: UUID) -> list[CollectionAccess]: + """Получить список доступа к коллекции""" + collection = await self.get_collection(collection_id) + + has_access = await self.check_access(collection_id, user_id) + if not has_access: + raise ForbiddenError("У вас нет доступа к этой коллекции") + + return await self.access_repository.list_by_collection(collection_id) + + async def grant_access_by_telegram_id( + self, + collection_id: UUID, + telegram_id: str, + owner_id: UUID + ) -> CollectionAccess: + """Предоставить доступ пользователю к коллекции по Telegram ID""" + collection = await self.get_collection(collection_id) + + if collection.owner_id != owner_id: + raise ForbiddenError("Только владелец может предоставлять доступ") + + user = await self.user_repository.get_by_telegram_id(telegram_id) + if not user: + from src.domain.entities.user import User, UserRole + import logging + logger = logging.getLogger(__name__) + logger.info(f"Creating new user with telegram_id: {telegram_id}") + user = User(telegram_id=telegram_id, role=UserRole.USER) + try: + user = await self.user_repository.create(user) + logger.info(f"User created successfully: user_id={user.user_id}, telegram_id={user.telegram_id}") + except Exception as e: + logger.error(f"Error creating user: {e}") + raise + + if user.user_id == owner_id: + raise ForbiddenError("Владелец уже имеет доступ к коллекции") + + existing_access = await self.access_repository.get_by_user_and_collection(user.user_id, collection_id) + if existing_access: + return existing_access + + access = CollectionAccess(user_id=user.user_id, collection_id=collection_id) + return await self.access_repository.create(access) + + async def revoke_access_by_telegram_id( + self, + collection_id: UUID, + telegram_id: str, + owner_id: UUID + ) -> bool: + """Отозвать доступ пользователя к коллекции по Telegram ID""" + collection = await self.get_collection(collection_id) + + if collection.owner_id != owner_id: + raise ForbiddenError("Только владелец может отзывать доступ") + + user = await self.user_repository.get_by_telegram_id(telegram_id) + if not user: + raise NotFoundError(f"Пользователь с telegram_id {telegram_id} не найден") + + return await self.access_repository.delete_by_user_and_collection(user.user_id, collection_id) diff --git a/backend/src/application/use_cases/document_use_cases.py b/backend/src/application/use_cases/document_use_cases.py index f73b531..3ed254b 100644 --- a/backend/src/application/use_cases/document_use_cases.py +++ b/backend/src/application/use_cases/document_use_cases.py @@ -6,6 +6,7 @@ from typing import BinaryIO, Optional from src.domain.entities.document import Document from src.domain.repositories.document_repository import IDocumentRepository from src.domain.repositories.collection_repository import ICollectionRepository +from src.domain.repositories.collection_access_repository import ICollectionAccessRepository from src.application.services.document_parser_service import DocumentParserService from src.shared.exceptions import NotFoundError, ForbiddenError @@ -17,12 +18,25 @@ class DocumentUseCases: self, document_repository: IDocumentRepository, collection_repository: ICollectionRepository, + access_repository: ICollectionAccessRepository, parser_service: DocumentParserService ): self.document_repository = document_repository self.collection_repository = collection_repository + self.access_repository = access_repository self.parser_service = parser_service + async def _check_collection_access(self, user_id: UUID, collection) -> bool: + """Проверить доступ пользователя к коллекции""" + if collection.owner_id == user_id: + return True + + if collection.is_public: + return True + + access = await self.access_repository.get_by_user_and_collection(user_id, collection.collection_id) + return access is not None + async def create_document( self, collection_id: UUID, @@ -55,8 +69,9 @@ class DocumentUseCases: if not collection: raise NotFoundError(f"Коллекция {collection_id} не найдена") - if collection.owner_id != user_id: - raise ForbiddenError("Только владелец может добавлять документы") + has_access = await self._check_collection_access(user_id, collection) + if not has_access: + raise ForbiddenError("У вас нет доступа к этой коллекции") title, content = await self.parser_service.parse_pdf(file, filename) @@ -87,8 +102,11 @@ class DocumentUseCases: document = await self.get_document(document_id) collection = await self.collection_repository.get_by_id(document.collection_id) - if not collection or collection.owner_id != user_id: - raise ForbiddenError("Только владелец коллекции может изменять документы") + if not collection: + raise NotFoundError(f"Коллекция {document.collection_id} не найдена") + has_access = await self._check_collection_access(user_id, collection) + if not has_access: + raise ForbiddenError("У вас нет доступа к этой коллекции") if title is not None: document.title = title diff --git a/backend/src/application/use_cases/user_use_cases.py b/backend/src/application/use_cases/user_use_cases.py index 293263f..810164d 100644 --- a/backend/src/application/use_cases/user_use_cases.py +++ b/backend/src/application/use_cases/user_use_cases.py @@ -3,6 +3,7 @@ Use cases для работы с пользователями """ from uuid import UUID from typing import Optional +from datetime import datetime, timedelta from src.domain.entities.user import User, UserRole from src.domain.repositories.user_repository import IUserRepository from src.shared.exceptions import NotFoundError, ValidationError @@ -52,4 +53,27 @@ class UserUseCases: async def list_users(self, skip: int = 0, limit: int = 100) -> list[User]: """Получить список пользователей""" return await self.user_repository.list_all(skip=skip, limit=limit) + + async def increment_questions_used(self, telegram_id: str) -> User: + """Увеличить счетчик использованных вопросов""" + user = await self.user_repository.get_by_telegram_id(telegram_id) + if not user: + raise NotFoundError(f"Пользователь с telegram_id {telegram_id} не найден") + + user.questions_used += 1 + return await self.user_repository.update(user) + + async def activate_premium(self, telegram_id: str, days: int = 30) -> User: + """Активировать premium статус""" + user = await self.user_repository.get_by_telegram_id(telegram_id) + if not user: + raise NotFoundError(f"Пользователь с telegram_id {telegram_id} не найден") + + user.is_premium = True + if user.premium_until and user.premium_until > datetime.utcnow(): + user.premium_until = user.premium_until + timedelta(days=days) + else: + user.premium_until = datetime.utcnow() + timedelta(days=days) + + return await self.user_repository.update(user) diff --git a/backend/src/domain/entities/user.py b/backend/src/domain/entities/user.py index 5c3680d..3cc17a6 100644 --- a/backend/src/domain/entities/user.py +++ b/backend/src/domain/entities/user.py @@ -20,12 +20,18 @@ class User: telegram_id: str, role: UserRole = UserRole.USER, user_id: UUID | None = None, - created_at: datetime | None = None + created_at: datetime | None = None, + is_premium: bool = False, + premium_until: datetime | None = None, + questions_used: int = 0 ): self.user_id = user_id or uuid4() self.telegram_id = telegram_id self.role = role self.created_at = created_at or datetime.utcnow() + self.is_premium = is_premium + self.premium_until = premium_until + self.questions_used = questions_used def is_admin(self) -> bool: """проверка, является ли пользователь администратором""" diff --git a/backend/src/infrastructure/database/models.py b/backend/src/infrastructure/database/models.py index 8671da0..278b6ea 100644 --- a/backend/src/infrastructure/database/models.py +++ b/backend/src/infrastructure/database/models.py @@ -17,6 +17,10 @@ class UserModel(Base): telegram_id = Column(String, unique=True, nullable=False, index=True) role = Column(String, nullable=False, default="user") created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + is_premium = Column(Boolean, default=False, nullable=False) + premium_until = Column(DateTime, nullable=True) + questions_used = Column(Integer, default=0, nullable=False) + collections = relationship("CollectionModel", back_populates="owner", cascade="all, delete-orphan") conversations = relationship("ConversationModel", back_populates="user", cascade="all, delete-orphan") collection_accesses = relationship("CollectionAccessModel", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/src/infrastructure/repositories/postgresql/user_repository.py b/backend/src/infrastructure/repositories/postgresql/user_repository.py index d27801c..a7de61e 100644 --- a/backend/src/infrastructure/repositories/postgresql/user_repository.py +++ b/backend/src/infrastructure/repositories/postgresql/user_repository.py @@ -23,7 +23,10 @@ class PostgreSQLUserRepository(IUserRepository): user_id=user.user_id, telegram_id=user.telegram_id, role=user.role.value, - created_at=user.created_at + created_at=user.created_at, + is_premium=user.is_premium, + premium_until=user.premium_until, + questions_used=user.questions_used ) self.session.add(db_user) await self.session.commit() @@ -57,6 +60,9 @@ class PostgreSQLUserRepository(IUserRepository): db_user.telegram_id = user.telegram_id db_user.role = user.role.value + db_user.is_premium = user.is_premium + db_user.premium_until = user.premium_until + db_user.questions_used = user.questions_used await self.session.commit() await self.session.refresh(db_user) return self._to_entity(db_user) @@ -90,6 +96,9 @@ class PostgreSQLUserRepository(IUserRepository): user_id=db_user.user_id, telegram_id=db_user.telegram_id, role=UserRole(db_user.role), - created_at=db_user.created_at + created_at=db_user.created_at, + is_premium=db_user.is_premium, + premium_until=db_user.premium_until, + questions_used=db_user.questions_used ) diff --git a/backend/src/presentation/api/v1/admin.py b/backend/src/presentation/api/v1/admin.py index 1eed96e..d9c995a 100644 --- a/backend/src/presentation/api/v1/admin.py +++ b/backend/src/presentation/api/v1/admin.py @@ -1,58 +1,64 @@ -""" -Админ-панель - упрощенная версия через API эндпоинты -В будущем можно интегрировать полноценную админ-панель -""" -from __future__ import annotations - -from fastapi import APIRouter, HTTPException -from typing import List -from uuid import UUID -from dishka.integrations.fastapi import FromDishka -from src.presentation.schemas.user_schemas import UserResponse -from src.presentation.schemas.collection_schemas import CollectionResponse -from src.presentation.schemas.document_schemas import DocumentResponse -from src.presentation.schemas.conversation_schemas import ConversationResponse -from src.presentation.schemas.message_schemas import MessageResponse -from src.domain.entities.user import User, UserRole -from src.application.use_cases.user_use_cases import UserUseCases -from src.application.use_cases.collection_use_cases import CollectionUseCases - -router = APIRouter(prefix="/admin", tags=["admin"]) - - -@router.get("/users", response_model=List[UserResponse]) -async def admin_list_users( - skip: int = 0, - limit: int = 100, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[UserUseCases] = FromDishka() -): - """Получить список всех пользователей (только для админов)""" - if not current_user.is_admin(): - raise HTTPException(status_code=403, detail="Требуются права администратора") - users = await use_cases.list_users(skip=skip, limit=limit) - return [UserResponse.from_entity(user) for user in users] - - -@router.get("/collections", response_model=List[CollectionResponse]) -async def admin_list_collections( - skip: int = 0, - limit: int = 100, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[CollectionUseCases] = FromDishka() -): - """Получить список всех коллекций (только для админов)""" - from src.infrastructure.database.base import AsyncSessionLocal - from src.infrastructure.repositories.postgresql.collection_repository import PostgreSQLCollectionRepository - from sqlalchemy import select - from src.infrastructure.database.models import CollectionModel - - async with AsyncSessionLocal() as session: - repo = PostgreSQLCollectionRepository(session) - result = await session.execute( - select(CollectionModel).offset(skip).limit(limit) - ) - db_collections = result.scalars().all() - collections = [repo._to_entity(c) for c in db_collections if c] - return [CollectionResponse.from_entity(c) for c in collections if c] - +""" +Админ-панель - упрощенная версия через API эндпоинты +В будущем можно интегрировать полноценную админ-панель +""" +from fastapi import APIRouter, HTTPException, Request +from typing import List, Annotated +from uuid import UUID +from dishka.integrations.fastapi import FromDishka, inject +from src.domain.repositories.user_repository import IUserRepository +from src.presentation.middleware.auth_middleware import get_current_user +from src.presentation.schemas.user_schemas import UserResponse +from src.presentation.schemas.collection_schemas import CollectionResponse +from src.presentation.schemas.document_schemas import DocumentResponse +from src.presentation.schemas.conversation_schemas import ConversationResponse +from src.presentation.schemas.message_schemas import MessageResponse +from src.domain.entities.user import User, UserRole +from src.application.use_cases.user_use_cases import UserUseCases +from src.application.use_cases.collection_use_cases import CollectionUseCases + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.get("/users", response_model=List[UserResponse]) +@inject +async def admin_list_users( + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[UserUseCases, FromDishka()], + skip: int = 0, + limit: int = 100 +): + """Получить список всех пользователей (только для админов)""" + current_user = await get_current_user(request, user_repo) + if not current_user.is_admin(): + raise HTTPException(status_code=403, detail="Требуются права администратора") + users = await use_cases.list_users(skip=skip, limit=limit) + return [UserResponse.from_entity(user) for user in users] + + +@router.get("/collections", response_model=List[CollectionResponse]) +@inject +async def admin_list_collections( + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()], + skip: int = 0, + limit: int = 100 +): + """Получить список всех коллекций (только для админов)""" + current_user = await get_current_user(request, user_repo) + from src.infrastructure.database.base import AsyncSessionLocal + from src.infrastructure.repositories.postgresql.collection_repository import PostgreSQLCollectionRepository + from sqlalchemy import select + from src.infrastructure.database.models import CollectionModel + + async with AsyncSessionLocal() as session: + repo = PostgreSQLCollectionRepository(session) + result = await session.execute( + select(CollectionModel).offset(skip).limit(limit) + ) + db_collections = result.scalars().all() + collections = [repo._to_entity(c) for c in db_collections if c] + return [CollectionResponse.from_entity(c) for c in collections if c] + diff --git a/backend/src/presentation/api/v1/collections.py b/backend/src/presentation/api/v1/collections.py index 0b0431e..6789f2e 100644 --- a/backend/src/presentation/api/v1/collections.py +++ b/backend/src/presentation/api/v1/collections.py @@ -1,121 +1,226 @@ -""" -API роутеры для работы с коллекциями -""" -from __future__ import annotations - -from uuid import UUID -from fastapi import APIRouter, status -from fastapi.responses import JSONResponse -from typing import List -from dishka.integrations.fastapi import FromDishka -from src.presentation.schemas.collection_schemas import ( - CollectionCreate, - CollectionUpdate, - CollectionResponse, - CollectionAccessGrant, - CollectionAccessResponse -) -from src.application.use_cases.collection_use_cases import CollectionUseCases -from src.domain.entities.user import User - -router = APIRouter(prefix="/collections", tags=["collections"]) - - -@router.post("", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED) -async def create_collection( - collection_data: CollectionCreate, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[CollectionUseCases] = FromDishka() -): - """Создать коллекцию""" - collection = await use_cases.create_collection( - name=collection_data.name, - owner_id=current_user.user_id, - description=collection_data.description, - is_public=collection_data.is_public - ) - return CollectionResponse.from_entity(collection) - - -@router.get("/{collection_id}", response_model=CollectionResponse) -async def get_collection( - collection_id: UUID, - use_cases: FromDishka[CollectionUseCases] = FromDishka() -): - """Получить коллекцию по ID""" - collection = await use_cases.get_collection(collection_id) - return CollectionResponse.from_entity(collection) - - -@router.put("/{collection_id}", response_model=CollectionResponse) -async def update_collection( - collection_id: UUID, - collection_data: CollectionUpdate, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[CollectionUseCases] = FromDishka() -): - """Обновить коллекцию""" - collection = await use_cases.update_collection( - collection_id=collection_id, - user_id=current_user.user_id, - name=collection_data.name, - description=collection_data.description, - is_public=collection_data.is_public - ) - return CollectionResponse.from_entity(collection) - - -@router.delete("/{collection_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_collection( - collection_id: UUID, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[CollectionUseCases] = FromDishka() -): - """Удалить коллекцию""" - await use_cases.delete_collection(collection_id, current_user.user_id) - return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) - - -@router.get("", response_model=List[CollectionResponse]) -async def list_collections( - skip: int = 0, - limit: int = 100, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[CollectionUseCases] = FromDishka() -): - """Получить список коллекций, доступных пользователю""" - collections = await use_cases.list_user_collections( - user_id=current_user.user_id, - skip=skip, - limit=limit - ) - return [CollectionResponse.from_entity(c) for c in collections] - - -@router.post("/{collection_id}/access", response_model=CollectionAccessResponse, status_code=status.HTTP_201_CREATED) -async def grant_access( - collection_id: UUID, - access_data: CollectionAccessGrant, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[CollectionUseCases] = FromDishka() -): - """Предоставить доступ пользователю к коллекции""" - access = await use_cases.grant_access( - collection_id=collection_id, - user_id=access_data.user_id, - owner_id=current_user.user_id - ) - return CollectionAccessResponse.from_entity(access) - - -@router.delete("/{collection_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def revoke_access( - collection_id: UUID, - user_id: UUID, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[CollectionUseCases] = FromDishka() -): - """Отозвать доступ пользователя к коллекции""" - await use_cases.revoke_access(collection_id, user_id, current_user.user_id) - return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) - +""" +API роутеры для работы с коллекциями +""" +from uuid import UUID +from fastapi import APIRouter, status, Depends, Request +from fastapi.responses import JSONResponse +from typing import List, Annotated +from dishka.integrations.fastapi import FromDishka, inject +from src.domain.repositories.user_repository import IUserRepository +from src.presentation.middleware.auth_middleware import get_current_user +from src.presentation.schemas.collection_schemas import ( + CollectionCreate, + CollectionUpdate, + CollectionResponse, + CollectionAccessGrant, + CollectionAccessResponse, + CollectionAccessListResponse, + CollectionAccessUserInfo +) +from src.application.use_cases.collection_use_cases import CollectionUseCases +from src.domain.entities.user import User + +router = APIRouter(prefix="/collections", tags=["collections"]) + + +@router.post("", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED) +@inject +async def create_collection( + collection_data: CollectionCreate, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Создать коллекцию""" + current_user = await get_current_user(request, user_repo) + collection = await use_cases.create_collection( + name=collection_data.name, + owner_id=current_user.user_id, + description=collection_data.description, + is_public=collection_data.is_public + ) + return CollectionResponse.from_entity(collection) + + +@router.get("/{collection_id}", response_model=CollectionResponse) +@inject +async def get_collection( + collection_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Получить коллекцию по ID""" + current_user = await get_current_user(request, user_repo) + collection = await use_cases.get_collection(collection_id) + + has_access = await use_cases.check_access(collection_id, current_user.user_id) + if not has_access: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="У вас нет доступа к этой коллекции") + + return CollectionResponse.from_entity(collection) + + +@router.put("/{collection_id}", response_model=CollectionResponse) +@inject +async def update_collection( + collection_id: UUID, + collection_data: CollectionUpdate, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Обновить коллекцию""" + current_user = await get_current_user(request, user_repo) + collection = await use_cases.update_collection( + collection_id=collection_id, + user_id=current_user.user_id, + name=collection_data.name, + description=collection_data.description, + is_public=collection_data.is_public + ) + return CollectionResponse.from_entity(collection) + + +@router.delete("/{collection_id}", status_code=status.HTTP_204_NO_CONTENT) +@inject +async def delete_collection( + collection_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Удалить коллекцию""" + current_user = await get_current_user(request, user_repo) + await use_cases.delete_collection(collection_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("", response_model=List[CollectionResponse]) +@inject +async def list_collections( + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()], + skip: int = 0, + limit: int = 100 +): + """Получить список коллекций, доступных пользователю""" + current_user = await get_current_user(request, user_repo) + collections = await use_cases.list_user_collections( + user_id=current_user.user_id, + skip=skip, + limit=limit + ) + return [CollectionResponse.from_entity(c) for c in collections] + + +@router.post("/{collection_id}/access", response_model=CollectionAccessResponse, status_code=status.HTTP_201_CREATED) +@inject +async def grant_access( + collection_id: UUID, + access_data: CollectionAccessGrant, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Предоставить доступ пользователю к коллекции""" + current_user = await get_current_user(request, user_repo) + access = await use_cases.grant_access( + collection_id=collection_id, + user_id=access_data.user_id, + owner_id=current_user.user_id + ) + return CollectionAccessResponse.from_entity(access) + + +@router.delete("/{collection_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +@inject +async def revoke_access( + collection_id: UUID, + user_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Отозвать доступ пользователя к коллекции""" + current_user = await get_current_user(request, user_repo) + await use_cases.revoke_access(collection_id, user_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("/{collection_id}/access", response_model=List[CollectionAccessListResponse]) +@inject +async def list_collection_access( + collection_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Получить список пользователей с доступом к коллекции""" + current_user = await get_current_user(request, user_repo) + accesses = await use_cases.list_collection_access(collection_id, current_user.user_id) + result = [] + for access in accesses: + user = await user_repo.get_by_id(access.user_id) + if user: + user_info = CollectionAccessUserInfo( + user_id=user.user_id, + telegram_id=user.telegram_id, + role=user.role.value, + created_at=user.created_at + ) + result.append(CollectionAccessListResponse( + access_id=access.access_id, + user=user_info, + collection_id=access.collection_id, + created_at=access.created_at + )) + + return result + + +@router.post("/{collection_id}/access/telegram/{telegram_id}", response_model=CollectionAccessResponse, status_code=status.HTTP_201_CREATED) +@inject +async def grant_access_by_telegram_id( + collection_id: UUID, + telegram_id: str, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Предоставить доступ пользователю к коллекции по Telegram ID""" + import logging + logger = logging.getLogger(__name__) + + current_user = await get_current_user(request, user_repo) + logger.info(f"Granting access: collection_id={collection_id}, target_telegram_id={telegram_id}, owner_id={current_user.user_id}") + + try: + access = await use_cases.grant_access_by_telegram_id( + collection_id=collection_id, + telegram_id=telegram_id, + owner_id=current_user.user_id + ) + logger.info(f"Access granted successfully: access_id={access.access_id}") + return CollectionAccessResponse.from_entity(access) + except Exception as e: + logger.error(f"Error granting access: {e}", exc_info=True) + raise + + +@router.delete("/{collection_id}/access/telegram/{telegram_id}", status_code=status.HTTP_204_NO_CONTENT) +@inject +async def revoke_access_by_telegram_id( + collection_id: UUID, + telegram_id: str, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Отозвать доступ пользователя к коллекции по Telegram ID""" + current_user = await get_current_user(request, user_repo) + await use_cases.revoke_access_by_telegram_id(collection_id, telegram_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + diff --git a/backend/src/presentation/api/v1/conversations.py b/backend/src/presentation/api/v1/conversations.py index a48a661..80207be 100644 --- a/backend/src/presentation/api/v1/conversations.py +++ b/backend/src/presentation/api/v1/conversations.py @@ -1,71 +1,83 @@ -""" -API роутеры для работы с беседами -""" -from __future__ import annotations - -from uuid import UUID -from fastapi import APIRouter, status -from fastapi.responses import JSONResponse -from typing import List -from dishka.integrations.fastapi import FromDishka -from src.presentation.schemas.conversation_schemas import ( - ConversationCreate, - ConversationResponse -) -from src.application.use_cases.conversation_use_cases import ConversationUseCases -from src.domain.entities.user import User - -router = APIRouter(prefix="/conversations", tags=["conversations"]) - - -@router.post("", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED) -async def create_conversation( - conversation_data: ConversationCreate, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[ConversationUseCases] = FromDishka() -): - """Создать беседу""" - conversation = await use_cases.create_conversation( - user_id=current_user.user_id, - collection_id=conversation_data.collection_id - ) - return ConversationResponse.from_entity(conversation) - - -@router.get("/{conversation_id}", response_model=ConversationResponse) -async def get_conversation( - conversation_id: UUID, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[ConversationUseCases] = FromDishka() -): - """Получить беседу по ID""" - conversation = await use_cases.get_conversation(conversation_id, current_user.user_id) - return ConversationResponse.from_entity(conversation) - - -@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_conversation( - conversation_id: UUID, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[ConversationUseCases] = FromDishka() -): - """Удалить беседу""" - await use_cases.delete_conversation(conversation_id, current_user.user_id) - return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) - - -@router.get("", response_model=List[ConversationResponse]) -async def list_conversations( - skip: int = 0, - limit: int = 100, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[ConversationUseCases] = FromDishka() -): - """Получить список бесед пользователя""" - conversations = await use_cases.list_user_conversations( - user_id=current_user.user_id, - skip=skip, - limit=limit - ) - return [ConversationResponse.from_entity(c) for c in conversations] - +""" +API роутеры для работы с беседами +""" +from uuid import UUID +from fastapi import APIRouter, status, Depends, Request +from fastapi.responses import JSONResponse +from typing import List, Annotated +from dishka.integrations.fastapi import FromDishka, inject +from src.domain.repositories.user_repository import IUserRepository +from src.presentation.middleware.auth_middleware import get_current_user +from src.presentation.schemas.conversation_schemas import ( + ConversationCreate, + ConversationResponse +) +from src.application.use_cases.conversation_use_cases import ConversationUseCases +from src.domain.entities.user import User + +router = APIRouter(prefix="/conversations", tags=["conversations"]) + + +@router.post("", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED) +@inject +async def create_conversation( + conversation_data: ConversationCreate, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[ConversationUseCases, FromDishka()] +): + """Создать беседу""" + current_user = await get_current_user(request, user_repo) + conversation = await use_cases.create_conversation( + user_id=current_user.user_id, + collection_id=conversation_data.collection_id + ) + return ConversationResponse.from_entity(conversation) + + +@router.get("/{conversation_id}", response_model=ConversationResponse) +@inject +async def get_conversation( + conversation_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[ConversationUseCases, FromDishka()] +): + """Получить беседу по ID""" + current_user = await get_current_user(request, user_repo) + conversation = await use_cases.get_conversation(conversation_id, current_user.user_id) + return ConversationResponse.from_entity(conversation) + + +@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT) +@inject +async def delete_conversation( + conversation_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[ConversationUseCases, FromDishka()] +): + """Удалить беседу""" + current_user = await get_current_user(request, user_repo) + await use_cases.delete_conversation(conversation_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("", response_model=List[ConversationResponse]) +@inject +async def list_conversations( + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[ConversationUseCases, FromDishka()], + skip: int = 0, + limit: int = 100 +): + """Получить список бесед пользователя""" + current_user = await get_current_user(request, user_repo) + conversations = await use_cases.list_user_conversations( + user_id=current_user.user_id, + skip=skip, + limit=limit + ) + return [ConversationResponse.from_entity(c) for c in conversations] + diff --git a/backend/src/presentation/api/v1/documents.py b/backend/src/presentation/api/v1/documents.py index a2eedcf..a013850 100644 --- a/backend/src/presentation/api/v1/documents.py +++ b/backend/src/presentation/api/v1/documents.py @@ -1,123 +1,149 @@ -""" -API роутеры для работы с документами -""" -from __future__ import annotations - -from uuid import UUID -from fastapi import APIRouter, status, UploadFile, File -from fastapi.responses import JSONResponse -from typing import List -from dishka.integrations.fastapi import FromDishka -from src.presentation.schemas.document_schemas import ( - DocumentCreate, - DocumentUpdate, - DocumentResponse -) -from src.application.use_cases.document_use_cases import DocumentUseCases -from src.domain.entities.user import User - -router = APIRouter(prefix="/documents", tags=["documents"]) - - -@router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) -async def create_document( - document_data: DocumentCreate, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[DocumentUseCases] = FromDishka() -): - """Создать документ""" - document = await use_cases.create_document( - collection_id=document_data.collection_id, - title=document_data.title, - content=document_data.content, - metadata=document_data.metadata - ) - return DocumentResponse.from_entity(document) - - -@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) -async def upload_document( - collection_id: UUID, - file: UploadFile = File(...), - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[DocumentUseCases] = FromDishka() -): - """Загрузить и распарсить PDF документ или изображение""" - if not file.filename: - raise JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": "Имя файла не указано"} - ) - - supported_formats = ['.pdf', '.png', '.jpg', '.jpeg', '.tiff', '.bmp'] - file_ext = file.filename.lower().rsplit('.', 1)[-1] if '.' in file.filename else '' - - if f'.{file_ext}' not in supported_formats: - raise JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}"} - ) - - document = await use_cases.upload_and_parse_document( - collection_id=collection_id, - file=file.file, - filename=file.filename, - user_id=current_user.user_id - ) - return DocumentResponse.from_entity(document) - - -@router.get("/{document_id}", response_model=DocumentResponse) -async def get_document( - document_id: UUID, - use_cases: FromDishka[DocumentUseCases] = FromDishka() -): - """Получить документ по ID""" - document = await use_cases.get_document(document_id) - return DocumentResponse.from_entity(document) - - -@router.put("/{document_id}", response_model=DocumentResponse) -async def update_document( - document_id: UUID, - document_data: DocumentUpdate, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[DocumentUseCases] = FromDishka() -): - """Обновить документ""" - document = await use_cases.update_document( - document_id=document_id, - user_id=current_user.user_id, - title=document_data.title, - content=document_data.content, - metadata=document_data.metadata - ) - return DocumentResponse.from_entity(document) - - -@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_document( - document_id: UUID, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[DocumentUseCases] = FromDishka() -): - """Удалить документ""" - await use_cases.delete_document(document_id, current_user.user_id) - return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) - - -@router.get("/collection/{collection_id}", response_model=List[DocumentResponse]) -async def list_collection_documents( - collection_id: UUID, - skip: int = 0, - limit: int = 100, - use_cases: FromDishka[DocumentUseCases] = FromDishka() -): - """Получить документы коллекции""" - documents = await use_cases.list_collection_documents( - collection_id=collection_id, - skip=skip, - limit=limit - ) - return [DocumentResponse.from_entity(d) for d in documents] - +""" +API роутеры для работы с документами +""" +from uuid import UUID +from fastapi import APIRouter, status, UploadFile, File, Depends, Request, Query +from fastapi.responses import JSONResponse +from typing import List, Annotated +from dishka.integrations.fastapi import FromDishka, inject +from src.domain.repositories.user_repository import IUserRepository +from src.presentation.middleware.auth_middleware import get_current_user +from src.presentation.schemas.document_schemas import ( + DocumentCreate, + DocumentUpdate, + DocumentResponse +) +from src.application.use_cases.document_use_cases import DocumentUseCases +from src.application.use_cases.collection_use_cases import CollectionUseCases +from src.domain.entities.user import User + +router = APIRouter(prefix="/documents", tags=["documents"]) + + +@router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) +@inject +async def create_document( + document_data: DocumentCreate, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[DocumentUseCases, FromDishka()] +): + """Создать документ""" + current_user = await get_current_user(request, user_repo) + document = await use_cases.create_document( + collection_id=document_data.collection_id, + title=document_data.title, + content=document_data.content, + metadata=document_data.metadata + ) + return DocumentResponse.from_entity(document) + + +@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) +@inject +async def upload_document( + collection_id: UUID = Query(...), + request: Request = None, + user_repo: Annotated[IUserRepository, FromDishka()] = None, + use_cases: Annotated[DocumentUseCases, FromDishka()] = None, + file: UploadFile = File(...) +): + """Загрузить и распарсить PDF документ или изображение""" + current_user = await get_current_user(request, user_repo) + if not file.filename: + raise JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": "Имя файла не указано"} + ) + + supported_formats = ['.pdf', '.png', '.jpg', '.jpeg', '.tiff', '.bmp'] + file_ext = file.filename.lower().rsplit('.', 1)[-1] if '.' in file.filename else '' + + if f'.{file_ext}' not in supported_formats: + raise JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}"} + ) + + document = await use_cases.upload_and_parse_document( + collection_id=collection_id, + file=file.file, + filename=file.filename, + user_id=current_user.user_id + ) + return DocumentResponse.from_entity(document) + + +@router.get("/{document_id}", response_model=DocumentResponse) +@inject +async def get_document( + document_id: UUID, + use_cases: Annotated[DocumentUseCases, FromDishka()] +): + """Получить документ по ID""" + document = await use_cases.get_document(document_id) + return DocumentResponse.from_entity(document) + + +@router.put("/{document_id}", response_model=DocumentResponse) +@inject +async def update_document( + document_id: UUID, + document_data: DocumentUpdate, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[DocumentUseCases, FromDishka()] +): + """Обновить документ""" + current_user = await get_current_user(request, user_repo) + document = await use_cases.update_document( + document_id=document_id, + user_id=current_user.user_id, + title=document_data.title, + content=document_data.content, + metadata=document_data.metadata + ) + return DocumentResponse.from_entity(document) + + +@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT) +@inject +async def delete_document( + document_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[DocumentUseCases, FromDishka()] +): + """Удалить документ""" + current_user = await get_current_user(request, user_repo) + await use_cases.delete_document(document_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("/collection/{collection_id}", response_model=List[DocumentResponse]) +@inject +async def list_collection_documents( + collection_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[DocumentUseCases, FromDishka()], + collection_use_cases: Annotated[CollectionUseCases, FromDishka()], + skip: int = 0, + limit: int = 100 +): + """Получить документы коллекции""" + current_user = await get_current_user(request, user_repo) + + + has_access = await collection_use_cases.check_access(collection_id, current_user.user_id) + if not has_access: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="У вас нет доступа к этой коллекции") + + documents = await use_cases.list_collection_documents( + collection_id=collection_id, + skip=skip, + limit=limit + ) + return [DocumentResponse.from_entity(d) for d in documents] + diff --git a/backend/src/presentation/api/v1/messages.py b/backend/src/presentation/api/v1/messages.py index a7e11d7..b5bfc41 100644 --- a/backend/src/presentation/api/v1/messages.py +++ b/backend/src/presentation/api/v1/messages.py @@ -1,90 +1,99 @@ -""" -API роутеры для работы с сообщениями -""" -from __future__ import annotations - -from uuid import UUID -from fastapi import APIRouter, status -from fastapi.responses import JSONResponse -from typing import List -from dishka.integrations.fastapi import FromDishka -from src.presentation.schemas.message_schemas import ( - MessageCreate, - MessageUpdate, - MessageResponse -) -from src.application.use_cases.message_use_cases import MessageUseCases -from src.domain.entities.user import User - -router = APIRouter(prefix="/messages", tags=["messages"]) - - -@router.post("", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) -async def create_message( - message_data: MessageCreate, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[MessageUseCases] = FromDishka() -): - """Создать сообщение""" - message = await use_cases.create_message( - conversation_id=message_data.conversation_id, - content=message_data.content, - role=message_data.role, - user_id=current_user.user_id, - sources=message_data.sources - ) - return MessageResponse.from_entity(message) - - -@router.get("/{message_id}", response_model=MessageResponse) -async def get_message( - message_id: UUID, - use_cases: FromDishka[MessageUseCases] = FromDishka() -): - """Получить сообщение по ID""" - message = await use_cases.get_message(message_id) - return MessageResponse.from_entity(message) - - -@router.put("/{message_id}", response_model=MessageResponse) -async def update_message( - message_id: UUID, - message_data: MessageUpdate, - use_cases: FromDishka[MessageUseCases] = FromDishka() -): - """Обновить сообщение""" - message = await use_cases.update_message( - message_id=message_id, - content=message_data.content, - sources=message_data.sources - ) - return MessageResponse.from_entity(message) - - -@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_message( - message_id: UUID, - use_cases: FromDishka[MessageUseCases] = FromDishka() -): - """Удалить сообщение""" - await use_cases.delete_message(message_id) - return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) - - -@router.get("/conversation/{conversation_id}", response_model=List[MessageResponse]) -async def list_conversation_messages( - conversation_id: UUID, - skip: int = 0, - limit: int = 100, - current_user: FromDishka[User] = FromDishka(), - use_cases: FromDishka[MessageUseCases] = FromDishka() -): - """Получить сообщения беседы""" - messages = await use_cases.list_conversation_messages( - conversation_id=conversation_id, - user_id=current_user.user_id, - skip=skip, - limit=limit - ) - return [MessageResponse.from_entity(m) for m in messages] - +""" +API роутеры для работы с сообщениями +""" +from uuid import UUID +from fastapi import APIRouter, status, Depends, Request +from fastapi.responses import JSONResponse +from typing import List, Annotated +from dishka.integrations.fastapi import FromDishka, inject +from src.domain.repositories.user_repository import IUserRepository +from src.presentation.middleware.auth_middleware import get_current_user +from src.presentation.schemas.message_schemas import ( + MessageCreate, + MessageUpdate, + MessageResponse +) +from src.application.use_cases.message_use_cases import MessageUseCases +from src.domain.entities.user import User + +router = APIRouter(prefix="/messages", tags=["messages"]) + + +@router.post("", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +@inject +async def create_message( + message_data: MessageCreate, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[MessageUseCases, FromDishka()] +): + """Создать сообщение""" + current_user = await get_current_user(request, user_repo) + message = await use_cases.create_message( + conversation_id=message_data.conversation_id, + content=message_data.content, + role=message_data.role, + user_id=current_user.user_id, + sources=message_data.sources + ) + return MessageResponse.from_entity(message) + + +@router.get("/{message_id}", response_model=MessageResponse) +@inject +async def get_message( + message_id: UUID, + use_cases: Annotated[MessageUseCases, FromDishka()] +): + """Получить сообщение по ID""" + message = await use_cases.get_message(message_id) + return MessageResponse.from_entity(message) + + +@router.put("/{message_id}", response_model=MessageResponse) +@inject +async def update_message( + message_id: UUID, + message_data: MessageUpdate, + use_cases: Annotated[MessageUseCases, FromDishka()] +): + """Обновить сообщение""" + message = await use_cases.update_message( + message_id=message_id, + content=message_data.content, + sources=message_data.sources + ) + return MessageResponse.from_entity(message) + + +@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) +@inject +async def delete_message( + message_id: UUID, + use_cases: Annotated[MessageUseCases, FromDishka()] +): + """Удалить сообщение""" + await use_cases.delete_message(message_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("/conversation/{conversation_id}", response_model=List[MessageResponse]) +@inject +async def list_conversation_messages( + conversation_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[MessageUseCases, FromDishka()], + skip: int = 0, + limit: int = 100 +): + """Получить сообщения беседы""" + current_user = await get_current_user(request, user_repo) + messages = await use_cases.list_conversation_messages( + conversation_id=conversation_id, + user_id=current_user.user_id, + skip=skip, + limit=limit + ) + return [MessageResponse.from_entity(m) for m in messages] + diff --git a/backend/src/presentation/api/v1/rag.py b/backend/src/presentation/api/v1/rag.py index d859374..43595b0 100644 --- a/backend/src/presentation/api/v1/rag.py +++ b/backend/src/presentation/api/v1/rag.py @@ -1,10 +1,11 @@ """ API для RAG: индексация документов и ответы на вопросы """ -from __future__ import annotations - -from fastapi import APIRouter, status -from dishka.integrations.fastapi import FromDishka +from fastapi import APIRouter, status, Request +from typing import Annotated +from dishka.integrations.fastapi import FromDishka, inject +from src.domain.repositories.user_repository import IUserRepository +from src.presentation.middleware.auth_middleware import get_current_user from src.presentation.schemas.rag_schemas import ( QuestionRequest, RAGAnswer, @@ -19,23 +20,29 @@ router = APIRouter(prefix="/rag", tags=["rag"]) @router.post("/index", response_model=IndexDocumentResponse, status_code=status.HTTP_200_OK) +@inject async def index_document( body: IndexDocumentRequest, - use_cases: FromDishka[RAGUseCases] = FromDishka(), - current_user: FromDishka[User] = FromDishka(), + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[RAGUseCases, FromDishka()], ): """Индексирование идет через чанкирование, далее эмбеддинг и загрузка в векторную бд""" + current_user = await get_current_user(request, user_repo) result = await use_cases.index_document(body.document_id) return IndexDocumentResponse(**result) @router.post("/question", response_model=RAGAnswer, status_code=status.HTTP_200_OK) +@inject async def ask_question( body: QuestionRequest, - use_cases: FromDishka[RAGUseCases] = FromDishka(), - current_user: FromDishka[User] = FromDishka(), + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[RAGUseCases, FromDishka()], ): """Отвечает на вопрос, используя RAG в рамках беседы""" + current_user = await get_current_user(request, user_repo) result = await use_cases.ask_question( conversation_id=body.conversation_id, user_id=current_user.user_id, diff --git a/backend/src/presentation/api/v1/users.py b/backend/src/presentation/api/v1/users.py index c8d2b12..e95e921 100644 --- a/backend/src/presentation/api/v1/users.py +++ b/backend/src/presentation/api/v1/users.py @@ -1,83 +1,129 @@ -""" -API роутеры для работы с пользователями -""" -from __future__ import annotations - -from uuid import UUID -from fastapi import APIRouter, status -from fastapi.responses import JSONResponse -from typing import List -from dishka.integrations.fastapi import FromDishka -from src.presentation.schemas.user_schemas import UserCreate, UserUpdate, UserResponse -from src.application.use_cases.user_use_cases import UserUseCases -from src.domain.entities.user import User - -router = APIRouter(prefix="/users", tags=["users"]) - - -@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -async def create_user( - user_data: UserCreate, - use_cases: FromDishka[UserUseCases] = FromDishka() -): - """Создать пользователя""" - user = await use_cases.create_user( - telegram_id=user_data.telegram_id, - role=user_data.role - ) - return UserResponse.from_entity(user) - - -@router.get("/me", response_model=UserResponse) -async def get_current_user_info( - current_user: FromDishka[User] = FromDishka() -): - """Получить информацию о текущем пользователе""" - return UserResponse.from_entity(current_user) - - -@router.get("/{user_id}", response_model=UserResponse) -async def get_user( - user_id: UUID, - use_cases: FromDishka[UserUseCases] = FromDishka() -): - """Получить пользователя по ID""" - user = await use_cases.get_user(user_id) - return UserResponse.from_entity(user) - - -@router.put("/{user_id}", response_model=UserResponse) -async def update_user( - user_id: UUID, - user_data: UserUpdate, - use_cases: FromDishka[UserUseCases] = FromDishka() -): - """Обновить пользователя""" - user = await use_cases.update_user( - user_id=user_id, - telegram_id=user_data.telegram_id, - role=user_data.role - ) - return UserResponse.from_entity(user) - - -@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user( - user_id: UUID, - use_cases: FromDishka[UserUseCases] = FromDishka() -): - """Удалить пользователя""" - await use_cases.delete_user(user_id) - return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) - - -@router.get("", response_model=List[UserResponse]) -async def list_users( - skip: int = 0, - limit: int = 100, - use_cases: FromDishka[UserUseCases] = FromDishka() -): - """Получить список пользователей""" - users = await use_cases.list_users(skip=skip, limit=limit) - return [UserResponse.from_entity(user) for user in users] - +""" +API роутеры для работы с пользователями +""" +from uuid import UUID +from fastapi import APIRouter, status, Depends, Request +from fastapi.responses import JSONResponse +from typing import List, Annotated +from dishka.integrations.fastapi import FromDishka, inject +from src.domain.repositories.user_repository import IUserRepository +from src.presentation.middleware.auth_middleware import get_current_user +from src.presentation.schemas.user_schemas import UserCreate, UserUpdate, UserResponse +from src.application.use_cases.user_use_cases import UserUseCases +from src.domain.entities.user import User + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +@inject +async def create_user( + user_data: UserCreate, + use_cases: Annotated[UserUseCases, FromDishka()] +): + """Создать пользователя""" + user = await use_cases.create_user( + telegram_id=user_data.telegram_id, + role=user_data.role + ) + return UserResponse.from_entity(user) + + +@router.get("/me", response_model=UserResponse) +@inject +async def get_current_user_info( + request, + user_repo: Annotated[IUserRepository, FromDishka()] +): + """Получить информацию о текущем пользователе""" + current_user = await get_current_user(request, user_repo) + return UserResponse.from_entity(current_user) + + +@router.get("/telegram/{telegram_id}", response_model=UserResponse) +@inject +async def get_user_by_telegram_id( + telegram_id: str, + use_cases: Annotated[UserUseCases, FromDishka()] +): + """Получить пользователя по Telegram ID""" + user = await use_cases.get_user_by_telegram_id(telegram_id) + if not user: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail=f"Пользователь с telegram_id {telegram_id} не найден") + return UserResponse.from_entity(user) + + +@router.post("/telegram/{telegram_id}/increment-questions", response_model=UserResponse) +@inject +async def increment_questions( + telegram_id: str, + use_cases: Annotated[UserUseCases, FromDishka()] +): + """Увеличить счетчик использованных вопросов""" + user = await use_cases.increment_questions_used(telegram_id) + return UserResponse.from_entity(user) + + +@router.post("/telegram/{telegram_id}/activate-premium", response_model=UserResponse) +@inject +async def activate_premium( + + use_cases: Annotated[UserUseCases, FromDishka()], + telegram_id: str, + days: int = 30, +): + """Активировать premium статус""" + user = await use_cases.activate_premium(telegram_id, days=days) + return UserResponse.from_entity(user) + + +@router.get("/{user_id}", response_model=UserResponse) +@inject +async def get_user( + user_id: UUID, + use_cases: Annotated[UserUseCases, FromDishka()] +): + """Получить пользователя по ID""" + user = await use_cases.get_user(user_id) + return UserResponse.from_entity(user) + + +@router.put("/{user_id}", response_model=UserResponse) +@inject +async def update_user( + user_id: UUID, + user_data: UserUpdate, + use_cases: Annotated[UserUseCases, FromDishka()] +): + """Обновить пользователя""" + user = await use_cases.update_user( + user_id=user_id, + telegram_id=user_data.telegram_id, + role=user_data.role + ) + return UserResponse.from_entity(user) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +@inject +async def delete_user( + user_id: UUID, + use_cases: Annotated[UserUseCases, FromDishka()] +): + """Удалить пользователя""" + await use_cases.delete_user(user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + + +@router.get("", response_model=List[UserResponse]) +@inject +async def list_users( + use_cases: Annotated[UserUseCases, FromDishka()], + skip: int = 0, + limit: int = 100 +): + """Получить список пользователей""" + users = await use_cases.list_users(skip=skip, limit=limit) + return [UserResponse.from_entity(user) for user in users] + diff --git a/backend/src/presentation/main.py b/backend/src/presentation/main.py index 359fe35..f6a1fae 100644 --- a/backend/src/presentation/main.py +++ b/backend/src/presentation/main.py @@ -1,10 +1,11 @@ -from __future__ import annotations - import sys import os +import asyncio +from pathlib import Path -if '/app' not in sys.path: - sys.path.insert(0, '/app') +backend_dir = Path(__file__).parent.parent.parent +if str(backend_dir) not in sys.path: + sys.path.insert(0, str(backend_dir)) from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -22,15 +23,17 @@ from src.infrastructure.database.base import engine, Base @asynccontextmanager async def lifespan(app: FastAPI): """Управление жизненным циклом приложения""" - container = create_container() - setup_dishka(container, app) try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) except Exception as e: print(f"Примечание при создании таблиц: {e}") yield - await container.close() + if hasattr(app.state, 'container') and hasattr(app.state.container, 'close'): + if asyncio.iscoroutinefunction(app.state.container.close): + await app.state.container.close() + else: + app.state.container.close() await engine.dispose() @@ -41,6 +44,10 @@ app = FastAPI( lifespan=lifespan ) +container = create_container() +setup_dishka(container, app) +app.state.container = container + app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, diff --git a/backend/src/presentation/schemas/collection_schemas.py b/backend/src/presentation/schemas/collection_schemas.py index 8848fd2..d5ae94a 100644 --- a/backend/src/presentation/schemas/collection_schemas.py +++ b/backend/src/presentation/schemas/collection_schemas.py @@ -1,77 +1,96 @@ -""" -Pydantic схемы для Collection -""" -from uuid import UUID -from datetime import datetime -from pydantic import BaseModel - - -class CollectionBase(BaseModel): - """Базовая схема коллекции""" - name: str - description: str = "" - is_public: bool = False - - -class CollectionCreate(CollectionBase): - """Схема создания коллекции""" - pass - - -class CollectionUpdate(BaseModel): - """Схема обновления коллекции""" - name: str | None = None - description: str | None = None - is_public: bool | None = None - - -class CollectionResponse(BaseModel): - """Схема ответа с коллекцией""" - collection_id: UUID - name: str - description: str - owner_id: UUID - is_public: bool - created_at: datetime - - @classmethod - def from_entity(cls, collection: "Collection") -> "CollectionResponse": - """Создать из доменной сущности""" - return cls( - collection_id=collection.collection_id, - name=collection.name, - description=collection.description, - owner_id=collection.owner_id, - is_public=collection.is_public, - created_at=collection.created_at - ) - - class Config: - from_attributes = True - - -class CollectionAccessGrant(BaseModel): - """Схема предоставления доступа""" - user_id: UUID - - -class CollectionAccessResponse(BaseModel): - """Схема ответа с доступом""" - access_id: UUID - user_id: UUID - collection_id: UUID - created_at: datetime - - @classmethod - def from_entity(cls, access: "CollectionAccess") -> "CollectionAccessResponse": - """Создать из доменной сущности""" - return cls( - access_id=access.access_id, - user_id=access.user_id, - collection_id=access.collection_id, - created_at=access.created_at - ) - - class Config: - from_attributes = True - +""" +Pydantic схемы для Collection +""" +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel + + +class CollectionBase(BaseModel): + """Базовая схема коллекции""" + name: str + description: str = "" + is_public: bool = False + + +class CollectionCreate(CollectionBase): + """Схема создания коллекции""" + pass + + +class CollectionUpdate(BaseModel): + """Схема обновления коллекции""" + name: str | None = None + description: str | None = None + is_public: bool | None = None + + +class CollectionResponse(BaseModel): + """Схема ответа с коллекцией""" + collection_id: UUID + name: str + description: str + owner_id: UUID + is_public: bool + created_at: datetime + + @classmethod + def from_entity(cls, collection: "Collection") -> "CollectionResponse": + """Создать из доменной сущности""" + return cls( + collection_id=collection.collection_id, + name=collection.name, + description=collection.description, + owner_id=collection.owner_id, + is_public=collection.is_public, + created_at=collection.created_at + ) + + class Config: + from_attributes = True + + +class CollectionAccessGrant(BaseModel): + """Схема предоставления доступа""" + user_id: UUID + + +class CollectionAccessResponse(BaseModel): + """Схема ответа с доступом""" + access_id: UUID + user_id: UUID + collection_id: UUID + created_at: datetime + + @classmethod + def from_entity(cls, access: "CollectionAccess") -> "CollectionAccessResponse": + """Создать из доменной сущности""" + return cls( + access_id=access.access_id, + user_id=access.user_id, + collection_id=access.collection_id, + created_at=access.created_at + ) + + class Config: + from_attributes = True + + +class CollectionAccessUserInfo(BaseModel): + """Информация о пользователе с доступом""" + user_id: UUID + telegram_id: str + role: str + created_at: datetime + + +class CollectionAccessListResponse(BaseModel): + """Схема ответа со списком доступа""" + access_id: UUID + user: CollectionAccessUserInfo + collection_id: UUID + created_at: datetime + + class Config: + from_attributes = True + diff --git a/backend/src/presentation/schemas/conversation_schemas.py b/backend/src/presentation/schemas/conversation_schemas.py index 22aeb83..ba593c3 100644 --- a/backend/src/presentation/schemas/conversation_schemas.py +++ b/backend/src/presentation/schemas/conversation_schemas.py @@ -1,35 +1,35 @@ -""" -Pydantic схемы для Conversation -""" -from uuid import UUID -from datetime import datetime -from pydantic import BaseModel - - -class ConversationCreate(BaseModel): - """Схема создания беседы""" - collection_id: UUID - - -class ConversationResponse(BaseModel): - """Схема ответа с беседой""" - conversation_id: UUID - user_id: UUID - collection_id: UUID - created_at: datetime - updated_at: datetime - - @classmethod - def from_entity(cls, conversation: "Conversation") -> "ConversationResponse": - """Создать из доменной сущности""" - return cls( - conversation_id=conversation.conversation_id, - user_id=conversation.user_id, - collection_id=conversation.collection_id, - created_at=conversation.created_at, - updated_at=conversation.updated_at - ) - - class Config: - from_attributes = True - +""" +Pydantic схемы для Conversation +""" +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel + + +class ConversationCreate(BaseModel): + """Схема создания беседы""" + collection_id: UUID + + +class ConversationResponse(BaseModel): + """Схема ответа с беседой""" + conversation_id: UUID + user_id: UUID + collection_id: UUID + created_at: datetime + updated_at: datetime + + @classmethod + def from_entity(cls, conversation: "Conversation") -> "ConversationResponse": + """Создать из доменной сущности""" + return cls( + conversation_id=conversation.conversation_id, + user_id=conversation.user_id, + collection_id=conversation.collection_id, + created_at=conversation.created_at, + updated_at=conversation.updated_at + ) + + class Config: + from_attributes = True + diff --git a/backend/src/presentation/schemas/document_schemas.py b/backend/src/presentation/schemas/document_schemas.py index 8660e5c..6041fe0 100644 --- a/backend/src/presentation/schemas/document_schemas.py +++ b/backend/src/presentation/schemas/document_schemas.py @@ -1,52 +1,52 @@ -""" -Pydantic схемы для Document -""" -from uuid import UUID -from datetime import datetime -from typing import Any -from pydantic import BaseModel - - -class DocumentBase(BaseModel): - """Базовая схема документа""" - title: str - content: str - metadata: dict[str, Any] = {} - - -class DocumentCreate(DocumentBase): - """Схема создания документа""" - collection_id: UUID - - -class DocumentUpdate(BaseModel): - """Схема обновления документа""" - title: str | None = None - content: str | None = None - metadata: dict[str, Any] | None = None - - -class DocumentResponse(BaseModel): - """Схема ответа с документом""" - document_id: UUID - collection_id: UUID - title: str - content: str - metadata: dict[str, Any] - created_at: datetime - - @classmethod - def from_entity(cls, document: "Document") -> "DocumentResponse": - """Создать из доменной сущности""" - return cls( - document_id=document.document_id, - collection_id=document.collection_id, - title=document.title, - content=document.content, - metadata=document.metadata, - created_at=document.created_at - ) - - class Config: - from_attributes = True - +""" +Pydantic схемы для Document +""" +from uuid import UUID +from datetime import datetime +from typing import Any +from pydantic import BaseModel + + +class DocumentBase(BaseModel): + """Базовая схема документа""" + title: str + content: str + metadata: dict[str, Any] = {} + + +class DocumentCreate(DocumentBase): + """Схема создания документа""" + collection_id: UUID + + +class DocumentUpdate(BaseModel): + """Схема обновления документа""" + title: str | None = None + content: str | None = None + metadata: dict[str, Any] | None = None + + +class DocumentResponse(BaseModel): + """Схема ответа с документом""" + document_id: UUID + collection_id: UUID + title: str + content: str + metadata: dict[str, Any] + created_at: datetime + + @classmethod + def from_entity(cls, document: "Document") -> "DocumentResponse": + """Создать из доменной сущности""" + return cls( + document_id=document.document_id, + collection_id=document.collection_id, + title=document.title, + content=document.content, + metadata=document.metadata, + created_at=document.created_at + ) + + class Config: + from_attributes = True + diff --git a/backend/src/presentation/schemas/message_schemas.py b/backend/src/presentation/schemas/message_schemas.py index 2f346e8..17cba5a 100644 --- a/backend/src/presentation/schemas/message_schemas.py +++ b/backend/src/presentation/schemas/message_schemas.py @@ -1,52 +1,52 @@ -""" -Pydantic схемы для Message -""" -from uuid import UUID -from datetime import datetime -from typing import Any -from pydantic import BaseModel -from src.domain.entities.message import MessageRole - - -class MessageBase(BaseModel): - """Базовая схема сообщения""" - content: str - role: MessageRole - sources: dict[str, Any] = {} - - -class MessageCreate(MessageBase): - """Схема создания сообщения""" - conversation_id: UUID - - -class MessageUpdate(BaseModel): - """Схема обновления сообщения""" - content: str | None = None - sources: dict[str, Any] | None = None - - -class MessageResponse(BaseModel): - """Схема ответа с сообщением""" - message_id: UUID - conversation_id: UUID - content: str - role: MessageRole - sources: dict[str, Any] - created_at: datetime - - @classmethod - def from_entity(cls, message: "Message") -> "MessageResponse": - """Создать из доменной сущности""" - return cls( - message_id=message.message_id, - conversation_id=message.conversation_id, - content=message.content, - role=message.role, - sources=message.sources, - created_at=message.created_at - ) - - class Config: - from_attributes = True - +""" +Pydantic схемы для Message +""" +from uuid import UUID +from datetime import datetime +from typing import Any +from pydantic import BaseModel +from src.domain.entities.message import MessageRole + + +class MessageBase(BaseModel): + """Базовая схема сообщения""" + content: str + role: MessageRole + sources: dict[str, Any] = {} + + +class MessageCreate(MessageBase): + """Схема создания сообщения""" + conversation_id: UUID + + +class MessageUpdate(BaseModel): + """Схема обновления сообщения""" + content: str | None = None + sources: dict[str, Any] | None = None + + +class MessageResponse(BaseModel): + """Схема ответа с сообщением""" + message_id: UUID + conversation_id: UUID + content: str + role: MessageRole + sources: dict[str, Any] + created_at: datetime + + @classmethod + def from_entity(cls, message: "Message") -> "MessageResponse": + """Создать из доменной сущности""" + return cls( + message_id=message.message_id, + conversation_id=message.conversation_id, + content=message.content, + role=message.role, + sources=message.sources, + created_at=message.created_at + ) + + class Config: + from_attributes = True + diff --git a/backend/src/presentation/schemas/user_schemas.py b/backend/src/presentation/schemas/user_schemas.py index 16a14f0..2b2f65f 100644 --- a/backend/src/presentation/schemas/user_schemas.py +++ b/backend/src/presentation/schemas/user_schemas.py @@ -1,46 +1,52 @@ -""" -Pydantic схемы для User -""" -from uuid import UUID -from datetime import datetime -from pydantic import BaseModel -from src.domain.entities.user import UserRole - - -class UserBase(BaseModel): - """Базовая схема пользователя""" - telegram_id: str - role: UserRole - - -class UserCreate(UserBase): - """Схема создания пользователя""" - pass - - -class UserUpdate(BaseModel): - """Схема обновления пользователя""" - telegram_id: str | None = None - role: UserRole | None = None - - -class UserResponse(BaseModel): - """Схема ответа с пользователем""" - user_id: UUID - telegram_id: str - role: UserRole - created_at: datetime - - @classmethod - def from_entity(cls, user: "User") -> "UserResponse": - """Создать из доменной сущности""" - return cls( - user_id=user.user_id, - telegram_id=user.telegram_id, - role=user.role, - created_at=user.created_at - ) - - class Config: - from_attributes = True - +""" +Pydantic схемы для User +""" +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel +from src.domain.entities.user import UserRole + + +class UserBase(BaseModel): + """Базовая схема пользователя""" + telegram_id: str + role: UserRole + + +class UserCreate(UserBase): + """Схема создания пользователя""" + pass + + +class UserUpdate(BaseModel): + """Схема обновления пользователя""" + telegram_id: str | None = None + role: UserRole | None = None + + +class UserResponse(BaseModel): + """Схема ответа с пользователем""" + user_id: UUID + telegram_id: str + role: UserRole + created_at: datetime + is_premium: bool = False + premium_until: datetime | None = None + questions_used: int = 0 + + @classmethod + def from_entity(cls, user: "User") -> "UserResponse": + """Создать из доменной сущности""" + return cls( + user_id=user.user_id, + telegram_id=user.telegram_id, + role=user.role, + created_at=user.created_at, + is_premium=user.is_premium, + premium_until=user.premium_until, + questions_used=user.questions_used + ) + + class Config: + from_attributes = True + diff --git a/backend/src/shared/config.py b/backend/src/shared/config.py index 6ddc937..f60b4e7 100644 --- a/backend/src/shared/config.py +++ b/backend/src/shared/config.py @@ -7,18 +7,19 @@ from typing import Optional class Settings(BaseSettings): + """Настройки (загружаются из .env автоматически)""" - POSTGRES_HOST: str - POSTGRES_PORT: int - POSTGRES_USER: str - POSTGRES_PASSWORD: str - POSTGRES_DB: str + POSTGRES_HOST: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str = "postgres" + POSTGRES_PASSWORD: str = "postgres" + POSTGRES_DB: str = "lawyer_ai" - QDRANT_HOST: str - QDRANT_PORT: int + QDRANT_HOST: str = "localhost" + QDRANT_PORT: int = 6333 - REDIS_HOST: str - REDIS_PORT: int + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 TELEGRAM_BOT_TOKEN: Optional[str] = None YANDEX_OCR_API_KEY: Optional[str] = None @@ -29,11 +30,12 @@ class Settings(BaseSettings): APP_NAME: str = "ИИ-юрист" DEBUG: bool = False - SECRET_KEY: str + SECRET_KEY: str = "your-secret-key-change-in-production" CORS_ORIGINS: list[str] = ["*"] @property def database_url(self) -> str: + """Вычисляемый URL подключения""" return f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" class Config: diff --git a/backend/src/shared/di_container.py b/backend/src/shared/di_container.py index a5589e0..e773923 100644 --- a/backend/src/shared/di_container.py +++ b/backend/src/shared/di_container.py @@ -1,4 +1,4 @@ -from dishka import Container, Provider, Scope, provide +from dishka import Container, Provider, Scope, provide, make_async_container from fastapi import Request from sqlalchemy.ext.asyncio import AsyncSession from contextlib import asynccontextmanager @@ -39,13 +39,9 @@ from src.application.use_cases.rag_use_cases import RAGUseCases class DatabaseProvider(Provider): @provide(scope=Scope.REQUEST) - @asynccontextmanager async def get_db(self) -> AsyncSession: - async with AsyncSessionLocal() as session: - try: - yield session - finally: - await session.close() + session = AsyncSessionLocal() + return session class RepositoryProvider(Provider): @@ -77,7 +73,7 @@ class RepositoryProvider(Provider): class ServiceProvider(Provider): @provide(scope=Scope.APP) def get_redis_client(self) -> RedisClient: - return RedisClient() + return RedisClient(host=settings.REDIS_HOST, port=settings.REDIS_PORT) @provide(scope=Scope.APP) def get_cache_service(self, redis_client: RedisClient) -> CacheService: @@ -95,8 +91,6 @@ class ServiceProvider(Provider): def get_parser_service(self, ocr_service: YandexOCRService) -> DocumentParserService: return DocumentParserService(ocr_service) - -class VectorServiceProvider(Provider): @provide(scope=Scope.APP) def get_qdrant_client(self) -> QdrantClient: return QdrantClient(host=settings.QDRANT_HOST, port=settings.QDRANT_PORT) @@ -134,12 +128,6 @@ class VectorServiceProvider(Provider): splitter=text_splitter, ) -class AuthProvider(Provider): - @provide(scope=Scope.REQUEST) - async def get_current_user(self, request: Request, user_repo: IUserRepository) -> User: - from src.presentation.middleware.auth_middleware import get_current_user - return await get_current_user(request, user_repo) - class UseCaseProvider(Provider): @provide(scope=Scope.REQUEST) @@ -163,9 +151,10 @@ class UseCaseProvider(Provider): self, document_repo: IDocumentRepository, collection_repo: ICollectionRepository, + access_repo: ICollectionAccessRepository, parser_service: DocumentParserService ) -> DocumentUseCases: - return DocumentUseCases(document_repo, collection_repo, parser_service) + return DocumentUseCases(document_repo, collection_repo, access_repo, parser_service) @provide(scope=Scope.REQUEST) def get_conversation_use_cases( @@ -197,12 +186,10 @@ class UseCaseProvider(Provider): def create_container() -> Container: - container = Container() - container.add_provider(DatabaseProvider()) - container.add_provider(RepositoryProvider()) - container.add_provider(ServiceProvider()) - container.add_provider(AuthProvider()) - container.add_provider(UseCaseProvider()) - container.add_provider(VectorServiceProvider()) - return container + return make_async_container( + DatabaseProvider(), + RepositoryProvider(), + ServiceProvider(), + UseCaseProvider() + ) diff --git a/docker-compose.yml b/docker-compose.yml index 495c444..33fbac6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,7 @@ services: DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} DEEPSEEK_API_URL: ${DEEPSEEK_API_URL:-https://api.deepseek.com/v1/chat/completions} YANDEX_OCR_API_KEY: ${YANDEX_OCR_API_KEY} + BACKEND_URL: ${BACKEND_URL:-http://backend:8000/api/v1} DEBUG: "true" depends_on: - postgres diff --git a/tg_bot/application/services/rag_service.py b/tg_bot/application/services/rag_service.py index c5771ed..9f0f024 100644 --- a/tg_bot/application/services/rag_service.py +++ b/tg_bot/application/services/rag_service.py @@ -2,8 +2,6 @@ import aiohttp from tg_bot.infrastructure.external.deepseek_client import DeepSeekClient from tg_bot.config.settings import settings -BACKEND_URL = "http://localhost:8001/api/v1" - class RAGService: @@ -19,7 +17,7 @@ class RAGService: try: async with aiohttp.ClientSession() as session: async with session.get( - f"{BACKEND_URL}/users/telegram/{user_telegram_id}" + f"{settings.BACKEND_URL}/users/telegram/{user_telegram_id}" ) as user_response: if user_response.status != 200: return [] @@ -31,7 +29,7 @@ class RAGService: return [] async with session.get( - f"{BACKEND_URL}/collections/", + f"{settings.BACKEND_URL}/collections/", headers={"X-Telegram-ID": user_telegram_id} ) as collections_response: if collections_response.status != 200: @@ -48,7 +46,7 @@ class RAGService: try: async with aiohttp.ClientSession() as search_session: async with search_session.get( - f"{BACKEND_URL}/documents/collection/{collection_id}", + f"{settings.BACKEND_URL}/documents/collection/{collection_id}", params={"search": query, "limit": limit_per_collection}, headers={"X-Telegram-ID": user_telegram_id} ) as search_response: diff --git a/tg_bot/config/settings.py b/tg_bot/config/settings.py index 908bfd5..e7d67e1 100644 --- a/tg_bot/config/settings.py +++ b/tg_bot/config/settings.py @@ -1,9 +1,10 @@ -import os from typing import List, Optional from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): + """Настройки приложения (загружаются из .env файла в корне проекта)""" + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", @@ -13,26 +14,35 @@ class Settings(BaseSettings): APP_NAME: str = "VibeLawyerBot" VERSION: str = "0.1.0" - DEBUG: bool = True + DEBUG: bool = False + TELEGRAM_BOT_TOKEN: str = "" + FREE_QUESTIONS_LIMIT: int = 5 PAYMENT_AMOUNT: float = 500.0 - DATABASE_URL: str = "sqlite:///data/bot.db" + LOG_LEVEL: str = "INFO" LOG_FILE: str = "logs/bot.log" - YOOKASSA_SHOP_ID: str = "1230200" - YOOKASSA_SECRET_KEY: str = "test_GVoixmlp0FqohXcyFzFHbRlAUoA3B1I2aMtAkAE_ubw" + + YOOKASSA_SHOP_ID: str = "" + YOOKASSA_SECRET_KEY: str = "" YOOKASSA_RETURN_URL: str = "https://t.me/vibelawyer_bot" YOOKASSA_WEBHOOK_SECRET: Optional[str] = None + DEEPSEEK_API_KEY: Optional[str] = None DEEPSEEK_API_URL: str = "https://api.deepseek.com/v1/chat/completions" + + + BACKEND_URL: str = "http://localhost:8000/api/v1" + ADMIN_IDS_STR: str = "" @property def ADMIN_IDS(self) -> List[int]: + """Список ID администраторов из строки через запятую""" if not self.ADMIN_IDS_STR: return [] try: diff --git a/tg_bot/domain/services/user_service.py b/tg_bot/domain/services/user_service.py index 77c5dbc..67bccd9 100644 --- a/tg_bot/domain/services/user_service.py +++ b/tg_bot/domain/services/user_service.py @@ -1,20 +1,64 @@ -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from datetime import datetime, timedelta + +import aiohttp +from datetime import datetime from typing import Optional -from tg_bot.infrastructure.database.models import UserModel +from tg_bot.config.settings import settings +from tg_bot.infrastructure.http_client import create_http_session, normalize_backend_url + + +class User: + """Модель пользователя для телеграм-бота""" + def __init__(self, data: dict): + self.user_id = data.get("user_id") + self.telegram_id = data.get("telegram_id") + self.role = data.get("role") + created_at_str = data.get("created_at") + if created_at_str: + try: + created_at_str = created_at_str.replace("Z", "+00:00") + self.created_at = datetime.fromisoformat(created_at_str) + except (ValueError, AttributeError): + self.created_at = None + else: + self.created_at = None + + premium_until_str = data.get("premium_until") + if premium_until_str: + try: + premium_until_str = premium_until_str.replace("Z", "+00:00") + self.premium_until = datetime.fromisoformat(premium_until_str) + except (ValueError, AttributeError): + self.premium_until = None + else: + self.premium_until = None + + self.is_premium = data.get("is_premium", False) + self.questions_used = data.get("questions_used", 0) class UserService: + """Сервис для работы с пользователями через API бэкенда""" - def __init__(self, session: AsyncSession): - self.session = session + def __init__(self): + self.backend_url = normalize_backend_url(settings.BACKEND_URL) + print(f"UserService initialized with BACKEND_URL: {self.backend_url}") - async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[UserModel]: - result = await self.session.execute( - select(UserModel).filter_by(telegram_id=str(telegram_id)) - ) - return result.scalar_one_or_none() + async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[User]: + """Получить пользователя по Telegram ID""" + try: + url = f"{self.backend_url}/users/telegram/{telegram_id}" + async with create_http_session() as session: + async with session.get(url, ssl=False) as response: + if response.status == 200: + data = await response.json() + return User(data) + return None + except aiohttp.ClientConnectorError as e: + print(f"Backend not available at {self.backend_url}: {e}") + return None + except Exception as e: + print(f"Error getting user: {e}") + return None async def get_or_create_user( self, @@ -22,46 +66,64 @@ class UserService: username: str = "", first_name: str = "", last_name: str = "" - ) -> UserModel: + ) -> User: + """Получить или создать пользователя""" user = await self.get_user_by_telegram_id(telegram_id) if not user: - user = UserModel( - telegram_id=str(telegram_id), - username=username, - first_name=first_name, - last_name=last_name - ) - self.session.add(user) - await self.session.commit() - else: - user.username = username - user.first_name = first_name - user.last_name = last_name - await self.session.commit() + try: + async with create_http_session() as session: + async with session.post( + f"{self.backend_url}/users", + json={"telegram_id": str(telegram_id), "role": "user"}, + ssl=False + ) as response: + if response.status in [200, 201]: + data = await response.json() + return User(data) + else: + error_text = await response.text() + raise Exception( + f"Backend API returned status {response.status}: {error_text}. " + f"Make sure the backend server is running at {self.backend_url}" + ) + except aiohttp.ClientConnectorError as e: + error_msg = ( + f"Cannot connect to backend API at {self.backend_url}. " + f"Please ensure the backend server is running on port 8000. " + f"Start it with: cd project/backend && python run.py" + ) + print(f"Error creating user: {error_msg}") + print(f"Original error: {e}") + raise ConnectionError(error_msg) from e + except Exception as e: + error_msg = f"Error creating user: {e}. Backend URL: {self.backend_url}" + print(error_msg) + raise return user async def update_user_questions(self, telegram_id: int) -> bool: - user = await self.get_user_by_telegram_id(telegram_id) - if user: - user.questions_used += 1 - await self.session.commit() - return True - return False - - async def activate_premium(self, telegram_id: int) -> bool: + """Увеличить счетчик использованных вопросов""" try: - user = await self.get_user_by_telegram_id(telegram_id) - if user: - user.is_premium = True - if user.premium_until and user.premium_until > datetime.now(): - user.premium_until = user.premium_until + timedelta(days=30) - else: - user.premium_until = datetime.now() + timedelta(days=30) - await self.session.commit() - return True - else: - return False + async with create_http_session() as session: + async with session.post( + f"{self.backend_url}/users/telegram/{telegram_id}/increment-questions", + ssl=False + ) as response: + return response.status == 200 + except Exception as e: + print(f"Error updating questions: {e}") + return False + + async def activate_premium(self, telegram_id: int, days: int = 30) -> bool: + """Активировать premium статус""" + try: + async with create_http_session() as session: + async with session.post( + f"{self.backend_url}/users/telegram/{telegram_id}/activate-premium", + params={"days": days}, + ssl=False + ) as response: + return response.status == 200 except Exception as e: print(f"Error activating premium: {e}") - await self.session.rollback() return False diff --git a/tg_bot/infrastructure/__init__.py b/tg_bot/infrastructure/__init__.py new file mode 100644 index 0000000..f738cfb --- /dev/null +++ b/tg_bot/infrastructure/__init__.py @@ -0,0 +1,2 @@ +"""Infrastructure layer for the Telegram bot""" + diff --git a/tg_bot/infrastructure/database/database.py b/tg_bot/infrastructure/database/database.py deleted file mode 100644 index f49a009..0000000 --- a/tg_bot/infrastructure/database/database.py +++ /dev/null @@ -1,19 +0,0 @@ -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession -from tg_bot.config.settings import settings - -database_url = settings.DATABASE_URL -if database_url.startswith("sqlite:///"): - database_url = database_url.replace("sqlite:///", "sqlite+aiosqlite:///") - -engine = create_async_engine( - database_url, - echo=settings.DEBUG -) - -AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - -async def create_tables(): - from .models import Base - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - print(f"Таблицы созданы: {settings.DATABASE_URL}") \ No newline at end of file diff --git a/tg_bot/infrastructure/database/models.py b/tg_bot/infrastructure/database/models.py deleted file mode 100644 index f681729..0000000 --- a/tg_bot/infrastructure/database/models.py +++ /dev/null @@ -1,39 +0,0 @@ -import uuid -from datetime import datetime -from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text -from sqlalchemy.ext.declarative import declarative_base - -Base = declarative_base() - - -class UserModel(Base): - __tablename__ = "users" - user_id = Column("user_id", String(36), primary_key=True, default=lambda: str(uuid.uuid4())) - telegram_id = Column("telegram_id", String(100), nullable=False, unique=True) - created_at = Column("created_at", DateTime, default=datetime.utcnow, nullable=False) - role = Column("role", String(20), default="user", nullable=False) - - is_premium = Column(Boolean, default=False, nullable=False) - premium_until = Column(DateTime, nullable=True) - questions_used = Column(Integer, default=0, nullable=False) - - username = Column(String(100), nullable=True) - first_name = Column(String(100), nullable=True) - last_name = Column(String(100), nullable=True) - - -class PaymentModel(Base): - __tablename__ = "payments" - - id = Column(Integer, primary_key=True, autoincrement=True) - payment_id = Column(String(36), default=lambda: str(uuid.uuid4()), nullable=False, unique=True) - user_id = Column(Integer, nullable=False) - amount = Column(String(20), nullable=False) - currency = Column(String(3), default="RUB", nullable=False) - status = Column(String(20), default="pending", nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - yookassa_payment_id = Column(String(100), unique=True, nullable=True) - description = Column(Text, nullable=True) - - def __repr__(self): - return f"" \ No newline at end of file diff --git a/tg_bot/infrastructure/http_client.py b/tg_bot/infrastructure/http_client.py new file mode 100644 index 0000000..4549d63 --- /dev/null +++ b/tg_bot/infrastructure/http_client.py @@ -0,0 +1,88 @@ +"""HTTP client utilities for making requests to the backend API""" +import aiohttp +from typing import Optional +import ssl +import os + + +def get_windows_host_ip() -> Optional[str]: + """ + Get the Windows host IP address when running in WSL. + In WSL2, the Windows host IP is typically the first nameserver in /etc/resolv.conf. + """ + try: + if os.path.exists("/etc/resolv.conf"): + with open("/etc/resolv.conf", "r") as f: + for line in f: + if line.startswith("nameserver"): + ip = line.split()[1] + if ip not in ["127.0.0.1", "127.0.0.53"] and not ip.startswith("fe80"): + return ip + except Exception: + pass + return None + + +def normalize_backend_url(url: str) -> str: + """ + Normalize backend URL for better compatibility, especially on WSL and Docker. + """ + if not ("localhost" in url or "127.0.0.1" in url): + return url + if os.path.exists("/.dockerenv"): + print(f"Warning: Running in Docker but URL contains localhost: {url}") + print("Please set BACKEND_URL environment variable in docker-compose.yml to use Docker service name (e.g., http://backend:8000/api/v1)") + return url.replace("localhost", "127.0.0.1") + try: + if os.path.exists("/proc/version"): + with open("/proc/version", "r") as f: + version_content = f.read().lower() + if "microsoft" in version_content: + windows_ip = get_windows_host_ip() + if windows_ip: + if "localhost" in url or "127.0.0.1" in url: + url = url.replace("localhost", windows_ip).replace("127.0.0.1", windows_ip) + print(f"WSL detected: Using Windows host IP {windows_ip} for backend connection") + return url + except Exception as e: + print(f"Warning: Could not detect WSL environment: {e}") + + if url.startswith("http://localhost") or url.startswith("https://localhost"): + return url.replace("localhost", "127.0.0.1") + return url + + +def create_http_session(timeout: Optional[aiohttp.ClientTimeout] = None) -> aiohttp.ClientSession: + """ + Create a configured aiohttp ClientSession for backend API requests. + + Args: + timeout: Optional timeout configuration. Defaults to 30 seconds total timeout. + + Returns: + Configured aiohttp.ClientSession + """ + if timeout is None: + timeout = aiohttp.ClientTimeout(total=30, connect=10) + + connector = aiohttp.TCPConnector( + ssl=False, + limit=100, + limit_per_host=30, + force_close=True, + enable_cleanup_closed=True + ) + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + return aiohttp.ClientSession( + connector=connector, + timeout=timeout, + headers={ + "Content-Type": "application/json", + "Accept": "application/json" + } + ) + diff --git a/tg_bot/infrastructure/telegram/bot.py b/tg_bot/infrastructure/telegram/bot.py index 3490201..d4d0db1 100644 --- a/tg_bot/infrastructure/telegram/bot.py +++ b/tg_bot/infrastructure/telegram/bot.py @@ -10,7 +10,8 @@ from tg_bot.infrastructure.telegram.handlers import ( stats_handler, question_handler, buy_handler, - collection_handler + collection_handler, + document_handler ) logger = logging.getLogger(__name__) @@ -25,17 +26,28 @@ async def create_bot() -> tuple[Bot, Dispatcher]: dp.include_router(start_handler.router) dp.include_router(help_handler.router) dp.include_router(stats_handler.router) - dp.include_router(question_handler.router) dp.include_router(buy_handler.router) dp.include_router(collection_handler.router) + dp.include_router(document_handler.router) + dp.include_router(question_handler.router) return bot, dp async def start_bot(): bot = None try: + if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_BOT_TOKEN.strip(): + raise ValueError("TELEGRAM_BOT_TOKEN не установлен в переменных окружения или файле .env") + bot, dp = await create_bot() + try: + bot_info = await bot.get_me() + username = bot_info.username if bot_info.username else f"ID: {bot_info.id}" + logger.info(f"Бот успешно подключен: @{username}") + except Exception as e: + raise ValueError(f"Неверный токен Telegram бота: {e}") + try: webhook_info = await bot.get_webhook_info() if webhook_info.url: diff --git a/tg_bot/infrastructure/telegram/handlers/buy_handler.py b/tg_bot/infrastructure/telegram/handlers/buy_handler.py index 0411d00..1b4f0a3 100644 --- a/tg_bot/infrastructure/telegram/handlers/buy_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/buy_handler.py @@ -2,16 +2,14 @@ from aiogram import Router, types from aiogram.filters import Command from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton from decimal import Decimal +import aiohttp from tg_bot.config.settings import settings from tg_bot.payment.yookassa.client import yookassa_client -from tg_bot.infrastructure.database.database import AsyncSessionLocal -from tg_bot.infrastructure.database.models import PaymentModel from tg_bot.domain.services.user_service import UserService -from sqlalchemy import select -import uuid -from datetime import datetime, timedelta +from datetime import datetime router = Router() +user_service = UserService() @router.message(Command("buy")) @@ -19,23 +17,23 @@ async def cmd_buy(message: Message): user_id = message.from_user.id username = message.from_user.username or f"user_{user_id}" - async with AsyncSessionLocal() as session: - try: - user_service = UserService(session) - user = await user_service.get_user_by_telegram_id(user_id) + try: + user = await user_service.get_user_by_telegram_id(user_id) - if user and user.is_premium and user.premium_until and user.premium_until > datetime.now(): - days_left = (user.premium_until - datetime.now()).days - await message.answer( - f"У вас уже есть активная подписка!\n\n" - f"• Статус: Premium активен\n" - f"• Действует до: {user.premium_until.strftime('%d.%m.%Y')}\n" - f"• Осталось дней: {days_left}\n\n" - f"Новая подписка будет добавлена к текущей.", - parse_mode="HTML" - ) - except Exception: - pass + if user and user.is_premium and user.premium_until and user.premium_until > datetime.now(): + days_left = (user.premium_until - datetime.now()).days + await message.answer( + f"У вас уже есть активная подписка!\n\n" + f"• Статус: Premium активен\n" + f"• Действует до: {user.premium_until.strftime('%d.%m.%Y')}\n" + f"• Осталось дней: {days_left}\n\n" + f"Новая подписка будет добавлена к текущей.", + parse_mode="HTML" + ) + except aiohttp.ClientError as e: + print(f"Не удалось подключиться к backend при проверке подписки: {e}") + except Exception as e: + print(f"Ошибка при проверке подписки: {e}") await message.answer( "*Создаю ссылку для оплаты...*\n\n" @@ -49,24 +47,8 @@ async def cmd_buy(message: Message): description=f"Подписка VibeLawyerBot для @{username}", user_id=user_id ) - - async with AsyncSessionLocal() as session: - try: - payment = PaymentModel( - payment_id=str(uuid.uuid4()), - user_id=user_id, - amount=str(settings.PAYMENT_AMOUNT), - currency="RUB", - status="pending", - yookassa_payment_id=payment_data["id"], - description="Оплата подписки VibeLawyerBot" - ) - session.add(payment) - await session.commit() - print(f"Платёж сохранён в БД: {payment.payment_id}") - except Exception as e: - print(f"Ошибка сохранения платежа в БД: {e}") - await session.rollback() + + print(f"Платёж создан в ЮKассе: {payment_data['id']}") keyboard = InlineKeyboardMarkup( inline_keyboard=[ @@ -139,27 +121,15 @@ async def check_payment_status(callback_query: types.CallbackQuery): payment = YooPayment.find_one(yookassa_id) if payment.status == "succeeded": - async with AsyncSessionLocal() as session: - try: - result = await session.execute( - select(PaymentModel).filter_by(yookassa_payment_id=yookassa_id) - ) - db_payment = result.scalar_one_or_none() - - if db_payment: - db_payment.status = "succeeded" - user_service = UserService(session) - success = await user_service.activate_premium(user_id) - if success: - user = await user_service.get_user_by_telegram_id(user_id) - await session.commit() - if not user: - user = await user_service.get_user_by_telegram_id(user_id) - + try: + success = await user_service.activate_premium(user_id) + if success: + user = await user_service.get_user_by_telegram_id(user_id) + if user: await callback_query.message.answer( "Оплата подтверждена!\n\n" f"Ваш premium-доступ активирован до: " - f"{user.premium_until.strftime('%d.%m.%Y')}\n\n" + f"{user.premium_until.strftime('%d.%m.%Y') if user.premium_until else 'Не указано'}\n\n" "Теперь вы можете:\n" "• Задавать неограниченное количество вопросов\n" "• Получать приоритетные ответы\n" @@ -169,12 +139,23 @@ async def check_payment_status(callback_query: types.CallbackQuery): ) else: await callback_query.message.answer( - "Платёж найден в ЮKассе, но не в нашей БД\n\n" + "Оплата подтверждена, но не удалось активировать premium\n\n" "Пожалуйста, обратитесь к администратору.", parse_mode="HTML" ) - except Exception as e: - print(f"Ошибка обработки платежа: {e}") + else: + await callback_query.message.answer( + "Оплата подтверждена, но не удалось активировать premium\n\n" + "Пожалуйста, обратитесь к администратору.", + parse_mode="HTML" + ) + except Exception as e: + print(f"Ошибка обработки платежа: {e}") + await callback_query.message.answer( + "Ошибка активации premium\n\n" + "Пожалуйста, обратитесь к администратору.", + parse_mode="HTML" + ) elif payment.status == "pending": await callback_query.message.answer( @@ -206,42 +187,13 @@ async def check_payment_status(callback_query: types.CallbackQuery): @router.message(Command("mypayments")) async def cmd_my_payments(message: Message): - user_id = message.from_user.id - - async with AsyncSessionLocal() as session: - try: - result = await session.execute( - select(PaymentModel).filter_by(user_id=user_id).order_by(PaymentModel.created_at.desc()).limit(10) - ) - payments = result.scalars().all() - - if not payments: - await message.answer( - "У вас пока нет платежей\n\n" - "Используйте команду /buy чтобы оформить подписку.", - parse_mode="HTML" - ) - return - - response = ["Ваши последние платежи:\n"] - - for i, payment in enumerate(payments, 1): - status_text = "Успешно" if payment.status == "succeeded" else "Ожидание" if payment.status == "pending" else "Ошибка" - response.append( - f"\n{i}. {payment.amount} руб. ({status_text})\n" - f"Статус: {payment.status}\n" - f"Дата: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n" - f"ID: {payment.payment_id[:8]}..." - ) - - response.append("\n\nПолный доступ открывается после успешной оплаты") - - await message.answer( - "\n".join(response), - parse_mode="HTML" - ) - except Exception as e: - print(f"Ошибка получения платежей: {e}") + await message.answer( + "История платежей\n\n" + "История платежей хранится в системе оплаты ЮKassa.\n" + "Для проверки статуса подписки используйте команду /stats.\n\n" + "Для оформления новой подписки используйте команду /buy", + parse_mode="HTML" + ) @router.message(Command("testcards")) diff --git a/tg_bot/infrastructure/telegram/handlers/collection_handler.py b/tg_bot/infrastructure/telegram/handlers/collection_handler.py index 8f270ae..eba6f29 100644 --- a/tg_bot/infrastructure/telegram/handlers/collection_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/collection_handler.py @@ -1,18 +1,22 @@ -from aiogram import Router +from aiogram import Router, F from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery -from aiogram.filters import Command +from aiogram.filters import Command, StateFilter +from aiogram.fsm.context import FSMContext import aiohttp +from tg_bot.config.settings import settings +from tg_bot.infrastructure.telegram.states.collection_states import ( + CollectionAccessStates, + CollectionEditStates +) router = Router() -BACKEND_URL = "http://localhost:8001/api/v1" - async def get_user_collections(telegram_id: str): try: async with aiohttp.ClientSession() as session: async with session.get( - f"{BACKEND_URL}/collections/", + f"{settings.BACKEND_URL}/collections/", headers={"X-Telegram-ID": telegram_id} ) as response: if response.status == 200: @@ -25,16 +29,29 @@ async def get_user_collections(telegram_id: str): async def get_collection_documents(collection_id: str, telegram_id: str): try: + collection_id = str(collection_id).strip() + url = f"{settings.BACKEND_URL}/documents/collection/{collection_id}" + print(f"DEBUG get_collection_documents: URL={url}, collection_id={collection_id}, telegram_id={telegram_id}") + async with aiohttp.ClientSession() as session: async with session.get( - f"{BACKEND_URL}/documents/collection/{collection_id}", + url, headers={"X-Telegram-ID": telegram_id} ) as response: if response.status == 200: return await response.json() - return [] + elif response.status == 422: + error_text = await response.text() + print(f"Validation error getting documents: {response.status} - {error_text}, collection_id: {collection_id}, URL: {url}") + return [] + else: + error_text = await response.text() + print(f"Error getting documents: {response.status} - {error_text}, collection_id: {collection_id}, URL: {url}") + return [] except Exception as e: - print(f"Error getting documents: {e}") + print(f"Exception getting documents: {e}, collection_id: {collection_id}, type: {type(collection_id)}") + import traceback + traceback.print_exc() return [] @@ -42,7 +59,7 @@ async def search_in_collection(collection_id: str, query: str, telegram_id: str) try: async with aiohttp.ClientSession() as session: async with session.get( - f"{BACKEND_URL}/documents/collection/{collection_id}", + f"{settings.BACKEND_URL}/documents/collection/{collection_id}", params={"search": query}, headers={"X-Telegram-ID": telegram_id} ) as response: @@ -54,6 +71,91 @@ async def search_in_collection(collection_id: str, query: str, telegram_id: str) return [] +async def get_collection_info(collection_id: str, telegram_id: str): + """Получить информацию о коллекции""" + try: + collection_id = str(collection_id).strip() + url = f"{settings.BACKEND_URL}/collections/{collection_id}" + print(f"DEBUG get_collection_info: URL={url}, collection_id={collection_id}, telegram_id={telegram_id}") + + async with aiohttp.ClientSession() as session: + async with session.get( + url, + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + return await response.json() + elif response.status == 422: + error_text = await response.text() + print(f"Validation error getting collection info: {response.status} - {error_text}, collection_id: {collection_id}, URL: {url}") + return None + else: + error_text = await response.text() + print(f"Error getting collection info: {response.status} - {error_text}, collection_id: {collection_id}, URL: {url}") + return None + except Exception as e: + print(f"Exception getting collection info: {e}, collection_id: {collection_id}, type: {type(collection_id)}") + import traceback + traceback.print_exc() + return None + + +async def get_collection_access_list(collection_id: str, telegram_id: str): + """Получить список пользователей с доступом к коллекции""" + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{settings.BACKEND_URL}/collections/{collection_id}/access", + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + return await response.json() + return [] + except Exception as e: + print(f"Error getting access list: {e}") + return [] + + +async def grant_collection_access(collection_id: str, telegram_id: str, owner_telegram_id: str): + """Предоставить доступ к коллекции""" + try: + url = f"{settings.BACKEND_URL}/collections/{collection_id}/access/telegram/{telegram_id}" + print(f"DEBUG grant_collection_access: URL={url}, target_telegram_id={telegram_id}, owner_telegram_id={owner_telegram_id}") + + async with aiohttp.ClientSession() as session: + async with session.post( + url, + headers={"X-Telegram-ID": owner_telegram_id} + ) as response: + if response.status == 201: + result = await response.json() + print(f"DEBUG: Access granted successfully: {result}") + return result + else: + error_text = await response.text() + print(f"ERROR granting access: status={response.status}, error={error_text}, target_telegram_id={telegram_id}") + return None + except Exception as e: + print(f"Exception granting access: {e}, target_telegram_id={telegram_id}") + import traceback + traceback.print_exc() + return None + + +async def revoke_collection_access(collection_id: str, telegram_id: str, owner_telegram_id: str): + """Отозвать доступ к коллекции""" + try: + async with aiohttp.ClientSession() as session: + async with session.delete( + f"{settings.BACKEND_URL}/collections/{collection_id}/access/telegram/{telegram_id}", + headers={"X-Telegram-ID": owner_telegram_id} + ) as response: + return response.status == 204 + except Exception as e: + print(f"Error revoking access: {e}") + return False + + @router.message(Command("mycollections")) async def cmd_mycollections(message: Message): telegram_id = str(message.from_user.id) @@ -148,36 +250,495 @@ async def cmd_search(message: Message): await message.answer(response, parse_mode="HTML") -@router.callback_query(lambda c: c.data.startswith("collection:")) -async def show_collection_documents(callback: CallbackQuery): - collection_id = callback.data.split(":")[1] +@router.callback_query(lambda c: c.data.startswith("collection:") and not c.data.startswith("collection:documents:") and not c.data.startswith("collection:edit:") and not c.data.startswith("collection:access:") and not c.data.startswith("collection:view_access:")) +async def show_collection_menu(callback: CallbackQuery): + """Показать меню коллекции с опциями в зависимости от прав""" + parts = callback.data.split(":", 1) + if len(parts) < 2: + await callback.message.answer( + "Ошибка\n\nНеверный формат данных.", + parse_mode="HTML" + ) + await callback.answer() + return + + collection_id = parts[1] telegram_id = str(callback.from_user.id) - await callback.answer("Загружаю документы...") + print(f"DEBUG: collection_id from callback (menu): {collection_id}, callback_data: {callback.data}") - documents = await get_collection_documents(collection_id, telegram_id) + await callback.answer("Загружаю информацию...") - if not documents: + collection_info = await get_collection_info(collection_id, telegram_id) + if not collection_info: await callback.message.answer( - f"Коллекция пуста\n\n" - f"В этой коллекции пока нет документов.\n" - f"Обратитесь к администратору для добавления документов.", + "Ошибка\n\nНе удалось загрузить информацию о коллекции.", parse_mode="HTML" ) return + owner_id = collection_info.get("owner_id") + collection_name = collection_info.get("name", "Коллекция") + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{settings.BACKEND_URL}/users/telegram/{telegram_id}" + ) as response: + if response.status == 200: + user_info = await response.json() + current_user_id = user_info.get("user_id") + is_owner = str(owner_id) == str(current_user_id) + else: + is_owner = False + except: + is_owner = False + + keyboard_buttons = [] + + collection_id_str = str(collection_id) + + if is_owner: + keyboard_buttons = [ + [InlineKeyboardButton(text="Просмотр документов", callback_data=f"collection:documents:{collection_id_str}")], + [InlineKeyboardButton(text="Редактировать коллекцию", callback_data=f"collection:edit:{collection_id_str}")], + [InlineKeyboardButton(text="Управление доступом", callback_data=f"collection:access:{collection_id_str}")], + [InlineKeyboardButton(text="Загрузить документ", callback_data=f"document:upload:{collection_id_str}")], + [InlineKeyboardButton(text="Назад к коллекциям", callback_data="collections:list")] + ] + else: + keyboard_buttons = [ + [InlineKeyboardButton(text="Просмотр документов", callback_data=f"collection:documents:{collection_id_str}")], + [InlineKeyboardButton(text="Просмотр доступа", callback_data=f"collection:view_access:{collection_id_str}")], + [InlineKeyboardButton(text="Назад к коллекциям", callback_data="collections:list")] + ] + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + + role_text = "Владелец" if is_owner else "Доступ" + response = f"{collection_name}\n\n" + response += f"{role_text}\n\n" + response += f"ID: {collection_id}\n\n" + response += "Выберите действие:" + + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("collection:documents:")) +async def show_collection_documents(callback: CallbackQuery): + """Показать документы коллекции""" + try: + parts = callback.data.split(":", 2) + if len(parts) < 3: + raise ValueError("Неверный формат callback_data") + + collection_id = parts[2] + telegram_id = str(callback.from_user.id) + + print(f"DEBUG: collection_id from callback: {collection_id}, callback_data: {callback.data}") + + await callback.answer("Загружаю документы...") + + collection_info = await get_collection_info(collection_id, telegram_id) + if not collection_info: + await callback.message.answer( + "Ошибка\n\nНе удалось загрузить информацию о коллекции. Проверьте, что у вас есть доступ к этой коллекции.", + parse_mode="HTML" + ) + return + + documents = await get_collection_documents(collection_id, telegram_id) + + if not documents: + await callback.message.answer( + f"Коллекция пуста\n\n" + f"В этой коллекции пока нет документов.", + parse_mode="HTML" + ) + return + except IndexError: + await callback.message.answer( + "Ошибка\n\nНеверный формат данных.", + parse_mode="HTML" + ) + await callback.answer() + return + except Exception as e: + print(f"Error in show_collection_documents: {e}") + await callback.message.answer( + f"Ошибка\n\nПроизошла ошибка при загрузке документов: {str(e)}", + parse_mode="HTML" + ) + await callback.answer() + return + response = f"Документы в коллекции:\n\n" + keyboard_buttons = [] + for i, doc in enumerate(documents[:10], 1): + doc_id = doc.get("document_id") title = doc.get("title", "Без названия") content_preview = doc.get("content", "")[:100] response += f"{i}. {title}\n" if content_preview: response += f" {content_preview}...\n" response += "\n" + + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"{title[:30]}", + callback_data=f"document:view:{doc_id}" + ) + ]) if len(documents) > 10: response += f"\nПоказано 10 из {len(documents)} документов" - await callback.message.answer(response, parse_mode="HTML") + + collection_id_for_back = str(collection_info.get("collection_id", collection_id)) + keyboard_buttons.append([ + InlineKeyboardButton(text="Назад", callback_data=f"collection:{collection_id_for_back}") + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("collection:access:")) +async def show_access_management(callback: CallbackQuery): + """Показать меню управления доступом (только для владельца)""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await callback.answer("Загружаю список доступа...") + + access_list = await get_collection_access_list(collection_id, telegram_id) + + response = "Управление доступом\n\n" + response += "Пользователи с доступом:\n\n" + + keyboard_buttons = [] + + if access_list: + for i, access in enumerate(access_list[:10], 1): + user = access.get("user", {}) + user_telegram_id = user.get("telegram_id", "N/A") + role = user.get("role", "user") + response += f"{i}. {user_telegram_id} ({role})\n" + + keyboard_buttons.append([ + InlineKeyboardButton( + text=f" Удалить {user_telegram_id}", + callback_data=f"access:remove:{collection_id}:{user_telegram_id}" + ) + ]) + else: + response += "Нет пользователей с доступом\n\n" + + keyboard_buttons.extend([ + [InlineKeyboardButton(text="Добавить доступ", callback_data=f"access:add:{collection_id}")], + [InlineKeyboardButton(text="Назад", callback_data=f"collection:{collection_id}")] + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("collection:view_access:")) +async def show_access_list(callback: CallbackQuery): + """Показать список пользователей с доступом (read-only для пользователей с доступом)""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await callback.answer("Загружаю список доступа...") + + access_list = await get_collection_access_list(collection_id, telegram_id) + + response = "Пользователи с доступом\n\n" + + if access_list: + for i, access in enumerate(access_list[:20], 1): + user = access.get("user", {}) + user_telegram_id = user.get("telegram_id", "N/A") + role = user.get("role", "user") + response += f"{i}. {user_telegram_id} ({role})\n" + else: + response += "Нет пользователей с доступом\n" + + keyboard = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="Назад", callback_data=f"collection:{collection_id}") + ]]) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("access:add:")) +async def add_access_prompt(callback: CallbackQuery, state: FSMContext): + """Запросить пересылку сообщения для добавления доступа""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await state.update_data(collection_id=collection_id) + await state.set_state(CollectionAccessStates.waiting_for_username) + + await callback.message.answer( + "Добавить доступ\n\n" + "Перешлите любое сообщение от пользователя, которому нужно предоставить доступ.\n\n" + "Просто перешлите сообщение от нужного пользователя.", + parse_mode="HTML" + ) + await callback.answer() + + +@router.message(StateFilter(CollectionAccessStates.waiting_for_username)) +async def process_add_access(message: Message, state: FSMContext): + """Обработать добавление доступа через пересылку сообщения""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + collection_id = data.get("collection_id") + + if not collection_id: + await message.answer("Ошибка: не указана коллекция") + await state.clear() + return + + target_telegram_id = None + + if message.forward_from: + target_telegram_id = str(message.forward_from.id) + elif message.forward_from_chat: + await message.answer( + "Ошибка\n\n" + "Пожалуйста, перешлите сообщение от пользователя, а не из группы или канала.", + parse_mode="HTML" + ) + await state.clear() + return + elif message.forward_date: + await message.answer( + "Информация о пересылке скрыта\n\n" + "Пользователь скрыл информацию о пересылке в настройках приватности Telegram.\n\n" + "Попросите пользователя временно разрешить пересылку сообщений.", + parse_mode="HTML" + ) + await state.clear() + return + else: + await message.answer( + "Ошибка\n\n" + "Пожалуйста, перешлите сообщение от пользователя, которому нужно предоставить доступ.\n\n" + "Просто перешлите любое сообщение от нужного пользователя.", + parse_mode="HTML" + ) + await state.clear() + return + + if not target_telegram_id: + await message.answer( + "Ошибка\n\n" + "Не удалось определить Telegram ID пользователя.", + parse_mode="HTML" + ) + await state.clear() + return + + print(f"DEBUG: Attempting to grant access: collection_id={collection_id}, target_telegram_id={target_telegram_id}, owner_telegram_id={telegram_id}") + result = await grant_collection_access(collection_id, target_telegram_id, telegram_id) + + if result: + user_info = "" + if message.forward_from: + user_name = message.forward_from.first_name or "" + user_username = f"@{message.forward_from.username}" if message.forward_from.username else "" + user_info = f"{user_name} {user_username}".strip() or target_telegram_id + else: + user_info = target_telegram_id + + await message.answer( + f"Доступ предоставлен\n\n" + f"Пользователю {target_telegram_id} предоставлен доступ к коллекции.\n\n" + f"Пользователь: {user_info}\n\n" + f"Примечание: Если пользователь еще не взаимодействовал с ботом, он был автоматически создан в системе.", + parse_mode="HTML" + ) + else: + await message.answer( + "Ошибка\n\n" + "Не удалось предоставить доступ. Возможно:\n" + "• Доступ уже предоставлен\n" + "• Произошла ошибка на сервере\n" + "• Вы не являетесь владельцем коллекции\n\n" + "Проверьте логи сервера для получения подробной информации.", + parse_mode="HTML" + ) + + await state.clear() + + +@router.callback_query(lambda c: c.data.startswith("access:remove:")) +async def remove_access(callback: CallbackQuery): + """Удалить доступ пользователя""" + parts = callback.data.split(":") + collection_id = parts[2] + target_telegram_id = parts[3] + owner_telegram_id = str(callback.from_user.id) + + await callback.answer("Удаляю доступ...") + + result = await revoke_collection_access(collection_id, target_telegram_id, owner_telegram_id) + + if result: + await callback.message.answer( + f"Доступ отозван\n\n" + f"Доступ пользователя {target_telegram_id} отозван.", + parse_mode="HTML" + ) + else: + await callback.message.answer( + "Ошибка\n\n" + "Не удалось отозвать доступ.", + parse_mode="HTML" + ) + + +@router.callback_query(lambda c: c.data.startswith("collection:edit:")) +async def edit_collection_prompt(callback: CallbackQuery, state: FSMContext): + """Запросить данные для редактирования коллекции""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + collection_info = await get_collection_info(collection_id, telegram_id) + if not collection_info: + await callback.message.answer( + "Ошибка\n\nНе удалось загрузить информацию о коллекции.", + parse_mode="HTML" + ) + await callback.answer() + return + + await state.update_data(collection_id=collection_id) + await state.set_state(CollectionEditStates.waiting_for_name) + + await callback.message.answer( + "Редактирование коллекции\n\n" + "Отправьте новое название коллекции или /skip чтобы оставить текущее.\n\n" + f"Текущее название: {collection_info.get('name', 'Без названия')}", + parse_mode="HTML" + ) + await callback.answer() + + +@router.message(StateFilter(CollectionEditStates.waiting_for_name)) +async def process_edit_collection_name(message: Message, state: FSMContext): + """Обработать новое название коллекции""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + collection_id = data.get("collection_id") + + if message.text and message.text.strip() == "/skip": + new_name = None + else: + new_name = message.text.strip() if message.text else None + + await state.update_data(name=new_name) + await state.set_state(CollectionEditStates.waiting_for_description) + + collection_info = await get_collection_info(collection_id, telegram_id) + current_description = collection_info.get("description", "") if collection_info else "" + + await message.answer( + "Описание коллекции\n\n" + "Отправьте новое описание коллекции или /skip чтобы оставить текущее.\n\n" + f"Текущее описание: {current_description[:100] if current_description else 'Нет описания'}...", + parse_mode="HTML" + ) + + +@router.message(StateFilter(CollectionEditStates.waiting_for_description)) +async def process_edit_collection_description(message: Message, state: FSMContext): + """Обработать новое описание коллекции""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + collection_id = data.get("collection_id") + name = data.get("name") + + if message.text and message.text.strip() == "/skip": + new_description = None + else: + new_description = message.text.strip() if message.text else None + + try: + update_data = {} + if name: + update_data["name"] = name + if new_description: + update_data["description"] = new_description + + async with aiohttp.ClientSession() as session: + async with session.put( + f"{settings.BACKEND_URL}/collections/{collection_id}", + json=update_data, + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + await message.answer( + "Коллекция обновлена\n\n" + "Изменения сохранены.", + parse_mode="HTML" + ) + else: + error_text = await response.text() + await message.answer( + f"Ошибка\n\n" + f"Не удалось обновить коллекцию: {error_text}", + parse_mode="HTML" + ) + except Exception as e: + await message.answer( + f"Ошибка\n\n" + f"Произошла ошибка: {str(e)}", + parse_mode="HTML" + ) + + await state.clear() + + +@router.callback_query(lambda c: c.data == "collections:list") +async def back_to_collections(callback: CallbackQuery): + """Вернуться к списку коллекций""" + telegram_id = str(callback.from_user.id) + collections = await get_user_collections(telegram_id) + + if not collections: + await callback.message.answer( + "У вас пока нет коллекций\n\n" + "Обратитесь к администратору для создания коллекций и добавления документов.", + parse_mode="HTML" + ) + return + + response = "Ваши коллекции документов:\n\n" + keyboard_buttons = [] + + for i, collection in enumerate(collections[:10], 1): + name = collection.get("name", "Без названия") + description = collection.get("description", "") + collection_id = collection.get("collection_id") + + response += f"{i}. {name}\n" + if description: + response += f" {description[:50]}...\n" + response += f" ID: {collection_id}\n\n" + + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"{name}", + callback_data=f"collection:{collection_id}" + ) + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + response += "Нажмите на коллекцию, чтобы посмотреть документы" + + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) diff --git a/tg_bot/infrastructure/telegram/handlers/document_handler.py b/tg_bot/infrastructure/telegram/handlers/document_handler.py new file mode 100644 index 0000000..4f671a9 --- /dev/null +++ b/tg_bot/infrastructure/telegram/handlers/document_handler.py @@ -0,0 +1,381 @@ +""" +Обработчики для работы с документами +""" +from aiogram import Router, F +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +import aiohttp +from tg_bot.config.settings import settings +from tg_bot.infrastructure.telegram.states.collection_states import ( + DocumentEditStates, + DocumentUploadStates +) + +router = Router() + + +async def get_document_info(document_id: str, telegram_id: str): + """Получить информацию о документе""" + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{settings.BACKEND_URL}/documents/{document_id}", + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + return await response.json() + return None + except Exception as e: + print(f"Error getting document info: {e}") + return None + + +async def delete_document(document_id: str, telegram_id: str): + """Удалить документ""" + try: + async with aiohttp.ClientSession() as session: + async with session.delete( + f"{settings.BACKEND_URL}/documents/{document_id}", + headers={"X-Telegram-ID": telegram_id} + ) as response: + return response.status == 204 + except Exception as e: + print(f"Error deleting document: {e}") + return False + + +async def update_document(document_id: str, telegram_id: str, title: str = None, content: str = None): + """Обновить документ""" + try: + update_data = {} + if title: + update_data["title"] = title + if content: + update_data["content"] = content + + async with aiohttp.ClientSession() as session: + async with session.put( + f"{settings.BACKEND_URL}/documents/{document_id}", + json=update_data, + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + return await response.json() + return None + except Exception as e: + print(f"Error updating document: {e}") + return None + + +async def upload_document_to_collection(collection_id: str, file_data: bytes, filename: str, telegram_id: str): + """Загрузить документ в коллекцию""" + try: + async with aiohttp.ClientSession() as session: + form_data = aiohttp.FormData() + form_data.add_field('file', file_data, filename=filename, content_type='application/octet-stream') + + async with session.post( + f"{settings.BACKEND_URL}/documents/upload?collection_id={collection_id}", + data=form_data, + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 201: + return await response.json() + else: + error_text = await response.text() + print(f"Upload error: {response.status} - {error_text}") + return None + except Exception as e: + print(f"Error uploading document: {e}") + return None + + +@router.callback_query(lambda c: c.data.startswith("document:view:")) +async def view_document(callback: CallbackQuery): + """Просмотр документа""" + document_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await callback.answer("Загружаю документ...") + + document = await get_document_info(document_id, telegram_id) + if not document: + await callback.message.answer( + "Ошибка\n\nНе удалось загрузить документ.", + parse_mode="HTML" + ) + return + + title = document.get("title", "Без названия") + content = document.get("content", "") + collection_id = document.get("collection_id") + + content_preview = content[:2000] if len(content) > 2000 else content + has_more = len(content) > 2000 + + response = f"{title}\n\n" + response += f"{content_preview}" + if has_more: + response += "\n\n..." + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{settings.BACKEND_URL}/collections/{collection_id}", + headers={"X-Telegram-ID": telegram_id} + ) as response_collection: + if response_collection.status == 200: + collection_info = await response_collection.json() + owner_id = collection_info.get("owner_id") + + async with session.get( + f"{settings.BACKEND_URL}/users/telegram/{telegram_id}" + ) as response_user: + if response_user.status == 200: + user_info = await response_user.json() + current_user_id = user_info.get("user_id") + is_owner = str(owner_id) == str(current_user_id) + + keyboard_buttons = [] + if is_owner: + keyboard_buttons = [ + [InlineKeyboardButton(text="Редактировать", callback_data=f"document:edit:{document_id}")], + [InlineKeyboardButton(text="Удалить", callback_data=f"document:delete:{document_id}")], + [InlineKeyboardButton(text="Назад", callback_data=f"collection:documents:{collection_id}")] + ] + else: + keyboard_buttons = [ + [InlineKeyboardButton(text="Редактировать", callback_data=f"document:edit:{document_id}")], + [InlineKeyboardButton(text="Назад", callback_data=f"collection:documents:{collection_id}")] + ] + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + return + except: + pass + + keyboard = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="Назад", callback_data=f"collection:documents:{collection_id}") + ]]) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("document:edit:")) +async def edit_document_prompt(callback: CallbackQuery, state: FSMContext): + """Запросить данные для редактирования документа""" + document_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + document = await get_document_info(document_id, telegram_id) + if not document: + await callback.message.answer( + "Ошибка\n\nНе удалось загрузить документ.", + parse_mode="HTML" + ) + await callback.answer() + return + + await state.update_data(document_id=document_id) + await state.set_state(DocumentEditStates.waiting_for_title) + + await callback.message.answer( + "Редактирование документа\n\n" + "Отправьте новое название документа или /skip чтобы оставить текущее.\n\n" + f"Текущее название: {document.get('title', 'Без названия')}", + parse_mode="HTML" + ) + await callback.answer() + + +@router.message(StateFilter(DocumentEditStates.waiting_for_title)) +async def process_edit_title(message: Message, state: FSMContext): + """Обработать новое название документа""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + document_id = data.get("document_id") + + if message.text and message.text.strip() == "/skip": + new_title = None + else: + new_title = message.text.strip() if message.text else None + + await state.update_data(title=new_title) + await state.set_state(DocumentEditStates.waiting_for_content) + + await message.answer( + "Содержимое документа\n\n" + "Отправьте новое содержимое документа или /skip чтобы оставить текущее.", + parse_mode="HTML" + ) + + +@router.message(StateFilter(DocumentEditStates.waiting_for_content)) +async def process_edit_content(message: Message, state: FSMContext): + """Обработать новое содержимое документа""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + document_id = data.get("document_id") + title = data.get("title") + + if message.text and message.text.strip() == "/skip": + new_content = None + else: + new_content = message.text.strip() if message.text else None + + result = await update_document(document_id, telegram_id, title=title, content=new_content) + + if result: + await message.answer( + "Документ обновлен\n\n" + "Изменения сохранены.", + parse_mode="HTML" + ) + else: + await message.answer( + "Ошибка\n\n" + "Не удалось обновить документ.", + parse_mode="HTML" + ) + + await state.clear() + + +@router.callback_query(lambda c: c.data.startswith("document:delete:")) +async def delete_document_confirm(callback: CallbackQuery): + """Подтверждение удаления документа""" + document_id = callback.data.split(":")[2] + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="Да, удалить", callback_data=f"document:delete_confirm:{document_id}")], + [InlineKeyboardButton(text="Отмена", callback_data=f"document:view:{document_id}")] + ]) + + await callback.message.answer( + "Подтверждение удаления\n\n" + "Вы уверены, что хотите удалить этот документ?", + parse_mode="HTML", + reply_markup=keyboard + ) + await callback.answer() + + +@router.callback_query(lambda c: c.data.startswith("document:delete_confirm:")) +async def delete_document_execute(callback: CallbackQuery): + """Выполнить удаление документа""" + document_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await callback.answer("Удаляю документ...") + + # Получаем информацию о документе для возврата к коллекции + document = await get_document_info(document_id, telegram_id) + collection_id = document.get("collection_id") if document else None + + result = await delete_document(document_id, telegram_id) + + if result: + await callback.message.answer( + "Документ удален", + parse_mode="HTML" + ) + else: + await callback.message.answer( + "Ошибка\n\n" + "Не удалось удалить документ.", + parse_mode="HTML" + ) + + +@router.callback_query(lambda c: c.data.startswith("document:upload:")) +async def upload_document_prompt(callback: CallbackQuery, state: FSMContext): + """Запросить файл для загрузки""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await state.update_data(collection_id=collection_id) + await state.set_state(DocumentUploadStates.waiting_for_file) + + await callback.message.answer( + "Загрузка документа\n\n" + "Отправьте файл (PDF, PNG, JPG, JPEG, TIFF, BMP).\n\n" + "Поддерживаемые форматы:\n" + "• PDF\n" + "• Изображения: PNG, JPG, JPEG, TIFF, BMP", + parse_mode="HTML" + ) + await callback.answer() + + +@router.message(StateFilter(DocumentUploadStates.waiting_for_file), F.document | F.photo) +async def process_upload_document(message: Message, state: FSMContext): + """Обработать загрузку документа""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + collection_id = data.get("collection_id") + + if not collection_id: + await message.answer("Ошибка: не указана коллекция") + await state.clear() + return + + file_id = None + filename = None + + if message.document: + file_id = message.document.file_id + filename = message.document.file_name or "document.pdf" + + supported_extensions = ['.pdf', '.png', '.jpg', '.jpeg', '.tiff', '.bmp'] + file_ext = filename.lower().rsplit('.', 1)[-1] if '.' in filename else '' + if f'.{file_ext}' not in supported_extensions: + await message.answer( + "Ошибка\n\n" + f"Неподдерживаемый формат файла: {file_ext}\n\n" + "Поддерживаются: PDF, PNG, JPG, JPEG, TIFF, BMP", + parse_mode="HTML" + ) + await state.clear() + return + elif message.photo: + file_id = message.photo[-1].file_id + filename = "photo.jpg" + else: + await message.answer( + "Ошибка\n\n" + "Пожалуйста, отправьте файл (PDF или изображение).", + parse_mode="HTML" + ) + await state.clear() + return + + try: + file = await message.bot.get_file(file_id) + file_data = await message.bot.download_file(file.file_path) + file_bytes = file_data.read() + + result = await upload_document_to_collection(collection_id, file_bytes, filename, telegram_id) + + if result: + await message.answer( + f"Документ загружен\n\n" + f"Название: {result.get('title', filename)}", + parse_mode="HTML" + ) + else: + await message.answer( + "Ошибка\n\n" + "Не удалось загрузить документ.", + parse_mode="HTML" + ) + except Exception as e: + print(f"Error uploading document: {e}") + await message.answer( + "Ошибка\n\n" + f"Произошла ошибка при загрузке: {str(e)}", + parse_mode="HTML" + ) + + await state.clear() + diff --git a/tg_bot/infrastructure/telegram/handlers/question_handler.py b/tg_bot/infrastructure/telegram/handlers/question_handler.py index 540aac7..be85726 100644 --- a/tg_bot/infrastructure/telegram/handlers/question_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/question_handler.py @@ -3,14 +3,12 @@ from aiogram.types import Message from datetime import datetime import aiohttp from tg_bot.config.settings import settings -from tg_bot.infrastructure.database.database import AsyncSessionLocal -from tg_bot.infrastructure.database.models import UserModel -from tg_bot.domain.services.user_service import UserService +from tg_bot.domain.services.user_service import UserService, User from tg_bot.application.services.rag_service import RAGService router = Router() -BACKEND_URL = "http://localhost:8001/api/v1" rag_service = RAGService() +user_service = UserService() @router.message() async def handle_question(message: Message): @@ -19,58 +17,37 @@ async def handle_question(message: Message): if question_text.startswith('/'): return - async with AsyncSessionLocal() as session: - try: - user_service = UserService(session) - user = await user_service.get_user_by_telegram_id(user_id) + try: + user = await user_service.get_user_by_telegram_id(user_id) - if not user: - user = await user_service.get_or_create_user( - user_id, - message.from_user.username or "", - message.from_user.first_name or "", - message.from_user.last_name or "" - ) - await ensure_user_in_backend(str(user_id), message.from_user) - - if user.is_premium: - await process_premium_question(message, user, question_text, user_service) - - elif user.questions_used < settings.FREE_QUESTIONS_LIMIT: - await process_free_question(message, user, question_text, user_service) - - else: - await handle_limit_exceeded(message, user) - - except Exception as e: - print(f"Error processing question: {e}") - await message.answer( - "Произошла ошибка. Попробуйте позже.", - parse_mode="HTML" + if not user: + user = await user_service.get_or_create_user( + user_id, + message.from_user.username or "", + message.from_user.first_name or "", + message.from_user.last_name or "" ) + if user.is_premium: + await process_premium_question(message, user, question_text) + + elif user.questions_used < settings.FREE_QUESTIONS_LIMIT: + await process_free_question(message, user, question_text) + + else: + await handle_limit_exceeded(message, user) -async def ensure_user_in_backend(telegram_id: str, telegram_user): - try: - async with aiohttp.ClientSession() as session: - async with session.get( - f"{BACKEND_URL}/users/telegram/{telegram_id}" - ) as response: - if response.status == 200: - return - - async with session.post( - f"{BACKEND_URL}/users", - json={"telegram_id": telegram_id, "role": "user"} - ) as create_response: - if create_response.status in [200, 201]: - print(f"Пользователь {telegram_id} создан в backend") except Exception as e: - print(f"Error creating user in backend: {e}") + print(f"Error processing question: {e}") + await message.answer( + "Произошла ошибка. Попробуйте позже.", + parse_mode="HTML" + ) -async def process_premium_question(message: Message, user: UserModel, question_text: str, user_service: UserService): - await user_service.update_user_questions(user.telegram_id) +async def process_premium_question(message: Message, user: User, question_text: str): + await user_service.update_user_questions(int(user.telegram_id)) + user = await user_service.get_user_by_telegram_id(int(user.telegram_id)) await message.bot.send_chat_action(message.chat.id, "typing") @@ -129,9 +106,9 @@ async def process_premium_question(message: Message, user: UserModel, question_t await message.answer(response, parse_mode="HTML") -async def process_free_question(message: Message, user: UserModel, question_text: str, user_service: UserService): - await user_service.update_user_questions(user.telegram_id) - user = await user_service.get_user_by_telegram_id(user.telegram_id) +async def process_free_question(message: Message, user: User, question_text: str): + await user_service.update_user_questions(int(user.telegram_id)) + user = await user_service.get_user_by_telegram_id(int(user.telegram_id)) remaining = settings.FREE_QUESTIONS_LIMIT - user.questions_used await message.bot.send_chat_action(message.chat.id, "typing") @@ -201,9 +178,11 @@ async def process_free_question(message: Message, user: UserModel, question_text async def save_conversation_to_backend(telegram_id: str, question: str, answer: str, sources: list): try: + from tg_bot.config.settings import settings + backend_url = settings.BACKEND_URL async with aiohttp.ClientSession() as session: async with session.get( - f"{BACKEND_URL}/users/telegram/{telegram_id}" + f"{backend_url}/users/telegram/{telegram_id}" ) as user_response: if user_response.status != 200: return @@ -211,7 +190,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer: user_uuid = user_data.get("user_id") async with session.get( - f"{BACKEND_URL}/collections/", + f"{backend_url}/collections/", headers={"X-Telegram-ID": telegram_id} ) as collections_response: collections = [] @@ -223,7 +202,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer: collection_id = collections[0].get("collection_id") else: async with session.post( - f"{BACKEND_URL}/collections", + f"{backend_url}/collections", json={ "name": "Основная коллекция", "description": "Коллекция по умолчанию", @@ -239,7 +218,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer: return async with session.post( - f"{BACKEND_URL}/conversations", + f"{backend_url}/conversations", json={"collection_id": str(collection_id)}, headers={"X-Telegram-ID": telegram_id} ) as conversation_response: @@ -252,7 +231,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer: return await session.post( - f"{BACKEND_URL}/messages", + f"{backend_url}/messages", json={ "conversation_id": str(conversation_id), "content": question, @@ -262,7 +241,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer: ) await session.post( - f"{BACKEND_URL}/messages", + f"{backend_url}/messages", json={ "conversation_id": str(conversation_id), "content": answer, @@ -276,7 +255,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer: print(f"Error saving conversation: {e}") -async def handle_limit_exceeded(message: Message, user: UserModel): +async def handle_limit_exceeded(message: Message, user: User): response = ( f"Лимит бесплатных вопросов исчерпан!\n\n" diff --git a/tg_bot/infrastructure/telegram/handlers/start_handler.py b/tg_bot/infrastructure/telegram/handlers/start_handler.py index 8bc3a36..6f5af3b 100644 --- a/tg_bot/infrastructure/telegram/handlers/start_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/start_handler.py @@ -4,10 +4,10 @@ from aiogram.types import Message from datetime import datetime from tg_bot.config.settings import settings -from tg_bot.infrastructure.database.database import AsyncSessionLocal from tg_bot.domain.services.user_service import UserService router = Router() +user_service = UserService() @router.message(Command("start")) async def cmd_start(message: Message): @@ -16,22 +16,19 @@ async def cmd_start(message: Message): username = message.from_user.username or "" first_name = message.from_user.first_name or "" last_name = message.from_user.last_name or "" - async with AsyncSessionLocal() as session: - try: - user_service = UserService(session) - existing_user = await user_service.get_user_by_telegram_id(user_id) - user = await user_service.get_or_create_user( - user_id, - username, - first_name, - last_name - ) - if not existing_user: - print(f"Новый пользователь: {user_id}") - - except Exception as e: - print(f"Ошибка сохранения пользователя: {e}") - await session.rollback() + try: + existing_user = await user_service.get_user_by_telegram_id(user_id) + user = await user_service.get_or_create_user( + user_id, + username, + first_name, + last_name + ) + if not existing_user: + print(f"Новый пользователь: {user_id}") + + except Exception as e: + print(f"Ошибка сохранения пользователя: {e}") welcome_text = ( f"Привет, {first_name}!\n\n" f"Я VibeLawyerBot - ваш помощник в юридических вопросах.\n\n" diff --git a/tg_bot/infrastructure/telegram/handlers/stats_handler.py b/tg_bot/infrastructure/telegram/handlers/stats_handler.py index 58adfdc..aaa8ee5 100644 --- a/tg_bot/infrastructure/telegram/handlers/stats_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/stats_handler.py @@ -4,58 +4,56 @@ from aiogram.filters import Command from aiogram.types import Message from tg_bot.config.settings import settings -from tg_bot.infrastructure.database.database import AsyncSessionLocal from tg_bot.domain.services.user_service import UserService router = Router() +user_service = UserService() @router.message(Command("stats")) async def cmd_stats(message: Message): user_id = message.from_user.id - async with AsyncSessionLocal() as session: - try: - user_service = UserService(session) - user = await user_service.get_user_by_telegram_id(user_id) + try: + user = await user_service.get_user_by_telegram_id(user_id) - if user: - stats_text = ( - f"Ваша статистика\n\n" - f"Основное:\n" - f"• ID: {user_id}\n" - f"• Premium: {'Да' if user.is_premium else 'Нет'}\n" - f"• Вопросов использовано: {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n\n" - ) - - if user.is_premium: - stats_text += ( - f"Premium статус:\n" - f"• Активен до: {user.premium_until.strftime('%d.%m.%Y') if user.premium_until else 'Не указано'}\n" - f"• Лимит вопросов: безлимитно\n\n" - ) - else: - remaining = max(0, settings.FREE_QUESTIONS_LIMIT - user.questions_used) - stats_text += ( - f"Бесплатный доступ:\n" - f"• Осталось вопросов: {remaining}\n" - f"• Для безлимита: /buy\n\n" - ) - else: - stats_text = ( - f"Добро пожаловать!\n\n" - f"Вы новый пользователь.\n" - f"• Ваш ID: {user_id}\n" - f"• Бесплатных вопросов: {settings.FREE_QUESTIONS_LIMIT}\n" - f"• Для начала работы просто задайте вопрос!\n\n" - f"Используйте /buy для получения полного доступа" - ) - - await message.answer(stats_text, parse_mode="HTML") - - except Exception as e: - await message.answer( - f"Ошибка получения статистики\n\n" - f"Попробуйте позже.", - parse_mode="HTML" + if user: + stats_text = ( + f"Ваша статистика\n\n" + f"Основное:\n" + f"• ID: {user_id}\n" + f"• Premium: {'Да' if user.is_premium else 'Нет'}\n" + f"• Вопросов использовано: {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n\n" ) + + if user.is_premium: + stats_text += ( + f"Premium статус:\n" + f"• Активен до: {user.premium_until.strftime('%d.%m.%Y') if user.premium_until else 'Не указано'}\n" + f"• Лимит вопросов: безлимитно\n\n" + ) + else: + remaining = max(0, settings.FREE_QUESTIONS_LIMIT - user.questions_used) + stats_text += ( + f"Бесплатный доступ:\n" + f"• Осталось вопросов: {remaining}\n" + f"• Для безлимита: /buy\n\n" + ) + else: + stats_text = ( + f"Добро пожаловать!\n\n" + f"Вы новый пользователь.\n" + f"• Ваш ID: {user_id}\n" + f"• Бесплатных вопросов: {settings.FREE_QUESTIONS_LIMIT}\n" + f"• Для начала работы просто задайте вопрос!\n\n" + f"Используйте /buy для получения полного доступа" + ) + + await message.answer(stats_text, parse_mode="HTML") + + except Exception as e: + await message.answer( + f"Ошибка получения статистики\n\n" + f"Попробуйте позже.", + parse_mode="HTML" + ) diff --git a/tg_bot/infrastructure/telegram/states/collection_states.py b/tg_bot/infrastructure/telegram/states/collection_states.py new file mode 100644 index 0000000..f7f0939 --- /dev/null +++ b/tg_bot/infrastructure/telegram/states/collection_states.py @@ -0,0 +1,27 @@ +""" +FSM состояния для работы с коллекциями +""" +from aiogram.fsm.state import State, StatesGroup + + +class CollectionAccessStates(StatesGroup): + """Состояния для управления доступом к коллекции""" + waiting_for_username = State() + + +class CollectionEditStates(StatesGroup): + """Состояния для редактирования коллекции""" + waiting_for_name = State() + waiting_for_description = State() + + +class DocumentEditStates(StatesGroup): + """Состояния для редактирования документа""" + waiting_for_title = State() + waiting_for_content = State() + + +class DocumentUploadStates(StatesGroup): + """Состояния для загрузки документа""" + waiting_for_file = State() + diff --git a/tg_bot/payment/webhooks/handler.py b/tg_bot/payment/webhooks/handler.py index c0cb1eb..4bbcb7f 100644 --- a/tg_bot/payment/webhooks/handler.py +++ b/tg_bot/payment/webhooks/handler.py @@ -19,9 +19,6 @@ async def handle_yookassa_webhook(request: Request): try: from tg_bot.config.settings import settings from tg_bot.domain.services.user_service import UserService - from tg_bot.infrastructure.database.database import AsyncSessionLocal - from tg_bot.infrastructure.database.models import UserModel - from sqlalchemy import select from aiogram import Bot if event_type == "payment.succeeded": @@ -29,38 +26,34 @@ async def handle_yookassa_webhook(request: Request): user_id = payment.get("metadata", {}).get("user_id") if user_id: - async with AsyncSessionLocal() as session: - user_service = UserService(session) - success = await user_service.activate_premium(int(user_id)) - if success: - print(f"Premium activated for user {user_id}") - - result = await session.execute( - select(UserModel).filter_by(telegram_id=str(user_id)) - ) - user = result.scalar_one_or_none() - - if user and settings.TELEGRAM_BOT_TOKEN: - try: - bot = Bot(token=settings.TELEGRAM_BOT_TOKEN) - premium_until = user.premium_until or datetime.now() + timedelta(days=30) - - notification = ( - f"Оплата подтверждена!\n\n" - f"Premium активирован до {premium_until.strftime('%d.%m.%Y')}" - ) - - await bot.send_message( - chat_id=int(user_id), - text=notification, - parse_mode="HTML" - ) - print(f"Notification sent to user {user_id}") - await bot.session.close() - except Exception as e: - print(f"Error sending notification: {e}") - else: - print(f"User {user_id} not found") + user_service = UserService() + success = await user_service.activate_premium(int(user_id)) + if success: + print(f"Premium activated for user {user_id}") + + user = await user_service.get_user_by_telegram_id(int(user_id)) + + if user and settings.TELEGRAM_BOT_TOKEN: + try: + bot = Bot(token=settings.TELEGRAM_BOT_TOKEN) + premium_until = user.premium_until or datetime.now() + timedelta(days=30) + + notification = ( + f"Оплата подтверждена!\n\n" + f"Premium активирован до {premium_until.strftime('%d.%m.%Y')}" + ) + + await bot.send_message( + chat_id=int(user_id), + text=notification, + parse_mode="HTML" + ) + print(f"Notification sent to user {user_id}") + await bot.session.close() + except Exception as e: + print(f"Error sending notification: {e}") + else: + print(f"User {user_id} not found or failed to activate premium") except ImportError as e: print(f"Import error: {e}") diff --git a/tg_bot/requirements.txt b/tg_bot/requirements.txt index 8a95e17..3cfc47f 100644 --- a/tg_bot/requirements.txt +++ b/tg_bot/requirements.txt @@ -2,8 +2,6 @@ pydantic>=2.5.0 pydantic-settings>=2.1.0 python-dotenv>=1.0.0 aiogram>=3.10.0 -sqlalchemy>=2.0.0 -aiosqlite>=0.19.0 httpx>=0.25.2 yookassa>=2.4.0 aiohttp>=3.9.1 diff --git a/tg_bot/run.py b/tg_bot/run.py new file mode 100644 index 0000000..d62b7ef --- /dev/null +++ b/tg_bot/run.py @@ -0,0 +1,20 @@ + +""" +Скрипт для запуска Telegram бота без Docker +""" +import sys +import os +from pathlib import Path + +tg_bot_dir = Path(__file__).parent +sys.path.insert(0, str(tg_bot_dir)) + +if __name__ == "__main__": + + from main import main + import asyncio + + asyncio.run(main()) + + +