diff --git a/backend/src/application/use_cases/collection_use_cases.py b/backend/src/application/use_cases/collection_use_cases.py index 6e1b9f8..2c0f3c7 100644 --- a/backend/src/application/use_cases/collection_use_cases.py +++ b/backend/src/application/use_cases/collection_use_cases.py @@ -138,4 +138,68 @@ class CollectionUseCases: all_collections = {c.collection_id: c for c in owned + public + accessed_collections} return list(all_collections.values())[skip:skip+limit] + + async def list_collection_access(self, collection_id: UUID, user_id: UUID) -> list[CollectionAccess]: + """Получить список доступа к коллекции""" + collection = await self.get_collection(collection_id) + + has_access = await self.check_access(collection_id, user_id) + if not has_access: + raise ForbiddenError("У вас нет доступа к этой коллекции") + + return await self.access_repository.list_by_collection(collection_id) + + async def grant_access_by_telegram_id( + self, + collection_id: UUID, + telegram_id: str, + owner_id: UUID + ) -> CollectionAccess: + """Предоставить доступ пользователю к коллекции по Telegram ID""" + collection = await self.get_collection(collection_id) + + if collection.owner_id != owner_id: + raise ForbiddenError("Только владелец может предоставлять доступ") + + user = await self.user_repository.get_by_telegram_id(telegram_id) + if not user: + from src.domain.entities.user import User, UserRole + import logging + logger = logging.getLogger(__name__) + logger.info(f"Creating new user with telegram_id: {telegram_id}") + user = User(telegram_id=telegram_id, role=UserRole.USER) + try: + user = await self.user_repository.create(user) + logger.info(f"User created successfully: user_id={user.user_id}, telegram_id={user.telegram_id}") + except Exception as e: + logger.error(f"Error creating user: {e}") + raise + + if user.user_id == owner_id: + raise ForbiddenError("Владелец уже имеет доступ к коллекции") + + existing_access = await self.access_repository.get_by_user_and_collection(user.user_id, collection_id) + if existing_access: + return existing_access + + access = CollectionAccess(user_id=user.user_id, collection_id=collection_id) + return await self.access_repository.create(access) + + async def revoke_access_by_telegram_id( + self, + collection_id: UUID, + telegram_id: str, + owner_id: UUID + ) -> bool: + """Отозвать доступ пользователя к коллекции по Telegram ID""" + collection = await self.get_collection(collection_id) + + if collection.owner_id != owner_id: + raise ForbiddenError("Только владелец может отзывать доступ") + + user = await self.user_repository.get_by_telegram_id(telegram_id) + if not user: + raise NotFoundError(f"Пользователь с telegram_id {telegram_id} не найден") + + return await self.access_repository.delete_by_user_and_collection(user.user_id, collection_id) diff --git a/backend/src/application/use_cases/document_use_cases.py b/backend/src/application/use_cases/document_use_cases.py index f73b531..3ed254b 100644 --- a/backend/src/application/use_cases/document_use_cases.py +++ b/backend/src/application/use_cases/document_use_cases.py @@ -6,6 +6,7 @@ from typing import BinaryIO, Optional from src.domain.entities.document import Document from src.domain.repositories.document_repository import IDocumentRepository from src.domain.repositories.collection_repository import ICollectionRepository +from src.domain.repositories.collection_access_repository import ICollectionAccessRepository from src.application.services.document_parser_service import DocumentParserService from src.shared.exceptions import NotFoundError, ForbiddenError @@ -17,12 +18,25 @@ class DocumentUseCases: self, document_repository: IDocumentRepository, collection_repository: ICollectionRepository, + access_repository: ICollectionAccessRepository, parser_service: DocumentParserService ): self.document_repository = document_repository self.collection_repository = collection_repository + self.access_repository = access_repository self.parser_service = parser_service + async def _check_collection_access(self, user_id: UUID, collection) -> bool: + """Проверить доступ пользователя к коллекции""" + if collection.owner_id == user_id: + return True + + if collection.is_public: + return True + + access = await self.access_repository.get_by_user_and_collection(user_id, collection.collection_id) + return access is not None + async def create_document( self, collection_id: UUID, @@ -55,8 +69,9 @@ class DocumentUseCases: if not collection: raise NotFoundError(f"Коллекция {collection_id} не найдена") - if collection.owner_id != user_id: - raise ForbiddenError("Только владелец может добавлять документы") + has_access = await self._check_collection_access(user_id, collection) + if not has_access: + raise ForbiddenError("У вас нет доступа к этой коллекции") title, content = await self.parser_service.parse_pdf(file, filename) @@ -87,8 +102,11 @@ class DocumentUseCases: document = await self.get_document(document_id) collection = await self.collection_repository.get_by_id(document.collection_id) - if not collection or collection.owner_id != user_id: - raise ForbiddenError("Только владелец коллекции может изменять документы") + if not collection: + raise NotFoundError(f"Коллекция {document.collection_id} не найдена") + has_access = await self._check_collection_access(user_id, collection) + if not has_access: + raise ForbiddenError("У вас нет доступа к этой коллекции") if title is not None: document.title = title diff --git a/backend/src/presentation/api/v1/collections.py b/backend/src/presentation/api/v1/collections.py index 0346e3a..6789f2e 100644 --- a/backend/src/presentation/api/v1/collections.py +++ b/backend/src/presentation/api/v1/collections.py @@ -13,7 +13,9 @@ from src.presentation.schemas.collection_schemas import ( CollectionUpdate, CollectionResponse, CollectionAccessGrant, - CollectionAccessResponse + CollectionAccessResponse, + CollectionAccessListResponse, + CollectionAccessUserInfo ) from src.application.use_cases.collection_use_cases import CollectionUseCases from src.domain.entities.user import User @@ -44,10 +46,19 @@ async def create_collection( @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) @@ -138,3 +149,78 @@ async def revoke_access( await use_cases.revoke_access(collection_id, user_id, current_user.user_id) return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + +@router.get("/{collection_id}/access", response_model=List[CollectionAccessListResponse]) +@inject +async def list_collection_access( + collection_id: UUID, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Получить список пользователей с доступом к коллекции""" + current_user = await get_current_user(request, user_repo) + accesses = await use_cases.list_collection_access(collection_id, current_user.user_id) + result = [] + for access in accesses: + user = await user_repo.get_by_id(access.user_id) + if user: + user_info = CollectionAccessUserInfo( + user_id=user.user_id, + telegram_id=user.telegram_id, + role=user.role.value, + created_at=user.created_at + ) + result.append(CollectionAccessListResponse( + access_id=access.access_id, + user=user_info, + collection_id=access.collection_id, + created_at=access.created_at + )) + + return result + + +@router.post("/{collection_id}/access/telegram/{telegram_id}", response_model=CollectionAccessResponse, status_code=status.HTTP_201_CREATED) +@inject +async def grant_access_by_telegram_id( + collection_id: UUID, + telegram_id: str, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Предоставить доступ пользователю к коллекции по Telegram ID""" + import logging + logger = logging.getLogger(__name__) + + current_user = await get_current_user(request, user_repo) + logger.info(f"Granting access: collection_id={collection_id}, target_telegram_id={telegram_id}, owner_id={current_user.user_id}") + + try: + access = await use_cases.grant_access_by_telegram_id( + collection_id=collection_id, + telegram_id=telegram_id, + owner_id=current_user.user_id + ) + logger.info(f"Access granted successfully: access_id={access.access_id}") + return CollectionAccessResponse.from_entity(access) + except Exception as e: + logger.error(f"Error granting access: {e}", exc_info=True) + raise + + +@router.delete("/{collection_id}/access/telegram/{telegram_id}", status_code=status.HTTP_204_NO_CONTENT) +@inject +async def revoke_access_by_telegram_id( + collection_id: UUID, + telegram_id: str, + request: Request, + user_repo: Annotated[IUserRepository, FromDishka()], + use_cases: Annotated[CollectionUseCases, FromDishka()] +): + """Отозвать доступ пользователя к коллекции по Telegram ID""" + current_user = await get_current_user(request, user_repo) + await use_cases.revoke_access_by_telegram_id(collection_id, telegram_id, current_user.user_id) + return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) + diff --git a/backend/src/presentation/api/v1/documents.py b/backend/src/presentation/api/v1/documents.py index d342ee4..a013850 100644 --- a/backend/src/presentation/api/v1/documents.py +++ b/backend/src/presentation/api/v1/documents.py @@ -2,7 +2,7 @@ API роутеры для работы с документами """ from uuid import UUID -from fastapi import APIRouter, status, UploadFile, File, Depends, Request +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 @@ -14,6 +14,7 @@ from src.presentation.schemas.document_schemas import ( 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"]) @@ -41,10 +42,10 @@ async def create_document( @router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) @inject async def upload_document( - collection_id: UUID, - request: Request, - user_repo: Annotated[IUserRepository, FromDishka()], - use_cases: Annotated[DocumentUseCases, FromDishka()], + collection_id: UUID = Query(...), + request: Request = None, + user_repo: Annotated[IUserRepository, FromDishka()] = None, + use_cases: Annotated[DocumentUseCases, FromDishka()] = None, file: UploadFile = File(...) ): """Загрузить и распарсить PDF документ или изображение""" @@ -123,11 +124,22 @@ async def delete_document( @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, diff --git a/backend/src/presentation/schemas/collection_schemas.py b/backend/src/presentation/schemas/collection_schemas.py index 08d75ec..d5ae94a 100644 --- a/backend/src/presentation/schemas/collection_schemas.py +++ b/backend/src/presentation/schemas/collection_schemas.py @@ -75,3 +75,22 @@ class CollectionAccessResponse(BaseModel): class Config: from_attributes = True + +class CollectionAccessUserInfo(BaseModel): + """Информация о пользователе с доступом""" + user_id: UUID + telegram_id: str + role: str + created_at: datetime + + +class CollectionAccessListResponse(BaseModel): + """Схема ответа со списком доступа""" + access_id: UUID + user: CollectionAccessUserInfo + collection_id: UUID + created_at: datetime + + class Config: + from_attributes = True + diff --git a/backend/src/shared/di_container.py b/backend/src/shared/di_container.py index 271f301..e773923 100644 --- a/backend/src/shared/di_container.py +++ b/backend/src/shared/di_container.py @@ -151,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( diff --git a/tg_bot/infrastructure/telegram/bot.py b/tg_bot/infrastructure/telegram/bot.py index 7bcbf64..d4d0db1 100644 --- a/tg_bot/infrastructure/telegram/bot.py +++ b/tg_bot/infrastructure/telegram/bot.py @@ -10,7 +10,8 @@ from tg_bot.infrastructure.telegram.handlers import ( stats_handler, question_handler, buy_handler, - collection_handler + collection_handler, + document_handler ) logger = logging.getLogger(__name__) @@ -27,6 +28,7 @@ async def create_bot() -> tuple[Bot, Dispatcher]: dp.include_router(stats_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 diff --git a/tg_bot/infrastructure/telegram/handlers/collection_handler.py b/tg_bot/infrastructure/telegram/handlers/collection_handler.py index eea2ab8..eba6f29 100644 --- a/tg_bot/infrastructure/telegram/handlers/collection_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/collection_handler.py @@ -1,8 +1,13 @@ -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() @@ -24,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"{settings.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 [] @@ -53,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) @@ -147,36 +250,495 @@ async def cmd_search(message: Message): await message.answer(response, parse_mode="HTML") -@router.callback_query(lambda c: c.data.startswith("collection:")) -async def show_collection_documents(callback: CallbackQuery): - collection_id = callback.data.split(":")[1] +@router.callback_query(lambda c: c.data.startswith("collection:") and not c.data.startswith("collection:documents:") and not c.data.startswith("collection:edit:") and not c.data.startswith("collection:access:") and not c.data.startswith("collection:view_access:")) +async def show_collection_menu(callback: CallbackQuery): + """Показать меню коллекции с опциями в зависимости от прав""" + parts = callback.data.split(":", 1) + if len(parts) < 2: + await callback.message.answer( + "Ошибка\n\nНеверный формат данных.", + parse_mode="HTML" + ) + await callback.answer() + return + + collection_id = parts[1] telegram_id = str(callback.from_user.id) - await callback.answer("Загружаю документы...") + print(f"DEBUG: collection_id from callback (menu): {collection_id}, callback_data: {callback.data}") - documents = await get_collection_documents(collection_id, telegram_id) + await callback.answer("Загружаю информацию...") - if not documents: + collection_info = await get_collection_info(collection_id, telegram_id) + if not collection_info: await callback.message.answer( - f"Коллекция пуста\n\n" - f"В этой коллекции пока нет документов.\n" - f"Обратитесь к администратору для добавления документов.", + "Ошибка\n\nНе удалось загрузить информацию о коллекции.", parse_mode="HTML" ) return + owner_id = collection_info.get("owner_id") + collection_name = collection_info.get("name", "Коллекция") + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{settings.BACKEND_URL}/users/telegram/{telegram_id}" + ) as response: + if response.status == 200: + user_info = await response.json() + current_user_id = user_info.get("user_id") + is_owner = str(owner_id) == str(current_user_id) + else: + is_owner = False + except: + is_owner = False + + keyboard_buttons = [] + + collection_id_str = str(collection_id) + + if is_owner: + keyboard_buttons = [ + [InlineKeyboardButton(text="Просмотр документов", callback_data=f"collection:documents:{collection_id_str}")], + [InlineKeyboardButton(text="Редактировать коллекцию", callback_data=f"collection:edit:{collection_id_str}")], + [InlineKeyboardButton(text="Управление доступом", callback_data=f"collection:access:{collection_id_str}")], + [InlineKeyboardButton(text="Загрузить документ", callback_data=f"document:upload:{collection_id_str}")], + [InlineKeyboardButton(text="Назад к коллекциям", callback_data="collections:list")] + ] + else: + keyboard_buttons = [ + [InlineKeyboardButton(text="Просмотр документов", callback_data=f"collection:documents:{collection_id_str}")], + [InlineKeyboardButton(text="Просмотр доступа", callback_data=f"collection:view_access:{collection_id_str}")], + [InlineKeyboardButton(text="Назад к коллекциям", callback_data="collections:list")] + ] + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + + role_text = "Владелец" if is_owner else "Доступ" + response = f"{collection_name}\n\n" + response += f"{role_text}\n\n" + response += f"ID: {collection_id}\n\n" + response += "Выберите действие:" + + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("collection:documents:")) +async def show_collection_documents(callback: CallbackQuery): + """Показать документы коллекции""" + try: + parts = callback.data.split(":", 2) + if len(parts) < 3: + raise ValueError("Неверный формат callback_data") + + collection_id = parts[2] + telegram_id = str(callback.from_user.id) + + print(f"DEBUG: collection_id from callback: {collection_id}, callback_data: {callback.data}") + + await callback.answer("Загружаю документы...") + + collection_info = await get_collection_info(collection_id, telegram_id) + if not collection_info: + await callback.message.answer( + "Ошибка\n\nНе удалось загрузить информацию о коллекции. Проверьте, что у вас есть доступ к этой коллекции.", + parse_mode="HTML" + ) + return + + documents = await get_collection_documents(collection_id, telegram_id) + + if not documents: + await callback.message.answer( + f"Коллекция пуста\n\n" + f"В этой коллекции пока нет документов.", + parse_mode="HTML" + ) + return + except IndexError: + await callback.message.answer( + "Ошибка\n\nНеверный формат данных.", + parse_mode="HTML" + ) + await callback.answer() + return + except Exception as e: + print(f"Error in show_collection_documents: {e}") + await callback.message.answer( + f"Ошибка\n\nПроизошла ошибка при загрузке документов: {str(e)}", + parse_mode="HTML" + ) + await callback.answer() + return + response = f"Документы в коллекции:\n\n" + keyboard_buttons = [] + for i, doc in enumerate(documents[:10], 1): + doc_id = doc.get("document_id") title = doc.get("title", "Без названия") content_preview = doc.get("content", "")[:100] response += f"{i}. {title}\n" if content_preview: response += f" {content_preview}...\n" response += "\n" + + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"{title[:30]}", + callback_data=f"document:view:{doc_id}" + ) + ]) if len(documents) > 10: response += f"\nПоказано 10 из {len(documents)} документов" - await callback.message.answer(response, parse_mode="HTML") + + collection_id_for_back = str(collection_info.get("collection_id", collection_id)) + keyboard_buttons.append([ + InlineKeyboardButton(text="Назад", callback_data=f"collection:{collection_id_for_back}") + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("collection:access:")) +async def show_access_management(callback: CallbackQuery): + """Показать меню управления доступом (только для владельца)""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await callback.answer("Загружаю список доступа...") + + access_list = await get_collection_access_list(collection_id, telegram_id) + + response = "Управление доступом\n\n" + response += "Пользователи с доступом:\n\n" + + keyboard_buttons = [] + + if access_list: + for i, access in enumerate(access_list[:10], 1): + user = access.get("user", {}) + user_telegram_id = user.get("telegram_id", "N/A") + role = user.get("role", "user") + response += f"{i}. {user_telegram_id} ({role})\n" + + keyboard_buttons.append([ + InlineKeyboardButton( + text=f" Удалить {user_telegram_id}", + callback_data=f"access:remove:{collection_id}:{user_telegram_id}" + ) + ]) + else: + response += "Нет пользователей с доступом\n\n" + + keyboard_buttons.extend([ + [InlineKeyboardButton(text="Добавить доступ", callback_data=f"access:add:{collection_id}")], + [InlineKeyboardButton(text="Назад", callback_data=f"collection:{collection_id}")] + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("collection:view_access:")) +async def show_access_list(callback: CallbackQuery): + """Показать список пользователей с доступом (read-only для пользователей с доступом)""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await callback.answer("Загружаю список доступа...") + + access_list = await get_collection_access_list(collection_id, telegram_id) + + response = "Пользователи с доступом\n\n" + + if access_list: + for i, access in enumerate(access_list[:20], 1): + user = access.get("user", {}) + user_telegram_id = user.get("telegram_id", "N/A") + role = user.get("role", "user") + response += f"{i}. {user_telegram_id} ({role})\n" + else: + response += "Нет пользователей с доступом\n" + + keyboard = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="Назад", callback_data=f"collection:{collection_id}") + ]]) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("access:add:")) +async def add_access_prompt(callback: CallbackQuery, state: FSMContext): + """Запросить пересылку сообщения для добавления доступа""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await state.update_data(collection_id=collection_id) + await state.set_state(CollectionAccessStates.waiting_for_username) + + await callback.message.answer( + "Добавить доступ\n\n" + "Перешлите любое сообщение от пользователя, которому нужно предоставить доступ.\n\n" + "Просто перешлите сообщение от нужного пользователя.", + parse_mode="HTML" + ) + await callback.answer() + + +@router.message(StateFilter(CollectionAccessStates.waiting_for_username)) +async def process_add_access(message: Message, state: FSMContext): + """Обработать добавление доступа через пересылку сообщения""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + collection_id = data.get("collection_id") + + if not collection_id: + await message.answer("Ошибка: не указана коллекция") + await state.clear() + return + + target_telegram_id = None + + if message.forward_from: + target_telegram_id = str(message.forward_from.id) + elif message.forward_from_chat: + await message.answer( + "Ошибка\n\n" + "Пожалуйста, перешлите сообщение от пользователя, а не из группы или канала.", + parse_mode="HTML" + ) + await state.clear() + return + elif message.forward_date: + await message.answer( + "Информация о пересылке скрыта\n\n" + "Пользователь скрыл информацию о пересылке в настройках приватности Telegram.\n\n" + "Попросите пользователя временно разрешить пересылку сообщений.", + parse_mode="HTML" + ) + await state.clear() + return + else: + await message.answer( + "Ошибка\n\n" + "Пожалуйста, перешлите сообщение от пользователя, которому нужно предоставить доступ.\n\n" + "Просто перешлите любое сообщение от нужного пользователя.", + parse_mode="HTML" + ) + await state.clear() + return + + if not target_telegram_id: + await message.answer( + "Ошибка\n\n" + "Не удалось определить Telegram ID пользователя.", + parse_mode="HTML" + ) + await state.clear() + return + + print(f"DEBUG: Attempting to grant access: collection_id={collection_id}, target_telegram_id={target_telegram_id}, owner_telegram_id={telegram_id}") + result = await grant_collection_access(collection_id, target_telegram_id, telegram_id) + + if result: + user_info = "" + if message.forward_from: + user_name = message.forward_from.first_name or "" + user_username = f"@{message.forward_from.username}" if message.forward_from.username else "" + user_info = f"{user_name} {user_username}".strip() or target_telegram_id + else: + user_info = target_telegram_id + + await message.answer( + f"Доступ предоставлен\n\n" + f"Пользователю {target_telegram_id} предоставлен доступ к коллекции.\n\n" + f"Пользователь: {user_info}\n\n" + f"Примечание: Если пользователь еще не взаимодействовал с ботом, он был автоматически создан в системе.", + parse_mode="HTML" + ) + else: + await message.answer( + "Ошибка\n\n" + "Не удалось предоставить доступ. Возможно:\n" + "• Доступ уже предоставлен\n" + "• Произошла ошибка на сервере\n" + "• Вы не являетесь владельцем коллекции\n\n" + "Проверьте логи сервера для получения подробной информации.", + parse_mode="HTML" + ) + + await state.clear() + + +@router.callback_query(lambda c: c.data.startswith("access:remove:")) +async def remove_access(callback: CallbackQuery): + """Удалить доступ пользователя""" + parts = callback.data.split(":") + collection_id = parts[2] + target_telegram_id = parts[3] + owner_telegram_id = str(callback.from_user.id) + + await callback.answer("Удаляю доступ...") + + result = await revoke_collection_access(collection_id, target_telegram_id, owner_telegram_id) + + if result: + await callback.message.answer( + f"Доступ отозван\n\n" + f"Доступ пользователя {target_telegram_id} отозван.", + parse_mode="HTML" + ) + else: + await callback.message.answer( + "Ошибка\n\n" + "Не удалось отозвать доступ.", + parse_mode="HTML" + ) + + +@router.callback_query(lambda c: c.data.startswith("collection:edit:")) +async def edit_collection_prompt(callback: CallbackQuery, state: FSMContext): + """Запросить данные для редактирования коллекции""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + collection_info = await get_collection_info(collection_id, telegram_id) + if not collection_info: + await callback.message.answer( + "Ошибка\n\nНе удалось загрузить информацию о коллекции.", + parse_mode="HTML" + ) + await callback.answer() + return + + await state.update_data(collection_id=collection_id) + await state.set_state(CollectionEditStates.waiting_for_name) + + await callback.message.answer( + "Редактирование коллекции\n\n" + "Отправьте новое название коллекции или /skip чтобы оставить текущее.\n\n" + f"Текущее название: {collection_info.get('name', 'Без названия')}", + parse_mode="HTML" + ) + await callback.answer() + + +@router.message(StateFilter(CollectionEditStates.waiting_for_name)) +async def process_edit_collection_name(message: Message, state: FSMContext): + """Обработать новое название коллекции""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + collection_id = data.get("collection_id") + + if message.text and message.text.strip() == "/skip": + new_name = None + else: + new_name = message.text.strip() if message.text else None + + await state.update_data(name=new_name) + await state.set_state(CollectionEditStates.waiting_for_description) + + collection_info = await get_collection_info(collection_id, telegram_id) + current_description = collection_info.get("description", "") if collection_info else "" + + await message.answer( + "Описание коллекции\n\n" + "Отправьте новое описание коллекции или /skip чтобы оставить текущее.\n\n" + f"Текущее описание: {current_description[:100] if current_description else 'Нет описания'}...", + parse_mode="HTML" + ) + + +@router.message(StateFilter(CollectionEditStates.waiting_for_description)) +async def process_edit_collection_description(message: Message, state: FSMContext): + """Обработать новое описание коллекции""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + collection_id = data.get("collection_id") + name = data.get("name") + + if message.text and message.text.strip() == "/skip": + new_description = None + else: + new_description = message.text.strip() if message.text else None + + try: + update_data = {} + if name: + update_data["name"] = name + if new_description: + update_data["description"] = new_description + + async with aiohttp.ClientSession() as session: + async with session.put( + f"{settings.BACKEND_URL}/collections/{collection_id}", + json=update_data, + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + await message.answer( + "Коллекция обновлена\n\n" + "Изменения сохранены.", + parse_mode="HTML" + ) + else: + error_text = await response.text() + await message.answer( + f"Ошибка\n\n" + f"Не удалось обновить коллекцию: {error_text}", + parse_mode="HTML" + ) + except Exception as e: + await message.answer( + f"Ошибка\n\n" + f"Произошла ошибка: {str(e)}", + parse_mode="HTML" + ) + + await state.clear() + + +@router.callback_query(lambda c: c.data == "collections:list") +async def back_to_collections(callback: CallbackQuery): + """Вернуться к списку коллекций""" + telegram_id = str(callback.from_user.id) + collections = await get_user_collections(telegram_id) + + if not collections: + await callback.message.answer( + "У вас пока нет коллекций\n\n" + "Обратитесь к администратору для создания коллекций и добавления документов.", + parse_mode="HTML" + ) + return + + response = "Ваши коллекции документов:\n\n" + keyboard_buttons = [] + + for i, collection in enumerate(collections[:10], 1): + name = collection.get("name", "Без названия") + description = collection.get("description", "") + collection_id = collection.get("collection_id") + + response += f"{i}. {name}\n" + if description: + response += f" {description[:50]}...\n" + response += f" ID: {collection_id}\n\n" + + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"{name}", + callback_data=f"collection:{collection_id}" + ) + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + response += "Нажмите на коллекцию, чтобы посмотреть документы" + + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) diff --git a/tg_bot/infrastructure/telegram/handlers/document_handler.py b/tg_bot/infrastructure/telegram/handlers/document_handler.py new file mode 100644 index 0000000..4f671a9 --- /dev/null +++ b/tg_bot/infrastructure/telegram/handlers/document_handler.py @@ -0,0 +1,381 @@ +""" +Обработчики для работы с документами +""" +from aiogram import Router, F +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +import aiohttp +from tg_bot.config.settings import settings +from tg_bot.infrastructure.telegram.states.collection_states import ( + DocumentEditStates, + DocumentUploadStates +) + +router = Router() + + +async def get_document_info(document_id: str, telegram_id: str): + """Получить информацию о документе""" + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{settings.BACKEND_URL}/documents/{document_id}", + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + return await response.json() + return None + except Exception as e: + print(f"Error getting document info: {e}") + return None + + +async def delete_document(document_id: str, telegram_id: str): + """Удалить документ""" + try: + async with aiohttp.ClientSession() as session: + async with session.delete( + f"{settings.BACKEND_URL}/documents/{document_id}", + headers={"X-Telegram-ID": telegram_id} + ) as response: + return response.status == 204 + except Exception as e: + print(f"Error deleting document: {e}") + return False + + +async def update_document(document_id: str, telegram_id: str, title: str = None, content: str = None): + """Обновить документ""" + try: + update_data = {} + if title: + update_data["title"] = title + if content: + update_data["content"] = content + + async with aiohttp.ClientSession() as session: + async with session.put( + f"{settings.BACKEND_URL}/documents/{document_id}", + json=update_data, + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + return await response.json() + return None + except Exception as e: + print(f"Error updating document: {e}") + return None + + +async def upload_document_to_collection(collection_id: str, file_data: bytes, filename: str, telegram_id: str): + """Загрузить документ в коллекцию""" + try: + async with aiohttp.ClientSession() as session: + form_data = aiohttp.FormData() + form_data.add_field('file', file_data, filename=filename, content_type='application/octet-stream') + + async with session.post( + f"{settings.BACKEND_URL}/documents/upload?collection_id={collection_id}", + data=form_data, + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 201: + return await response.json() + else: + error_text = await response.text() + print(f"Upload error: {response.status} - {error_text}") + return None + except Exception as e: + print(f"Error uploading document: {e}") + return None + + +@router.callback_query(lambda c: c.data.startswith("document:view:")) +async def view_document(callback: CallbackQuery): + """Просмотр документа""" + document_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await callback.answer("Загружаю документ...") + + document = await get_document_info(document_id, telegram_id) + if not document: + await callback.message.answer( + "Ошибка\n\nНе удалось загрузить документ.", + parse_mode="HTML" + ) + return + + title = document.get("title", "Без названия") + content = document.get("content", "") + collection_id = document.get("collection_id") + + content_preview = content[:2000] if len(content) > 2000 else content + has_more = len(content) > 2000 + + response = f"{title}\n\n" + response += f"{content_preview}" + if has_more: + response += "\n\n..." + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{settings.BACKEND_URL}/collections/{collection_id}", + headers={"X-Telegram-ID": telegram_id} + ) as response_collection: + if response_collection.status == 200: + collection_info = await response_collection.json() + owner_id = collection_info.get("owner_id") + + async with session.get( + f"{settings.BACKEND_URL}/users/telegram/{telegram_id}" + ) as response_user: + if response_user.status == 200: + user_info = await response_user.json() + current_user_id = user_info.get("user_id") + is_owner = str(owner_id) == str(current_user_id) + + keyboard_buttons = [] + if is_owner: + keyboard_buttons = [ + [InlineKeyboardButton(text="Редактировать", callback_data=f"document:edit:{document_id}")], + [InlineKeyboardButton(text="Удалить", callback_data=f"document:delete:{document_id}")], + [InlineKeyboardButton(text="Назад", callback_data=f"collection:documents:{collection_id}")] + ] + else: + keyboard_buttons = [ + [InlineKeyboardButton(text="Редактировать", callback_data=f"document:edit:{document_id}")], + [InlineKeyboardButton(text="Назад", callback_data=f"collection:documents:{collection_id}")] + ] + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + return + except: + pass + + keyboard = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="Назад", callback_data=f"collection:documents:{collection_id}") + ]]) + await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.callback_query(lambda c: c.data.startswith("document:edit:")) +async def edit_document_prompt(callback: CallbackQuery, state: FSMContext): + """Запросить данные для редактирования документа""" + document_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + document = await get_document_info(document_id, telegram_id) + if not document: + await callback.message.answer( + "Ошибка\n\nНе удалось загрузить документ.", + parse_mode="HTML" + ) + await callback.answer() + return + + await state.update_data(document_id=document_id) + await state.set_state(DocumentEditStates.waiting_for_title) + + await callback.message.answer( + "Редактирование документа\n\n" + "Отправьте новое название документа или /skip чтобы оставить текущее.\n\n" + f"Текущее название: {document.get('title', 'Без названия')}", + parse_mode="HTML" + ) + await callback.answer() + + +@router.message(StateFilter(DocumentEditStates.waiting_for_title)) +async def process_edit_title(message: Message, state: FSMContext): + """Обработать новое название документа""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + document_id = data.get("document_id") + + if message.text and message.text.strip() == "/skip": + new_title = None + else: + new_title = message.text.strip() if message.text else None + + await state.update_data(title=new_title) + await state.set_state(DocumentEditStates.waiting_for_content) + + await message.answer( + "Содержимое документа\n\n" + "Отправьте новое содержимое документа или /skip чтобы оставить текущее.", + parse_mode="HTML" + ) + + +@router.message(StateFilter(DocumentEditStates.waiting_for_content)) +async def process_edit_content(message: Message, state: FSMContext): + """Обработать новое содержимое документа""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + document_id = data.get("document_id") + title = data.get("title") + + if message.text and message.text.strip() == "/skip": + new_content = None + else: + new_content = message.text.strip() if message.text else None + + result = await update_document(document_id, telegram_id, title=title, content=new_content) + + if result: + await message.answer( + "Документ обновлен\n\n" + "Изменения сохранены.", + parse_mode="HTML" + ) + else: + await message.answer( + "Ошибка\n\n" + "Не удалось обновить документ.", + parse_mode="HTML" + ) + + await state.clear() + + +@router.callback_query(lambda c: c.data.startswith("document:delete:")) +async def delete_document_confirm(callback: CallbackQuery): + """Подтверждение удаления документа""" + document_id = callback.data.split(":")[2] + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="Да, удалить", callback_data=f"document:delete_confirm:{document_id}")], + [InlineKeyboardButton(text="Отмена", callback_data=f"document:view:{document_id}")] + ]) + + await callback.message.answer( + "Подтверждение удаления\n\n" + "Вы уверены, что хотите удалить этот документ?", + parse_mode="HTML", + reply_markup=keyboard + ) + await callback.answer() + + +@router.callback_query(lambda c: c.data.startswith("document:delete_confirm:")) +async def delete_document_execute(callback: CallbackQuery): + """Выполнить удаление документа""" + document_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await callback.answer("Удаляю документ...") + + # Получаем информацию о документе для возврата к коллекции + document = await get_document_info(document_id, telegram_id) + collection_id = document.get("collection_id") if document else None + + result = await delete_document(document_id, telegram_id) + + if result: + await callback.message.answer( + "Документ удален", + parse_mode="HTML" + ) + else: + await callback.message.answer( + "Ошибка\n\n" + "Не удалось удалить документ.", + parse_mode="HTML" + ) + + +@router.callback_query(lambda c: c.data.startswith("document:upload:")) +async def upload_document_prompt(callback: CallbackQuery, state: FSMContext): + """Запросить файл для загрузки""" + collection_id = callback.data.split(":")[2] + telegram_id = str(callback.from_user.id) + + await state.update_data(collection_id=collection_id) + await state.set_state(DocumentUploadStates.waiting_for_file) + + await callback.message.answer( + "Загрузка документа\n\n" + "Отправьте файл (PDF, PNG, JPG, JPEG, TIFF, BMP).\n\n" + "Поддерживаемые форматы:\n" + "• PDF\n" + "• Изображения: PNG, JPG, JPEG, TIFF, BMP", + parse_mode="HTML" + ) + await callback.answer() + + +@router.message(StateFilter(DocumentUploadStates.waiting_for_file), F.document | F.photo) +async def process_upload_document(message: Message, state: FSMContext): + """Обработать загрузку документа""" + telegram_id = str(message.from_user.id) + data = await state.get_data() + collection_id = data.get("collection_id") + + if not collection_id: + await message.answer("Ошибка: не указана коллекция") + await state.clear() + return + + file_id = None + filename = None + + if message.document: + file_id = message.document.file_id + filename = message.document.file_name or "document.pdf" + + supported_extensions = ['.pdf', '.png', '.jpg', '.jpeg', '.tiff', '.bmp'] + file_ext = filename.lower().rsplit('.', 1)[-1] if '.' in filename else '' + if f'.{file_ext}' not in supported_extensions: + await message.answer( + "Ошибка\n\n" + f"Неподдерживаемый формат файла: {file_ext}\n\n" + "Поддерживаются: PDF, PNG, JPG, JPEG, TIFF, BMP", + parse_mode="HTML" + ) + await state.clear() + return + elif message.photo: + file_id = message.photo[-1].file_id + filename = "photo.jpg" + else: + await message.answer( + "Ошибка\n\n" + "Пожалуйста, отправьте файл (PDF или изображение).", + parse_mode="HTML" + ) + await state.clear() + return + + try: + file = await message.bot.get_file(file_id) + file_data = await message.bot.download_file(file.file_path) + file_bytes = file_data.read() + + result = await upload_document_to_collection(collection_id, file_bytes, filename, telegram_id) + + if result: + await message.answer( + f"Документ загружен\n\n" + f"Название: {result.get('title', filename)}", + parse_mode="HTML" + ) + else: + await message.answer( + "Ошибка\n\n" + "Не удалось загрузить документ.", + parse_mode="HTML" + ) + except Exception as e: + print(f"Error uploading document: {e}") + await message.answer( + "Ошибка\n\n" + f"Произошла ошибка при загрузке: {str(e)}", + parse_mode="HTML" + ) + + await state.clear() + diff --git a/tg_bot/infrastructure/telegram/states/collection_states.py b/tg_bot/infrastructure/telegram/states/collection_states.py new file mode 100644 index 0000000..f7f0939 --- /dev/null +++ b/tg_bot/infrastructure/telegram/states/collection_states.py @@ -0,0 +1,27 @@ +""" +FSM состояния для работы с коллекциями +""" +from aiogram.fsm.state import State, StatesGroup + + +class CollectionAccessStates(StatesGroup): + """Состояния для управления доступом к коллекции""" + waiting_for_username = State() + + +class CollectionEditStates(StatesGroup): + """Состояния для редактирования коллекции""" + waiting_for_name = State() + waiting_for_description = State() + + +class DocumentEditStates(StatesGroup): + """Состояния для редактирования документа""" + waiting_for_title = State() + waiting_for_content = State() + + +class DocumentUploadStates(StatesGroup): + """Состояния для загрузки документа""" + waiting_for_file = State() +