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:
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}'
|
|
||||||
|
|||||||
@@ -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"),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user