Compare commits

..

No commits in common. "22b6fd272fa3445959f5b78f39dd9db4954fa7f1" and "54e4d3542e95f0cc238f58199510e5066eaffa86" have entirely different histories.

6 changed files with 201 additions and 246 deletions

View File

@ -105,7 +105,7 @@ class MainController(QStackedWidget):
# return # return
token = session['access_token'] token = session['access_token']
success, data = await get_private_chats(login=username, token=token, offset=0, limit=50) success, data = await get_private_chats(token=token, offset=0, limit=50)
if success: if success:
# Отправляем данные в основной поток через сигнал # Отправляем данные в основной поток через сигнал
@ -113,5 +113,3 @@ class MainController(QStackedWidget):
else: else:
# Отправляем ошибку в основной поток через сигнал # Отправляем ошибку в основной поток через сигнал
self.notification_requested.emit(f"Не удалось загрузить чаты: {data}", True) self.notification_requested.emit(f"Не удалось загрузить чаты: {data}", True)
print("data", data)

View File

@ -1,92 +0,0 @@
import httpx
from contextvars import ContextVar
from typing import Dict, Any, Optional
# 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)
# 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

View File

@ -3,61 +3,62 @@ import asyncio
from app.core import config from app.core import config
from app.core.database import add_session, logout, get_session from app.core.database import add_session, logout, get_session
from app.core.localizer import localizer from app.core.localizer import localizer
from app.core.http_client import get_client, authorized_get
async def login(login, password): async def login(login, password):
""" """
Логин пользователя по логину и паролю. Отправляет запрос на аутентификацию и в случае успеха сохраняет сессию.
:param login: Логин пользователя :param login: Логин пользователя
:param password: Пароль пользователя :param password: Пароль пользователя
:return: tuple (ok: bool, message: str) :return: Кортеж (успех: bool, сообщение: str)
""" """
url = f"{config.BASE_URL}/v1/auth/login" url = f"{config.BASE_URL}/v1/auth/login"
try: try:
response = await get_client().post(url, json={"login": login, "password": password}) async with httpx.AsyncClient(http2=True) as client:
response = await client.post(url, json={"login": login, "password": password})
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
if data.get("status") == "fine": if data.get("status") == "fine":
token_data = data["data"] token_data = data["data"]
add_session( add_session(
login=login, login=login,
access_token=token_data["access_token"], access_token=token_data["access_token"],
refresh_token=token_data["refresh_token"], refresh_token=token_data["refresh_token"],
user_id=token_data.get("user_id"), user_id=token_data["user_id"]
) )
return True, localizer.translate("Вход выполнен успешно") return True, localizer.translate("Успешный вход")
return False, data.get("detail", 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 == 401: elif response.status_code in [403]:
error_data = response.json() error_data = response.json()
return False, error_data.get("detail", localizer.translate("Неверный логин или пароль")) return False, error_data.get("detail", localizer.translate("Учетная запись пользователя отключена"))
if response.status_code == 403: elif response.status_code == 422:
error_data = response.json() return False, localizer.translate("Некорректные данные для входа")
return False, error_data.get("detail", localizer.translate("Доступ запрещен"))
if response.status_code == 422: else:
return False, localizer.translate("Некорректные входные данные") return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}"
return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}"
except httpx.RequestError as e: except httpx.RequestError as e:
return False, f"{localizer.translate('Сетевая ошибка')}: {e}" return False, f"{localizer.translate('Ошибка сети')}: {e}"
except Exception as e: except Exception as e:
return False, f"{localizer.translate('Произошла ошибка')}: {e}" return False, f"{localizer.translate('Произошла ошибка')}: {e}"
async def register(login, password, invite=None): async def register(login, password, invite=None):
""" """
Регистрация нового пользователя. Отправляет запрос на регистрацию нового пользователя.
:param login: Логин :param login: Логин
:param password: Пароль :param password: Пароль
:param invite: Инвайт-код (опционально) :param invite: Инвайт-код (опционально)
:return: tuple (ok: bool, message: str) :return: Кортеж (успех: bool, сообщение: str)
""" """
url = f"{config.BASE_URL}/v1/auth/register" url = f"{config.BASE_URL}/v1/auth/register"
payload = {"login": login, "password": password} payload = {"login": login, "password": password}
@ -65,90 +66,116 @@ async def register(login, password, invite=None):
payload["invite"] = invite payload["invite"] = invite
try: try:
response = await get_client().post(url, json=payload) async with httpx.AsyncClient(http2=True) as client:
response = await client.post(url, json=payload)
if response.status_code == 201: if response.status_code == 201:
return True, localizer.translate("Регистрация прошла успешно!") return True, localizer.translate("Регистрация прошла успешно!")
error_data = response.json()
error_message = error_data.get("detail", localizer.translate("Произошла ошибка"))
error_data = response.json() if response.status_code == 409:
error_message = error_data.get("detail", localizer.translate("Произошла ошибка")) return False, localizer.translate("Этот логин уже занят.")
elif response.status_code == 400:
if response.status_code == 409: return False, localizer.translate("Неверный инвайт-код.")
return False, localizer.translate("Пользователь уже существует.") elif response.status_code == 403:
if response.status_code == 400: return False, localizer.translate("Регистрация в данный момент отключена.")
return False, localizer.translate("Некорректный запрос.") elif response.status_code == 422:
if response.status_code == 403: return False, localizer.translate("Данные не прошли валидацию. Проверьте длину логина и пароля.")
return False, localizer.translate("Регистрация по приглашению отключена.") else:
if response.status_code == 422: return False, f"{localizer.translate('Ошибка сервера')} ({response.status_code}): {error_message}"
return False, localizer.translate("Некорректные входные данные. Проверьте логин и пароль.")
return False, f"{localizer.translate('Ошибка сервера')} ({response.status_code}): {error_message}"
except httpx.RequestError as e: except httpx.RequestError as e:
return False, f"{localizer.translate('Сетевая ошибка')}: {e}" return False, f"{localizer.translate('Ошибка сети')}: {e}"
except Exception as e: except Exception as e:
return False, f"{localizer.translate('Произошла ошибка')}: {e}" return False, f"{localizer.translate('Произошла ошибка')}: {e}"
async def refresh_token(access_token: str, refresh_token: str): async def refresh_token(access_token: str, refresh_token: str):
""" """
Обновление пары токенов по refresh_token. Обновляет токен доступа, используя токен обновления.
:param access_token: Текущий access token :param access_token: Истекший токен доступа
:param refresh_token: Refresh token :param refresh_token: Токен обновления
:return: tuple (ok: bool, data: dict | str) :return: Кортеж (успех: bool, данные: dict | str)
""" """
url = f"{config.BASE_URL}/v1/auth/token/refresh" url = f"{config.BASE_URL}/v1/auth/token/refresh"
payload = {"access_token": access_token, "refresh_token": refresh_token} payload = {"access_token": access_token, "refresh_token": refresh_token}
try: try:
response = await get_client().post(url, json=payload) async with httpx.AsyncClient(http2=True) as client:
response = await client.post(url, json=payload)
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
if data.get("status") == "fine": if data.get("status") == "fine":
token_data = data["data"] token_data = data["data"]
add_session( # Обновляем сессию с новыми токенами
login=None, add_session(
access_token=token_data["access_token"], login=None, # Логин не требуется для обновления
refresh_token=token_data["refresh_token"], access_token=token_data["access_token"],
update_existing=True, refresh_token=token_data["refresh_token"],
) update_existing=True
return True, token_data )
return False, data.get("detail", localizer.translate("Неизвестная ошибка")) return True, token_data
else:
return False, data.get("detail", localizer.translate("Unknown error"))
if response.status_code == 401: elif response.status_code == 401:
return False, localizer.translate("Refresh token недействителен или истек") return False, localizer.translate("Refresh token is invalid or expired")
return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" else:
return False, f"{localizer.translate('Server error')}: {response.status_code}"
except httpx.RequestError as e: except httpx.RequestError as e:
return False, f"{localizer.translate('Сетевая ошибка')}: {e}" return False, f"{localizer.translate('Network error')}: {e}"
except Exception as e: except Exception as e:
return False, f"{localizer.translate('Произошла ошибка')}: {e}" return False, f"{localizer.translate('An error occurred')}: {e}"
async def get_user_role(access_token: str, login: str): async def get_user_role(access_token: str, login: str):
""" """
Получение роли пользователя. При 401 выполняется автообновление токена внутри authorized_get. Получает роль и права пользователя по токену доступа.
В случае истечения срока действия токена, пытается его обновить.
""" """
url = f"{config.BASE_URL}/v1/user/role" url = f"{config.BASE_URL}/v1/user/role"
headers = {"Authorization": f"Bearer {access_token}"} headers = {"Authorization": f"Bearer {access_token}"}
try: try:
response = await authorized_get(url, login=login, access_token=access_token, headers=headers) async with httpx.AsyncClient(http2=True) as client:
response = await client.get(url, headers=headers)
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
return (True, data['data']) if data.get("status") == "fine" else (False, data.get("detail", localizer.translate("Неизвестная ошибка"))) return (True, data['data']) if data.get("status") == "fine" else (False, data.get("detail", localizer.translate("Unknown error")))
if response.status_code == 401: elif response.status_code == 401:
return False, localizer.translate("Сессия истекла, войдите снова") # Токен истек, пытаемся обновить
session = get_session(login)
if not session or not session['refresh_token']:
return False, localizer.translate("No refresh token found")
return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" 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}"
except httpx.RequestError as e: except httpx.RequestError as e:
return False, f"{localizer.translate('Сетевая ошибка')}: {e}" return False, f"{localizer.translate('Network error')}: {e}"
except Exception as e: except Exception as e:
return False, f"{localizer.translate('Произошла ошибка')}: {e}" return False, f"{localizer.translate('An error occurred')}: {e}"

