Merge pull request 'andrewbokh' (#6) from andrewbokh into main
Reviewed-on: #6
This commit is contained in:
commit
49c3d1b0fd
28
backend/alembic/versions/002_add_premium_fields.py
Normal file
28
backend/alembic/versions/002_add_premium_fields.py
Normal file
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
23
backend/run.py
Normal file
23
backend/run.py
Normal file
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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}:*"
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
"""проверка, является ли пользователь администратором"""
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
2
tg_bot/infrastructure/__init__.py
Normal file
2
tg_bot/infrastructure/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Infrastructure layer for the Telegram bot"""
|
||||
|
||||
@ -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}")
|
||||
@ -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"<Payment(user_id={self.user_id}, amount={self.amount}, status={self.status})>"
|
||||
88
tg_bot/infrastructure/http_client.py
Normal file
88
tg_bot/infrastructure/http_client.py
Normal file
@ -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"
|
||||
}
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"<b>У вас уже есть активная подписка!</b>\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"<b>У вас уже есть активная подписка!</b>\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(
|
||||
"<b>Оплата подтверждена!</b>\n\n"
|
||||
f"Ваш premium-доступ активирован до: "
|
||||
f"<b>{user.premium_until.strftime('%d.%m.%Y')}</b>\n\n"
|
||||
f"<b>{user.premium_until.strftime('%d.%m.%Y') if user.premium_until else 'Не указано'}</b>\n\n"
|
||||
"Теперь вы можете:\n"
|
||||
"• Задавать неограниченное количество вопросов\n"
|
||||
"• Получать приоритетные ответы\n"
|
||||
@ -169,12 +139,23 @@ async def check_payment_status(callback_query: types.CallbackQuery):
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer(
|
||||
"<b>Платёж найден в ЮKассе, но не в нашей БД</b>\n\n"
|
||||
"<b>Оплата подтверждена, но не удалось активировать premium</b>\n\n"
|
||||
"Пожалуйста, обратитесь к администратору.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Ошибка обработки платежа: {e}")
|
||||
else:
|
||||
await callback_query.message.answer(
|
||||
"<b>Оплата подтверждена, но не удалось активировать premium</b>\n\n"
|
||||
"Пожалуйста, обратитесь к администратору.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Ошибка обработки платежа: {e}")
|
||||
await callback_query.message.answer(
|
||||
"<b>Ошибка активации premium</b>\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(
|
||||
"<b>У вас пока нет платежей</b>\n\n"
|
||||
"Используйте команду /buy чтобы оформить подписку.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
response = ["<b>Ваши последние платежи:</b>\n"]
|
||||
|
||||
for i, payment in enumerate(payments, 1):
|
||||
status_text = "Успешно" if payment.status == "succeeded" else "Ожидание" if payment.status == "pending" else "Ошибка"
|
||||
response.append(
|
||||
f"\n<b>{i}. {payment.amount} руб. ({status_text})</b>\n"
|
||||
f"Статус: {payment.status}\n"
|
||||
f"Дата: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
f"ID: <code>{payment.payment_id[:8]}...</code>"
|
||||
)
|
||||
|
||||
response.append("\n\n<i>Полный доступ открывается после успешной оплаты</i>")
|
||||
|
||||
await message.answer(
|
||||
"\n".join(response),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения платежей: {e}")
|
||||
await message.answer(
|
||||
"<b>История платежей</b>\n\n"
|
||||
"История платежей хранится в системе оплаты ЮKassa.\n"
|
||||
"Для проверки статуса подписки используйте команду /stats.\n\n"
|
||||
"Для оформления новой подписки используйте команду /buy",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("testcards"))
|
||||
|
||||
@ -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(
|
||||
"<b>Ошибка</b>\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"<b>Коллекция пуста</b>\n\n"
|
||||
f"В этой коллекции пока нет документов.\n"
|
||||
f"Обратитесь к администратору для добавления документов.",
|
||||
"<b>Ошибка</b>\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 = "<b>Владелец</b>" if is_owner else "<b>Доступ</b>"
|
||||
response = f"<b>{collection_name}</b>\n\n"
|
||||
response += f"{role_text}\n\n"
|
||||
response += f"ID: <code>{collection_id}</code>\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(
|
||||
"<b>Ошибка</b>\n\nНе удалось загрузить информацию о коллекции. Проверьте, что у вас есть доступ к этой коллекции.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
documents = await get_collection_documents(collection_id, telegram_id)
|
||||
|
||||
if not documents:
|
||||
await callback.message.answer(
|
||||
f"<b>Коллекция пуста</b>\n\n"
|
||||
f"В этой коллекции пока нет документов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
except IndexError:
|
||||
await callback.message.answer(
|
||||
"<b>Ошибка</b>\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"<b>Ошибка</b>\n\nПроизошла ошибка при загрузке документов: {str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
response = f"<b>Документы в коллекции:</b>\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}. <b>{title}</b>\n"
|
||||
if content_preview:
|
||||
response += f" <i>{content_preview}...</i>\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<i>Показано 10 из {len(documents)} документов</i>"
|
||||
|
||||
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 = "<b>Управление доступом</b>\n\n"
|
||||
response += "<b>Пользователи с доступом:</b>\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}. <code>{user_telegram_id}</code> ({role})\n"
|
||||
|
||||
keyboard_buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f" Удалить {user_telegram_id}",
|
||||
callback_data=f"access:remove:{collection_id}:{user_telegram_id}"
|
||||
)
|
||||
])
|
||||
else:
|
||||
response += "<i>Нет пользователей с доступом</i>\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 = "<b>Пользователи с доступом</b>\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}. <code>{user_telegram_id}</code> ({role})\n"
|
||||
else:
|
||||
response += "<i>Нет пользователей с доступом</i>\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(
|
||||
"<b>Добавить доступ</b>\n\n"
|
||||
"Перешлите любое сообщение от пользователя, которому нужно предоставить доступ.\n\n"
|
||||
"<i>Просто перешлите сообщение от нужного пользователя.</i>",
|
||||
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(
|
||||
"<b>Ошибка</b>\n\n"
|
||||
"Пожалуйста, перешлите сообщение от пользователя, а не из группы или канала.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
elif message.forward_date:
|
||||
await message.answer(
|
||||
"<b>Информация о пересылке скрыта</b>\n\n"
|
||||
"Пользователь скрыл информацию о пересылке в настройках приватности Telegram.\n\n"
|
||||
"Попросите пользователя временно разрешить пересылку сообщений.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
else:
|
||||
await message.answer(
|
||||
"<b>Ошибка</b>\n\n"
|
||||
"Пожалуйста, перешлите сообщение от пользователя, которому нужно предоставить доступ.\n\n"
|
||||
"<i>Просто перешлите любое сообщение от нужного пользователя.</i>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not target_telegram_id:
|
||||
await message.answer(
|
||||
"<b>Ошибка</b>\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"<b>Доступ предоставлен</b>\n\n"
|
||||
f"Пользователю <code>{target_telegram_id}</code> предоставлен доступ к коллекции.\n\n"
|
||||
f"Пользователь: {user_info}\n\n"
|
||||
f"<i>Примечание: Если пользователь еще не взаимодействовал с ботом, он был автоматически создан в системе.</i>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
"<b>Ошибка</b>\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"<b>Доступ отозван</b>\n\n"
|
||||
f"Доступ пользователя <code>{target_telegram_id}</code> отозван.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.answer(
|
||||
"<b>Ошибка</b>\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(
|
||||
"<b>Ошибка</b>\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(
|
||||
"<b>Редактирование коллекции</b>\n\n"
|
||||
"Отправьте новое название коллекции или /skip чтобы оставить текущее.\n\n"
|
||||
f"Текущее название: <b>{collection_info.get('name', 'Без названия')}</b>",
|
||||
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(
|
||||
"<b>Описание коллекции</b>\n\n"
|
||||
"Отправьте новое описание коллекции или /skip чтобы оставить текущее.\n\n"
|
||||
f"Текущее описание: <i>{current_description[:100] if current_description else 'Нет описания'}...</i>",
|
||||
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(
|
||||
"<b>Коллекция обновлена</b>\n\n"
|
||||
"Изменения сохранены.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
error_text = await response.text()
|
||||
await message.answer(
|
||||
f"<b>Ошибка</b>\n\n"
|
||||
f"Не удалось обновить коллекцию: {error_text}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
await message.answer(
|
||||
f"<b>Ошибка</b>\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(
|
||||
"<b>У вас пока нет коллекций</b>\n\n"
|
||||
"Обратитесь к администратору для создания коллекций и добавления документов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
response = "<b>Ваши коллекции документов:</b>\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}. <b>{name}</b>\n"
|
||||
if description:
|
||||
response += f" <i>{description[:50]}...</i>\n"
|
||||
response += f" ID: <code>{collection_id}</code>\n\n"
|
||||
|
||||
keyboard_buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{name}",
|
||||
callback_data=f"collection:{collection_id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
response += "<i>Нажмите на коллекцию, чтобы посмотреть документы</i>"
|
||||
|
||||
await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
|
||||
381
tg_bot/infrastructure/telegram/handlers/document_handler.py
Normal file
381
tg_bot/infrastructure/telegram/handlers/document_handler.py
Normal file
@ -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(
|
||||
"<b>Ошибка</b>\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"<b>{title}</b>\n\n"
|
||||
response += f"<i>{content_preview}</i>"
|
||||
if has_more:
|
||||
response += "\n\n<i>...</i>"
|
||||
|
||||
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(
|
||||
"<b>Ошибка</b>\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(
|
||||
"<b>Редактирование документа</b>\n\n"
|
||||
"Отправьте новое название документа или /skip чтобы оставить текущее.\n\n"
|
||||
f"Текущее название: <b>{document.get('title', 'Без названия')}</b>",
|
||||
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(
|
||||
"<b>Содержимое документа</b>\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(
|
||||
"<b>Документ обновлен</b>\n\n"
|
||||
"Изменения сохранены.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
"<b>Ошибка</b>\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(
|
||||
"<b>Подтверждение удаления</b>\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(
|
||||
"<b>Документ удален</b>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.answer(
|
||||
"<b>Ошибка</b>\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(
|
||||
"<b>Загрузка документа</b>\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(
|
||||
"<b>Ошибка</b>\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(
|
||||
"<b>Ошибка</b>\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"<b>Документ загружен</b>\n\n"
|
||||
f"Название: <b>{result.get('title', filename)}</b>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
"<b>Ошибка</b>\n\n"
|
||||
"Не удалось загрузить документ.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error uploading document: {e}")
|
||||
await message.answer(
|
||||
"<b>Ошибка</b>\n\n"
|
||||
f"Произошла ошибка при загрузке: {str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
@ -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"<b>Лимит бесплатных вопросов исчерпан!</b>\n\n"
|
||||
|
||||
|
||||
@ -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"<b>Привет, {first_name}!</b>\n\n"
|
||||
f"Я <b>VibeLawyerBot</b> - ваш помощник в юридических вопросах.\n\n"
|
||||
|
||||
@ -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"<b>Ваша статистика</b>\n\n"
|
||||
f"<b>Основное:</b>\n"
|
||||
f"• ID: <code>{user_id}</code>\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"<b>Premium статус:</b>\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"<b>Бесплатный доступ:</b>\n"
|
||||
f"• Осталось вопросов: {remaining}\n"
|
||||
f"• Для безлимита: /buy\n\n"
|
||||
)
|
||||
else:
|
||||
stats_text = (
|
||||
f"<b>Добро пожаловать!</b>\n\n"
|
||||
f"Вы новый пользователь.\n"
|
||||
f"• Ваш ID: <code>{user_id}</code>\n"
|
||||
f"• Бесплатных вопросов: {settings.FREE_QUESTIONS_LIMIT}\n"
|
||||
f"• Для начала работы просто задайте вопрос!\n\n"
|
||||
f"<i>Используйте /buy для получения полного доступа</i>"
|
||||
)
|
||||
|
||||
await message.answer(stats_text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(
|
||||
f"<b>Ошибка получения статистики</b>\n\n"
|
||||
f"Попробуйте позже.",
|
||||
parse_mode="HTML"
|
||||
if user:
|
||||
stats_text = (
|
||||
f"<b>Ваша статистика</b>\n\n"
|
||||
f"<b>Основное:</b>\n"
|
||||
f"• ID: <code>{user_id}</code>\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"<b>Premium статус:</b>\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"<b>Бесплатный доступ:</b>\n"
|
||||
f"• Осталось вопросов: {remaining}\n"
|
||||
f"• Для безлимита: /buy\n\n"
|
||||
)
|
||||
else:
|
||||
stats_text = (
|
||||
f"<b>Добро пожаловать!</b>\n\n"
|
||||
f"Вы новый пользователь.\n"
|
||||
f"• Ваш ID: <code>{user_id}</code>\n"
|
||||
f"• Бесплатных вопросов: {settings.FREE_QUESTIONS_LIMIT}\n"
|
||||
f"• Для начала работы просто задайте вопрос!\n\n"
|
||||
f"<i>Используйте /buy для получения полного доступа</i>"
|
||||
)
|
||||
|
||||
await message.answer(stats_text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(
|
||||
f"<b>Ошибка получения статистики</b>\n\n"
|
||||
f"Попробуйте позже.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
27
tg_bot/infrastructure/telegram/states/collection_states.py
Normal file
27
tg_bot/infrastructure/telegram/states/collection_states.py
Normal file
@ -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()
|
||||
|
||||
@ -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"<b>Оплата подтверждена!</b>\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"<b>Оплата подтверждена!</b>\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}")
|
||||
|
||||
@ -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
|
||||
|
||||
20
tg_bot/run.py
Normal file
20
tg_bot/run.py
Normal file
@ -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())
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user