feat(integrations): добавлена проверка соединения для Recommerce

- Добавлен endpoint /test/<integration_id>/ для тестирования соединений
- RecommerceService упрощён под реальное API (x-auth-token + store_url)
- Кнопка "Проверить подключение" в UI с обработкой статусов
- Миграция для удаления IntegrationConfig и обновления полей

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 00:57:35 +03:00
parent 37394121e1
commit b1b56fbb2e
6 changed files with 212 additions and 127 deletions

View File

@@ -1,142 +1,102 @@
"""
Сервис для работы с Recommerce API.
API документация:
- Запросы отправляются на домен магазина (store_url)
- Авторизация через заголовок x-auth-token
- POST данные передаются как form-data
- Ответы в JSON
"""
import requests
from typing import Tuple
from .base import MarketplaceService
class RecommerceService(MarketplaceService):
"""
Сервис для работы с Recommerce API.
Recommerce - агрегатор маркетплейсов (WB, Ozon, Яндекс и др.)
"""
class RecommerceService:
"""Сервис для работы с Recommerce API"""
API_BASE_URL = "https://api.recommerce.ru" # Пример, нужно уточнить
def __init__(self, integration):
"""
Args:
integration: RecommerceIntegration instance
"""
self.integration = integration
def _get_headers(self) -> dict:
"""Получить заголовки с токеном авторизации"""
headers = super()._get_headers()
if self.config.api_token:
headers['Authorization'] = f'Bearer {self.config.api_token}'
return headers
"""Заголовки для API запросов"""
token = self.integration.api_token or ''
# HTTP заголовки должны быть ASCII-совместимыми
return {
'x-auth-token': token.encode('ascii', 'ignore').decode('ascii'),
'Accept': 'application/json',
}
def _get_api_url(self, path: str) -> str:
"""Получить полный URL для API endpoint"""
base = self.config.api_endpoint or self.API_BASE_URL
return f"{base.rstrip('/')}/{path.lstrip('/')}"
def _get_url(self, path: str) -> str:
"""Полный URL для API endpoint"""
base = self.integration.store_url.rstrip('/')
return f"{base}/api/v1/{path.lstrip('/')}"
def _request(self, method: str, path: str, **kwargs) -> Tuple[bool, dict, str]:
"""
HTTP запрос к API.
Returns:
(success, data, error_message)
"""
url = self._get_url(path)
try:
response = requests.request(
method,
url,
headers=self._get_headers(),
timeout=15,
**kwargs
)
if response.status_code in [200, 201, 204]:
if response.text:
return True, response.json(), ''
return True, {}, ''
else:
return False, {}, f"HTTP {response.status_code}: {response.text[:200]}"
except requests.exceptions.Timeout:
return False, {}, 'Таймаут соединения (15 сек)'
except requests.exceptions.ConnectionError:
return False, {}, 'Не удалось подключиться к серверу'
except Exception as e:
return False, {}, str(e)
def test_connection(self) -> Tuple[bool, str]:
"""
Проверить соединение с Recommerce API.
Returns:
tuple: (success, message)
(success, message)
"""
if not self.config.api_token:
if not self.integration.store_url:
return False, 'Не указан URL магазина'
if not self.integration.api_token:
return False, 'Не указан API токен'
# Проверка соединения через endpoint информации о магазине
if self.config.merchant_id:
url = self._get_api_url(f'/merchants/{self.config.merchant_id}')
else:
url = self._get_api_url('/merchants/me')
url = self.integration.store_url.rstrip('/') + '/api/v1/'
try:
response = requests.get(
url,
headers=self._get_headers(),
timeout=15
)
success, data, error = self._make_request(url)
if response.status_code == 401:
return False, 'Неверный API токен'
if response.status_code == 403:
return False, 'Доступ запрещён'
if success:
merchant_name = data.get('name', 'Магазин')
return True, f'Соединение установлено: {merchant_name}'
else:
return False, f'Ошибка соединения: {error}'
# Любой ответ от сервера = соединение работает
return True, 'Соединение установлено'
def sync(self) -> Tuple[bool, str]:
"""
Выполнить синхронизацию с Recommerce.
Returns:
tuple: (success, message)
"""
if not self.is_available():
return False, 'Интеграция не настроена или отключена'
# TODO: реализовать полную синхронизацию
# - Загрузка товаров с маркетплейсов
# - Обновление цен
# - Обновление остатков
# - Загрузка заказов
return True, 'Синхронизация запущена (заглушка)'
def fetch_products(self) -> Tuple[bool, list, str]:
"""
Получить товары с Recommerce.
Returns:
tuple: (success, products, error_message)
"""
url = self._get_api_url('/products')
success, data, error = self._make_request(url)
if success:
products = data.get('items', [])
return True, products, ''
else:
return False, [], error
def push_product(self, product_data: dict) -> Tuple[bool, str]:
"""
Отправить товар на Recommerce.
Args:
product_data: Данные товара для отправки
Returns:
tuple: (success, message)
"""
url = self._get_api_url('/products')
success, data, error = self._make_request(url, method='POST', json=product_data)
if success:
product_id = data.get('id', '')
return True, f'Товар отправлен: ID={product_id}'
else:
return False, f'Ошибка отправки: {error}'
def update_stock(self, product_id: str, quantity: int) -> Tuple[bool, str]:
"""
Обновить остаток товара.
Args:
product_id: ID товара
quantity: Количество
Returns:
tuple: (success, message)
"""
url = self._get_api_url(f'/products/{product_id}/stock')
success, data, error = self._make_request(
url, method='PATCH', json={'quantity': quantity}
)
if success:
return True, f'Остаток обновлён: {quantity} шт.'
else:
return False, f'Ошибка обновления: {error}'
def update_price(self, product_id: str, price: float) -> Tuple[bool, str]:
"""
Обновить цену товара.
Args:
product_id: ID товара
price: Новая цена
Returns:
tuple: (success, message)
"""
url = self._get_api_url(f'/products/{product_id}/price')
success, data, error = self._make_request(
url, method='PATCH', json={'price': price}
)
if success:
return True, f'Цена обновлена: {price} руб.'
else:
return False, f'Ошибка обновления: {error}'
except requests.exceptions.Timeout:
return False, 'Таймаут соединения (15 сек)'
except requests.exceptions.ConnectionError:
return False, 'Не удалось подключиться к серверу'
except Exception as e:
return False, str(e)