View File

@ -7,49 +7,60 @@ from app.core.models.chat_models import (
PrivateMessageSendRequest, PrivateMessageSendResponse PrivateMessageSendRequest, PrivateMessageSendResponse
) )
from uuid import UUID 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):
async def get_private_chats(login: str, token: str, offset: int = 0, limit: int = 20):
""" """
Получить список приватных чатов. Получает список приватных чатов пользователя.
:param token: Bearer токен :param token: Токен доступа пользователя
:param offset: Смещение :param offset: Смещение для пагинации
:param limit: Количество :param limit: Количество чатов для загрузки
:return: tuple (ok: bool, data: PrivateChatListData | str) :return: Кортеж (успех: bool, данные: PrivateChatListData | str)
""" """
# TODO: Добавить логику обновления токена, как в auth_service.py
url = f"{config.BASE_URL}/v1/chat/private/list" url = f"{config.BASE_URL}/v1/chat/private/list"
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
params = {"offset": offset, "limit": limit} params = {"offset": offset, "limit": limit}
try: try:
response = await authorized_get(url, login=login, access_token=token, headers=headers, params=params) async with httpx.AsyncClient(http2=True) as client:
if response.status_code == 200: response = await client.get(url, headers=headers, params=params)
data = response.json() print("response.status_code", response.status_code)
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 in [401, 403]: if response.status_code == 200:
error_data = response.json() data = response.json()
return False, error_data.get("detail", localizer.translate("Недостаточно прав или неавторизован")) if data.get("status") == "fine":
# Используем Pydantic модель для парсинга ответа
response_model = PrivateChatListResponse(**data)
print("response_model.data", response_model.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 == 422: elif response.status_code == 422:
return False, localizer.translate("Некорректные параметры запроса") return False, localizer.translate("Некорректные параметры запроса")
return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" else:
return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}"
except httpx.RequestError as e: except httpx.RequestError as e:
return False, f"{localizer.translate('Сетевая ошибка')}: {e}" return False, f"{localizer.translate('Ошибка сети')}: {e}"
except Exception as e: except Exception as e:
return False, f"{localizer.translate('Произошла ошибка')}: {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):
async def get_chat_history(login: str, 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)
""" """
url = f"{config.BASE_URL}/v1/chat/private/history" url = f"{config.BASE_URL}/v1/chat/private/history"
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
@ -58,61 +69,73 @@ async def get_chat_history(login: str, token: str, chat_id: UUID, before_message
params["before_message_id"] = before_message_id params["before_message_id"] = before_message_id
try: try:
response = await authorized_get(url, login=login, access_token=token, headers=headers, params=params) async with httpx.AsyncClient(http2=True) as client:
response = await client.get(url, headers=headers, params=params)
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
if data.get("status") == "fine": if data.get("status") == "fine":
response_model = PrivateChatHistoryResponse(**data) response_model = PrivateChatHistoryResponse(**data)
return True, response_model.data return True, response_model.data
return False, data.get("detail", localizer.translate("Неизвестная ошибка")) else:
return False, data.get("detail", "Неизвестная ошибка ответа")
if response.status_code in [401, 403]: elif response.status_code in [401, 403]:
error_data = response.json() error_data = response.json()
return False, error_data.get("detail", localizer.translate("Недостаточно прав или неавторизован")) return False, error_data.get("detail", "Ошибка аутентификации или доступа")
if response.status_code == 422: elif response.status_code == 422:
return False, localizer.translate("Некорректные параметры запроса") return False, "Некорректные параметры запроса"
return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" else:
return False, f"Ошибка сервера: {response.status_code}"
except httpx.RequestError as e: except httpx.RequestError as e:
return False, f"{localizer.translate('Сетевая ошибка')}: {e}" return False, f"Ошибка сети: {e}"
except Exception as e: except Exception as e:
return False, f"{localizer.translate('Произошла ошибка')}: {e}" return False, f"Произошла ошибка: {e}"
async def send_private_message(login: str, token: str, payload: "PrivateMessageSendRequest"): async def send_private_message(token: str, payload: "PrivateMessageSendRequest"):
""" """
Отправка приватного сообщения. Отправляет приватное сообщение в чат.
:param token: Токен доступа
:param payload: Данные сообщения (Pydantic модель PrivateMessageSendRequest)
:return: Кортеж (успех: bool, данные: PrivateMessageSendData | str)
""" """
url = f"{config.BASE_URL}/v1/chat/private/send" url = f"{config.BASE_URL}/v1/chat/private/send"
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
try: try:
response = await authorized_post(url, login=login, access_token=token, headers=headers, json=payload.model_dump(mode='json')) async with httpx.AsyncClient(http2=True) as client:
response = await client.post(url, headers=headers, json=payload.model_dump(mode='json'))
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
if data.get("status") == "fine": if data.get("status") == "fine":
response_model = PrivateMessageSendResponse(**data) response_model = PrivateMessageSendResponse(**data)
return True, response_model.data return True, response_model.data
return False, data.get("detail", localizer.translate("Неизвестная ошибка")) else:
return False, data.get("detail", "Неизвестная ошибка ответа")
if response.status_code in [401, 403, 404]: elif response.status_code in [401, 403, 404]:
error_data = response.json() error_data = response.json()
return False, error_data.get("detail", localizer.translate("Недостаточно прав или ресурс не найден")) print("error_data", error_data)
return False, error_data.get("detail", "Ошибка доступа или чат не найден")
if response.status_code == 422:
error_data = response.json() elif response.status_code == 422:
detail = error_data.get("detail") error_data = response.json()
if isinstance(detail, list): # Может быть список ошибок
return False, ", ".join([e.get("msg", localizer.translate("Некорректные параметры")) for e in detail]) detail = error_data.get("detail")
return False, detail or localizer.translate("Некорректный запрос") if isinstance(detail, list):
return False, ", ".join([e.get("msg", "Неизвестная ошибка валидации") for e in detail])
return False, f"{localizer.translate('Ошибка сервера')}: {response.status_code}" return False, detail or "Некорректные данные"
else:
return False, f"Ошибка сервера: {response.status_code}"
except httpx.RequestError as e: except httpx.RequestError as e:
return False, f"{localizer.translate('Сетевая ошибка')}: {e}" return False, f"Ошибка сети: {e}"
except Exception as e: except Exception as e:
return False, f"{localizer.translate('Произошла ошибка')}: {e}" return False, f"Произошла ошибка: {e}"

View File

@ -489,7 +489,7 @@ class YobbleHomeView(QWidget):
return return
self.show_notification("Загрузка истории...", is_error=False, duration=1000) self.show_notification("Загрузка истории...", is_error=False, duration=1000)
success, data = await get_chat_history(self.username, token, chat_id) success, data = await get_chat_history(token, chat_id)
if success: if success:
if self.chat_view: if self.chat_view:
@ -532,7 +532,7 @@ class YobbleHomeView(QWidget):
self.show_notification("Ошибка: сессия не найдена.", is_error=True) self.show_notification("Ошибка: сессия не найдена.", is_error=True)
return return
success, data = await send_private_message(self.username, token, payload) success, data = await send_private_message(token, payload)
if success: if success:
# В случае успеха, создаем объект сообщения и добавляем в чат # В случае успеха, создаем объект сообщения и добавляем в чат
@ -791,4 +791,3 @@ class YobbleHomeView(QWidget):
); );
}} }}
""" """

View File

@ -13,7 +13,7 @@ class MainWindow(QMainWindow):
super().__init__() super().__init__()
self.setWindowTitle(config.APP_HEADER) self.setWindowTitle(config.APP_HEADER)
self.setMinimumSize(400, 650) self.setMinimumSize(400, 650)
self.controller = MainController() self.controller = MainController()
self.setCentralWidget(self.controller) self.setCentralWidget(self.controller)