diff --git a/app/controllers/main_controller.py b/app/controllers/main_controller.py index 05b996d..66d04e4 100644 --- a/app/controllers/main_controller.py +++ b/app/controllers/main_controller.py @@ -105,7 +105,7 @@ class MainController(QStackedWidget): # return token = session['access_token'] - success, data = await get_private_chats(token=token, offset=0, limit=50) + success, data = await get_private_chats(login=username, token=token, offset=0, limit=50) if success: # Отправляем данные в основной поток через сигнал @@ -113,3 +113,5 @@ class MainController(QStackedWidget): else: # Отправляем ошибку в основной поток через сигнал self.notification_requested.emit(f"Не удалось загрузить чаты: {data}", True) + print("data", data) + diff --git a/app/core/http_client.py b/app/core/http_client.py index 3ebd3ab..b8a4ba2 100644 --- a/app/core/http_client.py +++ b/app/core/http_client.py @@ -1,17 +1,92 @@ -import ssl import httpx +from contextvars import ContextVar +from typing import Dict, Any, Optional -ssl_transport = httpx.AsyncHTTPTransport(http2=True) - -# Ограничения пула соединений +# Connection pool & timeouts limits = httpx.Limits(max_connections=200, max_keepalive_connections=50) - -# Таймауты timeout = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0) -# Глобальный клиент -client = httpx.AsyncClient( - transport=ssl_transport, - limits=limits, - timeout=timeout, -) +# Keep a client per event loop/context to avoid cross-loop issues +_client_ctx: ContextVar[httpx.AsyncClient | None] = ContextVar("http_client_ctx", default=None) + +def get_client() -> httpx.AsyncClient: + client = _client_ctx.get() + if client is None or client.is_closed: + transport = httpx.AsyncHTTPTransport(http2=True) + client = httpx.AsyncClient(transport=transport, limits=limits, timeout=timeout) + _client_ctx.set(client) + return client + +async def aclose_client(): + client = _client_ctx.get() + if client is not None and not client.is_closed: + await client.aclose() + _client_ctx.set(None) + +# --- Authorized helpers with auto-refresh on 401 --- +async def authorized_get( + url: str, + *, + login: Optional[str] = None, + access_token: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, Any]] = None, +) -> httpx.Response: + hdrs = dict(headers or {}) + if access_token: + hdrs["Authorization"] = f"Bearer {access_token}" + + resp = await get_client().get(url, headers=hdrs, params=params) + if resp.status_code != 401 or not login or not access_token: + return resp + + # Try refresh flow lazily to avoid import cycle at import time + from app.core.database import get_session, logout + from app.core.services.auth_service import refresh_token as do_refresh + + session = get_session(login) + if not session or not session.get("refresh_token"): + return resp + + ok, data = await do_refresh(access_token, session["refresh_token"]) + if ok: + new_access = data["access_token"] + hdrs["Authorization"] = f"Bearer {new_access}" + return await get_client().get(url, headers=hdrs, params=params) + + logout(access_token) + return resp + +async def authorized_post( + url: str, + *, + login: Optional[str] = None, + access_token: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, Any]] = None, + json: Any = None, + data: Any = None, +) -> httpx.Response: + hdrs = dict(headers or {}) + if access_token: + hdrs["Authorization"] = f"Bearer {access_token}" + + resp = await get_client().post(url, headers=hdrs, params=params, json=json, data=data) + if resp.status_code != 401 or not login or not access_token: + return resp + + from app.core.database import get_session, logout + from app.core.services.auth_service import refresh_token as do_refresh + + session = get_session(login) + if not session or not session.get("refresh_token"): + return resp + + ok, data = await do_refresh(access_token, session["refresh_token"]) + if ok: + new_access = data["access_token"] + hdrs["Authorization"] = f"Bearer {new_access}" + return await get_client().post(url, headers=hdrs, params=params, json=json, data=data) + + logout(access_token) + return resp diff --git a/app/core/services/auth_service.py b/app/core/services/auth_service.py index 086122d..4edf6a8 100644 --- a/app/core/services/auth_service.py +++ b/app/core/services/auth_service.py @@ -3,62 +3,61 @@ import asyncio from app.core import config from app.core.database import add_session, logout, get_session from app.core.localizer import localizer +from app.core.http_client import get_client, authorized_get + async def login(login, password): """ - Отправляет запрос на аутентификацию и в случае успеха сохраняет сессию. + Логин пользователя по логину и паролю. :param login: Логин пользователя :param password: Пароль пользователя - :return: Кортеж (успех: bool, сообщение: str) + :return: tuple (ok: bool, message: str) """ url = f"{config.BASE_URL}/v1/auth/login" - try: - async with httpx.AsyncClient(http2=True) as client: - response = await client.post(url, json={"login": login, "password": password}) + response = await get_client().post(url, json={"login": login, "password": password}) - if response.status_code == 200: - data = response.json() - if data.get("status") == "fine": - token_data = data["data"] - add_session( - login=login, - access_token=token_data["access_token"], - refresh_token=token_data["refresh_token"], - user_id=token_data["user_id"] - ) - return True, localizer.translate("Успешный вход") - else: - return False, data.get("detail", localizer.translate("Неизвестная ошибка ответа")) - - elif response.status_code in [401]: - error_data = response.json() - return False, error_data.get("detail", localizer.translate("Неверный логин или пароль")) + if response.status_code == 200: + data = response.json() + if data.get("status") == "fine": + token_data = data["data"] + add_session( + login=login, + access_token=token_data["access_token"], + refresh_token=token_data["refresh_token"], + user_id=token_data.get("user_id"), + ) + return True, localizer.translate("Вход выполнен успешно") + return False, data.get("detail", localizer.translate("Неизвестная ошибка")) - elif response.status_code in [403]: - error_data = response.json() - return False, error_data.get("detail", localizer.translate("Учетная запись пользователя отключена")) + if response.status_code == 401: + error_data = response.json() + return False, error_data.get("detail", localizer.translate("Неверный логин или пароль")) - elif response.status_code == 422: - return False, localizer.translate("Некорректные данные для входа") + if response.status_code == 403: + error_data = response.json() + return False, error_data.get("detail", localizer.translate("Доступ запрещен")) - else: - return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" + if response.status_code == 422: + return False, localizer.translate("Некорректные входные данные") + + return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" except httpx.RequestError as e: - return False, f"{localizer.translate('Ошибка сети')}: {e}" + return False, f"{localizer.translate('Сетевая ошибка')}: {e}" except Exception as e: return False, f"{localizer.translate('Произошла ошибка')}: {e}" + async def register(login, password, invite=None): """ - Отправляет запрос на регистрацию нового пользователя. + Регистрация нового пользователя. :param login: Логин :param password: Пароль :param invite: Инвайт-код (опционально) - :return: Кортеж (успех: bool, сообщение: str) + :return: tuple (ok: bool, message: str) """ url = f"{config.BASE_URL}/v1/auth/register" payload = {"login": login, "password": password} @@ -66,116 +65,90 @@ async def register(login, password, invite=None): payload["invite"] = invite try: - async with httpx.AsyncClient(http2=True) as client: - response = await client.post(url, json=payload) + response = await get_client().post(url, json=payload) - if response.status_code == 201: - return True, localizer.translate("Регистрация прошла успешно!") - - error_data = response.json() - error_message = error_data.get("detail", localizer.translate("Произошла ошибка")) + if response.status_code == 201: + return True, localizer.translate("Регистрация прошла успешно!") - if response.status_code == 409: - return False, localizer.translate("Этот логин уже занят.") - elif response.status_code == 400: - return False, localizer.translate("Неверный инвайт-код.") - elif response.status_code == 403: - return False, localizer.translate("Регистрация в данный момент отключена.") - elif response.status_code == 422: - return False, localizer.translate("Данные не прошли валидацию. Проверьте длину логина и пароля.") - else: - return False, f"{localizer.translate('Ошибка сервера')} ({response.status_code}): {error_message}" + error_data = response.json() + error_message = error_data.get("detail", localizer.translate("Произошла ошибка")) + + if response.status_code == 409: + return False, localizer.translate("Пользователь уже существует.") + if response.status_code == 400: + return False, localizer.translate("Некорректный запрос.") + if response.status_code == 403: + return False, localizer.translate("Регистрация по приглашению отключена.") + if response.status_code == 422: + return False, localizer.translate("Некорректные входные данные. Проверьте логин и пароль.") + + return False, f"{localizer.translate('Ошибка сервера')} ({response.status_code}): {error_message}" except httpx.RequestError as e: - return False, f"{localizer.translate('Ошибка сети')}: {e}" + return False, f"{localizer.translate('Сетевая ошибка')}: {e}" except Exception as e: return False, f"{localizer.translate('Произошла ошибка')}: {e}" async def refresh_token(access_token: str, refresh_token: str): """ - Обновляет токен доступа, используя токен обновления. + Обновление пары токенов по refresh_token. - :param access_token: Истекший токен доступа - :param refresh_token: Токен обновления - :return: Кортеж (успех: bool, данные: dict | str) + :param access_token: Текущий access token + :param refresh_token: Refresh token + :return: tuple (ok: bool, data: dict | str) """ url = f"{config.BASE_URL}/v1/auth/token/refresh" payload = {"access_token": access_token, "refresh_token": refresh_token} try: - async with httpx.AsyncClient(http2=True) as client: - response = await client.post(url, json=payload) + response = await get_client().post(url, json=payload) - if response.status_code == 200: - data = response.json() - if data.get("status") == "fine": - token_data = data["data"] - # Обновляем сессию с новыми токенами - add_session( - login=None, # Логин не требуется для обновления - access_token=token_data["access_token"], - refresh_token=token_data["refresh_token"], - update_existing=True - ) - return True, token_data - else: - return False, data.get("detail", localizer.translate("Unknown error")) + if response.status_code == 200: + data = response.json() + if data.get("status") == "fine": + token_data = data["data"] + add_session( + login=None, + access_token=token_data["access_token"], + refresh_token=token_data["refresh_token"], + update_existing=True, + ) + return True, token_data + return False, data.get("detail", localizer.translate("Неизвестная ошибка")) - elif response.status_code == 401: - return False, localizer.translate("Refresh token is invalid or expired") - - else: - return False, f"{localizer.translate('Server error')}: {response.status_code}" + if response.status_code == 401: + return False, localizer.translate("Refresh token недействителен или истек") + + return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" except httpx.RequestError as e: - return False, f"{localizer.translate('Network error')}: {e}" + return False, f"{localizer.translate('Сетевая ошибка')}: {e}" except Exception as e: - return False, f"{localizer.translate('An error occurred')}: {e}" + return False, f"{localizer.translate('Произошла ошибка')}: {e}" async def get_user_role(access_token: str, login: str): """ - Получает роль и права пользователя по токену доступа. - В случае истечения срока действия токена, пытается его обновить. + Получение роли пользователя. При 401 выполняется автообновление токена внутри authorized_get. """ url = f"{config.BASE_URL}/v1/user/role" headers = {"Authorization": f"Bearer {access_token}"} try: - async with httpx.AsyncClient(http2=True) as client: - response = await client.get(url, headers=headers) + response = await authorized_get(url, login=login, access_token=access_token, headers=headers) - if response.status_code == 200: - data = response.json() - return (True, data['data']) if data.get("status") == "fine" else (False, data.get("detail", localizer.translate("Unknown error"))) + if response.status_code == 200: + data = response.json() + return (True, data['data']) if data.get("status") == "fine" else (False, data.get("detail", localizer.translate("Неизвестная ошибка"))) - elif response.status_code == 401: - # Токен истек, пытаемся обновить - session = get_session(login) - if not session or not session['refresh_token']: - return False, localizer.translate("No refresh token found") + if response.status_code == 401: + return False, localizer.translate("Сессия истекла, войдите снова") - refresh_success, refresh_data = await refresh_token(access_token, session['refresh_token']) - - if refresh_success: - # Повторяем запрос с новым токеном - new_access_token = refresh_data['access_token'] - headers["Authorization"] = f"Bearer {new_access_token}" - response = await client.get(url, headers=headers) - - if response.status_code == 200: - data = response.json() - return (True, data['data']) if data.get("status") == "fine" else (False, localizer.translate("Failed to get role after refresh")) - - # Если обновление не удалось, выходим из системы - logout(access_token) - return False, localizer.translate("Session expired, please log in again") - - else: - return False, f"{localizer.translate('Server error')}: {response.status_code}" + return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" except httpx.RequestError as e: - return False, f"{localizer.translate('Network error')}: {e}" + return False, f"{localizer.translate('Сетевая ошибка')}: {e}" except Exception as e: - return False, f"{localizer.translate('An error occurred')}: {e}" + return False, f"{localizer.translate('Произошла ошибка')}: {e}" + diff --git a/app/core/services/chat_service.py b/app/core/services/chat_service.py index 8943426..cec8d63 100644 --- a/app/core/services/chat_service.py +++ b/app/core/services/chat_service.py @@ -7,61 +7,49 @@ from app.core.models.chat_models import ( PrivateMessageSendRequest, PrivateMessageSendResponse ) from uuid import UUID +from app.core.http_client import get_client, authorized_get, authorized_post -async def get_private_chats(token: str, offset: int = 0, limit: int = 20): - """ - Получает список приватных чатов пользователя. - :param token: Токен доступа пользователя - :param offset: Смещение для пагинации - :param limit: Количество чатов для загрузки - :return: Кортеж (успех: bool, данные: PrivateChatListData | str) +async def get_private_chats(login: str, token: str, offset: int = 0, limit: int = 20): + """ + Получить список приватных чатов. + + :param token: Bearer токен + :param offset: Смещение + :param limit: Количество + :return: tuple (ok: bool, data: PrivateChatListData | str) """ - # TODO: Добавить логику обновления токена, как в auth_service.py url = f"{config.BASE_URL}/v1/chat/private/list" headers = {"Authorization": f"Bearer {token}"} params = {"offset": offset, "limit": limit} try: - async with httpx.AsyncClient(http2=True) as client: - response = await client.get(url, headers=headers, params=params) - print("response.status_code", response.status_code) + response = await authorized_get(url, login=login, access_token=token, headers=headers, params=params) + if response.status_code == 200: + data = response.json() + if data.get("status") == "fine": + response_model = PrivateChatListResponse(**data) + return True, response_model.data + return False, data.get("detail", localizer.translate("Неизвестная ошибка")) - if response.status_code == 200: - data = response.json() - if data.get("status") == "fine": - # Используем Pydantic модель для парсинга ответа - response_model = PrivateChatListResponse(**data) - # print("response_model.data", response_model.data) - print("data", data) - return True, response_model.data - else: - return False, data.get("detail", localizer.translate("Неизвестная ошибка ответа")) - - elif response.status_code in [401, 403]: - error_data = response.json() - return False, error_data.get("detail", localizer.translate("Ошибка аутентификации или авторизации")) + if response.status_code in [401, 403]: + error_data = response.json() + return False, error_data.get("detail", localizer.translate("Недостаточно прав или неавторизован")) - elif response.status_code == 422: - return False, localizer.translate("Некорректные параметры запроса") + if response.status_code == 422: + return False, localizer.translate("Некорректные параметры запроса") - else: - return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" + return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" except httpx.RequestError as e: - return False, f"{localizer.translate('Ошибка сети')}: {e}" + return False, f"{localizer.translate('Сетевая ошибка')}: {e}" except Exception as e: return False, f"{localizer.translate('Произошла ошибка')}: {e}" -async def get_chat_history(token: str, chat_id: UUID, before_message_id: int = None, limit: int = 30): - """ - Получает историю сообщений для указанного приватного чата. - :param token: Токен доступа - :param chat_id: ID чата - :param before_message_id: ID сообщения для пагинации (загрузка более старых) - :param limit: Количество сообщений для загрузки - :return: Кортеж (успех: bool, данные: PrivateChatHistoryData | str) +async def get_chat_history(login: str, token: str, chat_id: UUID, before_message_id: int = None, limit: int = 30): + """ + История сообщений приватного чата. """ url = f"{config.BASE_URL}/v1/chat/private/history" headers = {"Authorization": f"Bearer {token}"} @@ -70,73 +58,61 @@ async def get_chat_history(token: str, chat_id: UUID, before_message_id: int = N params["before_message_id"] = before_message_id try: - async with httpx.AsyncClient(http2=True) as client: - response = await client.get(url, headers=headers, params=params) + response = await authorized_get(url, login=login, access_token=token, headers=headers, params=params) - if response.status_code == 200: - data = response.json() - if data.get("status") == "fine": - response_model = PrivateChatHistoryResponse(**data) - return True, response_model.data - else: - return False, data.get("detail", "Неизвестная ошибка ответа") + if response.status_code == 200: + data = response.json() + if data.get("status") == "fine": + response_model = PrivateChatHistoryResponse(**data) + return True, response_model.data + return False, data.get("detail", localizer.translate("Неизвестная ошибка")) - elif response.status_code in [401, 403]: - error_data = response.json() - return False, error_data.get("detail", "Ошибка аутентификации или доступа") - - elif response.status_code == 422: - return False, "Некорректные параметры запроса" - - else: - return False, f"Ошибка сервера: {response.status_code}" + if response.status_code in [401, 403]: + error_data = response.json() + return False, error_data.get("detail", localizer.translate("Недостаточно прав или неавторизован")) + + if response.status_code == 422: + return False, localizer.translate("Некорректные параметры запроса") + + return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" except httpx.RequestError as e: - return False, f"Ошибка сети: {e}" + return False, f"{localizer.translate('Сетевая ошибка')}: {e}" except Exception as e: - return False, f"Произошла ошибка: {e}" + return False, f"{localizer.translate('Произошла ошибка')}: {e}" -async def send_private_message(token: str, payload: "PrivateMessageSendRequest"): +async def send_private_message(login: str, token: str, payload: "PrivateMessageSendRequest"): """ - Отправляет приватное сообщение в чат. - - :param token: Токен доступа - :param payload: Данные сообщения (Pydantic модель PrivateMessageSendRequest) - :return: Кортеж (успех: bool, данные: PrivateMessageSendData | str) + Отправка приватного сообщения. """ url = f"{config.BASE_URL}/v1/chat/private/send" headers = {"Authorization": f"Bearer {token}"} try: - async with httpx.AsyncClient(http2=True) as client: - response = await client.post(url, headers=headers, json=payload.model_dump(mode='json')) + response = await authorized_post(url, login=login, access_token=token, headers=headers, json=payload.model_dump(mode='json')) - if response.status_code == 200: - data = response.json() - if data.get("status") == "fine": - response_model = PrivateMessageSendResponse(**data) - return True, response_model.data - else: - return False, data.get("detail", "Неизвестная ошибка ответа") + if response.status_code == 200: + data = response.json() + if data.get("status") == "fine": + response_model = PrivateMessageSendResponse(**data) + return True, response_model.data + return False, data.get("detail", localizer.translate("Неизвестная ошибка")) - elif response.status_code in [401, 403, 404]: - error_data = response.json() - print("error_data", error_data) - return False, error_data.get("detail", "Ошибка доступа или чат не найден") - - elif response.status_code == 422: - error_data = response.json() - # Может быть список ошибок - detail = error_data.get("detail") - if isinstance(detail, list): - return False, ", ".join([e.get("msg", "Неизвестная ошибка валидации") for e in detail]) - return False, detail or "Некорректные данные" - - else: - return False, f"Ошибка сервера: {response.status_code}" + if response.status_code in [401, 403, 404]: + error_data = response.json() + return False, error_data.get("detail", localizer.translate("Недостаточно прав или ресурс не найден")) + + if response.status_code == 422: + error_data = response.json() + detail = error_data.get("detail") + if isinstance(detail, list): + return False, ", ".join([e.get("msg", localizer.translate("Некорректные параметры")) for e in detail]) + return False, detail or localizer.translate("Некорректный запрос") + + return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" except httpx.RequestError as e: - return False, f"Ошибка сети: {e}" + return False, f"{localizer.translate('Сетевая ошибка')}: {e}" except Exception as e: - return False, f"Произошла ошибка: {e}" + return False, f"{localizer.translate('Произошла ошибка')}: {e}" diff --git a/app/ui/views/yobble_home_view.py b/app/ui/views/yobble_home_view.py index aa37098..28dfd56 100644 --- a/app/ui/views/yobble_home_view.py +++ b/app/ui/views/yobble_home_view.py @@ -489,7 +489,7 @@ class YobbleHomeView(QWidget): return self.show_notification("Загрузка истории...", is_error=False, duration=1000) - success, data = await get_chat_history(token, chat_id) + success, data = await get_chat_history(self.username, token, chat_id) if success: if self.chat_view: @@ -532,7 +532,7 @@ class YobbleHomeView(QWidget): self.show_notification("Ошибка: сессия не найдена.", is_error=True) return - success, data = await send_private_message(token, payload) + success, data = await send_private_message(self.username, token, payload) if success: # В случае успеха, создаем объект сообщения и добавляем в чат @@ -791,3 +791,4 @@ class YobbleHomeView(QWidget): ); }} """ +