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

@@ -0,0 +1,29 @@
# Generated by Django 5.0.10 on 2026-01-11 21:41
import integrations.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('integrations', '0001_add_integration_models'),
]
operations = [
migrations.AlterField(
model_name='recommerceintegration',
name='api_token',
field=integrations.fields.EncryptedCharField(blank=True, help_text='Токен авторизации из панели управления Recommerce', max_length=2000, verbose_name='API Токен (x-auth-token)'),
),
migrations.AlterField(
model_name='woocommerceintegration',
name='consumer_key',
field=integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Key (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Key'),
),
migrations.AlterField(
model_name='woocommerceintegration',
name='consumer_secret',
field=integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Secret (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Secret'),
),
]

View File

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

View File

@@ -4,6 +4,7 @@ from .views import (
toggle_integration, toggle_integration,
save_integration_settings, save_integration_settings,
get_integration_form_data, get_integration_form_data,
test_integration_connection,
) )
app_name = 'integrations' app_name = 'integrations'
@@ -16,8 +17,5 @@ urlpatterns = [
path("toggle/<str:integration_id>/", toggle_integration, name="toggle"), path("toggle/<str:integration_id>/", toggle_integration, name="toggle"),
path("settings/<str:integration_id>/", save_integration_settings, name="settings"), path("settings/<str:integration_id>/", save_integration_settings, name="settings"),
path("form/<str:integration_id>/", get_integration_form_data, name="form_data"), path("form/<str:integration_id>/", get_integration_form_data, name="form_data"),
path("test/<str:integration_id>/", test_integration_connection, name="test"),
# TODO: добавить когда понадобится
# path("test/<str:integration_id>/", test_connection, name="test_connection"),
# path("webhook/<str:integration_type>/", webhook, name="webhook"),
] ]

View File

@@ -103,6 +103,51 @@ def toggle_integration(request, integration_id: str):
}) })
@require_POST
def test_integration_connection(request, integration_id: str):
"""
Тестировать соединение с интеграцией.
POST /integrations/test/<integration_id>/
"""
if not hasattr(request, 'user') or not request.user.is_authenticated:
return JsonResponse({'error': 'Unauthorized'}, status=401)
model = get_integration_model(integration_id)
if not model:
return JsonResponse({'error': f'Unknown integration: {integration_id}'}, status=404)
instance = model.objects.first()
if not instance:
return JsonResponse({'error': 'Интеграция не настроена'}, status=400)
if not instance.is_configured:
return JsonResponse({'error': 'Заполните настройки интеграции'}, status=400)
# Получить сервис для интеграции
service = get_integration_service(integration_id, instance)
if not service:
return JsonResponse({'error': 'Сервис не реализован'}, status=501)
# Выполнить тест
success, message = service.test_connection()
return JsonResponse({
'success': success,
'message': message,
})
def get_integration_service(integration_id: str, instance):
"""Получить сервис для интеграции"""
if integration_id == 'recommerce':
from .services.marketplaces.recommerce import RecommerceService
return RecommerceService(instance)
elif integration_id == 'woocommerce':
# TODO: WooCommerceService
return None
return None
@require_POST @require_POST
def save_integration_settings(request, integration_id: str): def save_integration_settings(request, integration_id: str):
""" """

View File

@@ -0,0 +1,16 @@
# Generated by Django 5.0.10 on 2026-01-11 21:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('system_settings', '0001_initial'),
]
operations = [
migrations.DeleteModel(
name='IntegrationConfig',
),
]

View File

@@ -156,6 +156,10 @@ document.addEventListener('DOMContentLoaded', function() {
// Построить форму // Построить форму
buildForm(data.fields, data.data || {}); buildForm(data.fields, data.data || {});
// Показать/скрыть кнопку тестирования
const testBtn = document.getElementById('test-connection-btn');
testBtn.style.display = data.is_configured ? 'inline-block' : 'none';
// Показать форму // Показать форму
document.getElementById('settings-loading').style.display = 'none'; document.getElementById('settings-loading').style.display = 'none';
document.getElementById('settings-form-container').style.display = 'block'; document.getElementById('settings-form-container').style.display = 'block';
@@ -338,6 +342,39 @@ document.addEventListener('DOMContentLoaded', function() {
showToast('Ошибка', 'Ошибка сети', true); showToast('Ошибка', 'Ошибка сети', true);
} }
}); });
// Тестирование соединения
document.getElementById('test-connection-btn').addEventListener('click', async function() {
if (!currentIntegration) return;
const btn = this;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Проверка...';
try {
const response = await fetch(`/settings/integrations/test/${currentIntegration}/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
showToast('Успех', data.message);
} else {
showToast('Ошибка', data.message || data.error, true);
}
} catch (error) {
showToast('Ошибка', 'Ошибка сети', true);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
});
}); });
</script> </script>
{% endblock %} {% endblock %}