import os import asyncio from typing import Optional from fastapi import Depends, APIRouter, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_, and_, exists, func from sqlalchemy.exc import IntegrityError from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from app.db import models from app.core.security import get_current_user from app.api import schemas from app.core.config import config from app.websocket import connection_manager async def get_db(): async with models.AsyncSessionLocal() as db: try: yield db finally: await db.close() def _get_drive_service_for_users(): credentials = Credentials( token=None, refresh_token=config.GOOGLE_REFRESH_TOKEN, token_uri='https://oauth2.googleapis.com/token', client_id=config.GOOGLE_CLIENT_ID, client_secret=config.GOOGLE_CLIENT_SECRET, scopes=['https://www.googleapis.com/auth/drive'] ) return build('drive', 'v3', credentials=credentials) def _build_user_search_filter(query: str): normalized = f"%{query.lower()}%" return or_( func.lower(models.User.username).like(normalized), func.lower(models.User.first_name).like(normalized), func.lower(models.User.last_name).like(normalized), models.User.phone.like(normalized), func.lower(models.User.first_name + " " + func.coalesce(models.User.last_name, '')).like(normalized), func.lower(func.coalesce(models.User.last_name, '') + " " + models.User.first_name).like(normalized), ) async def _delete_old_avatar_file(file_id: str, db: AsyncSession): res = await db.execute(select(models.MediaItem).where(models.MediaItem.file_id == file_id)) cloud_items = res.scalars().all() if not cloud_items: return try: service = _get_drive_service_for_users() except Exception as e: print(f"Не удалось инициализировать Google Drive для удаления аватарки: {e}") return for item in cloud_items: try: await asyncio.to_thread(service.files().delete(fileId=item.storage_file_id).execute) print(f"Старая аватарка {item.storage_file_id} успешно удалена из Google Drive") except Exception as e: print(f"Ошибка физического удаления аватарки из Google Drive: {e}") await db.delete(item) await db.commit() print(f"Старая аватарка {file_id} успешно удалена из базы данных") usersRouter = APIRouter( prefix="/users", tags=[], ) @usersRouter.get("/me") async def read_users_me(current_user: models.User = Depends(get_current_user)): return { "id": current_user.id, "username": current_user.username, "first_name": current_user.first_name, "last_name": current_user.last_name, "phone": current_user.phone, "email": getattr(current_user, "email", None), "about": current_user.about, "public_key": current_user.public_key, "encrypted_private_key": current_user.encrypted_private_key, "avatar_file_id": current_user.avatar_file_id, "totp_enabled": bool(current_user.totp_secret != None), } @usersRouter.put("/me") async def update_users_me( data: schemas.UpdateMe, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): user_to_update = await db.merge(current_user) if data.username is not None: user_to_update.username = data.username if data.first_name is not None: user_to_update.first_name = data.first_name if data.last_name is not None: user_to_update.last_name = data.last_name if data.phone is not None: user_to_update.phone = data.phone or None if data.email is not None: user_to_update.email = data.email or None if data.about is not None: user_to_update.about = data.about or None try: await db.commit() except IntegrityError: await db.rollback() raise HTTPException(status_code=400, detail="phone/email already in use") await db.refresh(user_to_update) await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id}) return { "status": "ok", "user": { "id": user_to_update.id, "username": user_to_update.username, "first_name": user_to_update.first_name, "last_name": user_to_update.last_name, "phone": user_to_update.phone, "email": getattr(user_to_update, "email", None), "about": user_to_update.about, }, } @usersRouter.put("/me/encryption-key") async def update_encrypted_private_key( data: schemas.UpdateEncryptedPrivateKey, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): user_to_update = await db.merge(current_user) user_to_update.encrypted_private_key = data.encrypted_private_key try: await db.commit() except Exception: await db.rollback() raise HTTPException(status_code=500, detail="Не удалось сохранить ключ шифрования") await db.refresh(user_to_update) await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id}) return {"status": "ok"} @usersRouter.put("/me/password") async def change_password( data: schemas.ChangePassword, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): from app.core.security import verify_password, get_password_hash if not verify_password(data.current_password, current_user.hashed_password): raise HTTPException(status_code=400, detail="Неверный текущий пароль") user_to_update = await db.merge(current_user) user_to_update.hashed_password = get_password_hash(data.new_password) try: await db.commit() except Exception: await db.rollback() raise HTTPException(status_code=500, detail="Не удалось изменить пароль") await db.refresh(user_to_update) return {"status": "ok"} @usersRouter.put("/me/privacy") async def update_privacy_settings( data: schemas.UpdatePrivacySettings, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): user_to_update = await db.merge(current_user) if data.show_email is not None: user_to_update.show_email = 1 if data.show_email else 0 if data.show_phone is not None: user_to_update.show_phone = 1 if data.show_phone else 0 if data.show_avatar is not None: user_to_update.show_avatar = 1 if data.show_avatar else 0 if data.show_about is not None: user_to_update.show_about = 1 if data.show_about else 0 user_to_update.show_username = 1 if data.show_last_online is not None: user_to_update.show_last_online = 1 if data.show_last_online else 0 try: await db.commit() except Exception: await db.rollback() raise HTTPException(status_code=500, detail="Не удалось сохранить настройки конфиденциальности") await db.refresh(user_to_update) await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id}) return {"status": "ok"} @usersRouter.get("/me/privacy") async def get_privacy_settings(current_user: models.User = Depends(get_current_user)): return { "show_email": bool(current_user.show_email), "show_phone": bool(current_user.show_phone), "show_avatar": bool(current_user.show_avatar), "show_about": bool(current_user.show_about), "show_username": True, "show_last_online": bool(current_user.show_last_online), } @usersRouter.get("/all") async def read_users_all( request: Request, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db), query: Optional[str] = None, ): stmt = select(models.User) if query and query.strip(): stmt = stmt.filter(_build_user_search_filter(query.strip())) res = await db.execute(stmt) users = res.scalars().all() users_for_return = [] if current_user.id >= 100: for user in users: if not (1 < int(user.id) < 100 or int(user.id) == 0 or int(user.id) == current_user.id): users_for_return.append(user) else: users_for_return = users return [ { "id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key, "phone": user.phone, "avatar_file_id": user.avatar_file_id if (user.show_avatar or current_user.id == 1) else None, "avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None, } for user in users_for_return ] @usersRouter.get("/chats") async def read_users_chats( request: Request, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db), query: Optional[str] = None, ): stmt = ( select(models.User) .filter(models.User.id != current_user.id) .filter(exists().where( or_( and_(models.Message.sender_id == current_user.id, models.Message.receiver_id == models.User.id), and_(models.Message.sender_id == models.User.id, models.Message.receiver_id == current_user.id) ) )) ) if query and query.strip(): stmt = stmt.filter(_build_user_search_filter(query.strip())) users_res = await db.execute(stmt) users = users_res.scalars().all() result = [] for user in users: last_msg_res = await db.execute( select(models.Message) .filter( or_( and_(models.Message.sender_id == current_user.id, models.Message.receiver_id == user.id), and_(models.Message.sender_id == user.id, models.Message.receiver_id == current_user.id), ) ) .order_by(models.Message.timestamp.desc()) ) last_msg = last_msg_res.scalars().first() unread_res = await db.execute( select(func.count(models.Message.id)) .filter( models.Message.sender_id == user.id, models.Message.receiver_id == current_user.id, models.Message.read_at.is_(None), ) ) unread_count = unread_res.scalar() or 0 result.append( { "id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key, "avatar_file_id": user.avatar_file_id if (user.show_avatar or current_user.id == 1) else None, "avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None, "last_message": last_msg.content if last_msg else None, "last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None), "unread_count": unread_count, "online": str(user.id) in connection_manager.manager.online_users, "last_message_id": last_msg.id if last_msg else None, "last_message_type": last_msg.message_type if last_msg else None, "phone": user.phone, } ) result.sort(key=lambda x: x['last_message_time'] or '', reverse=True) return result @usersRouter.get("/by-username/{username}", response_model=schemas.UserContactResponse) async def get_user_by_username(username: str, request: Request, db: AsyncSession = Depends(get_db), current_user: models.User = Depends(get_current_user)): res = await db.execute(select(models.User).where(models.User.username == username)) user = res.scalars().first() if not user: raise HTTPException(status_code=404, detail="Пользователь не найден") profile_data = { "id": user.id, "public_key": user.public_key, "first_name": user.first_name, "last_name": user.last_name, "username": user.username, "show_avatar": bool(user.show_avatar), "totp_enabled": bool(user.totp_secret), "online": str(user.id) in connection_manager.manager.online_users } if user.show_avatar or current_user.id == 1: profile_data["avatar_url"] = str(request.url_for("get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None if user.show_about or current_user.id == 1: profile_data["about"] = user.about if user.show_phone or current_user.id == 1: profile_data["phone"] = user.phone if user.show_email or current_user.id == 1: profile_data["email"] = user.email if user.show_last_online or current_user.id == 1: profile_data["last_online"] = user.last_online.isoformat() if user.last_online else None return profile_data @usersRouter.get("/{user_id}", response_model=schemas.UserProfile) async def get_user_by_id( user_id: int, request: Request, db: AsyncSession = Depends(get_db), current_user: models.User = Depends(get_current_user) ): res = await db.execute(select(models.User).where(models.User.id == user_id)) user = res.scalars().first() if not user: raise HTTPException(status_code=404, detail="Пользователь не найден") profile_data = { "id": user.id, "public_key": user.public_key, "first_name": user.first_name, "last_name": user.last_name, "username": user.username, "show_avatar": bool(user.show_avatar), "totp_enabled": bool(user.totp_secret), "online": str(user.id) in connection_manager.manager.online_users } if user.show_avatar or current_user.id == 1: profile_data["avatar_url"] = str(request.url_for("get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None if user.show_about or current_user.id == 1: profile_data["about"] = user.about if user.show_phone or current_user.id == 1: profile_data["phone"] = user.phone if user.show_email or current_user.id == 1: profile_data["email"] = user.email if user.show_last_online or current_user.id == 1: profile_data["last_online"] = user.last_online.isoformat() if user.last_online else None return profile_data @usersRouter.put("/me/avatar") async def update_user_avatar( data: dict, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): user_to_update = await db.merge(current_user) avatar_file_id = data.get("avatar_file_id") if avatar_file_id: old_avatar_file_id = user_to_update.avatar_file_id if old_avatar_file_id and old_avatar_file_id != avatar_file_id: await _delete_old_avatar_file(old_avatar_file_id, db) user_to_update.avatar_file_id = avatar_file_id await db.commit() print(f"Пользователь {user_to_update.id} обновил аватар: {avatar_file_id}") else: raise HTTPException(status_code=400, detail="avatar_file_id is required") await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id}) return {"message": "Avatar updated"}