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 @@
|
||||
"""
|
||||
Сервис для работы с 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)
|
||||
|
||||
@@ -4,6 +4,7 @@ from .views import (
|
||||
toggle_integration,
|
||||
save_integration_settings,
|
||||
get_integration_form_data,
|
||||
test_integration_connection,
|
||||
)
|
||||
|
||||
app_name = 'integrations'
|
||||
@@ -16,8 +17,5 @@ urlpatterns = [
|
||||
path("toggle/<str:integration_id>/", toggle_integration, name="toggle"),
|
||||
path("settings/<str:integration_id>/", save_integration_settings, name="settings"),
|
||||
path("form/<str:integration_id>/", get_integration_form_data, name="form_data"),
|
||||
|
||||
# TODO: добавить когда понадобится
|
||||
# path("test/<str:integration_id>/", test_connection, name="test_connection"),
|
||||
# path("webhook/<str:integration_type>/", webhook, name="webhook"),
|
||||
path("test/<str:integration_id>/", test_integration_connection, name="test"),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
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 || {});
|
||||
|
||||
// Показать/скрыть кнопку тестирования
|
||||
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-form-container').style.display = 'block';
|
||||
@@ -338,6 +342,39 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user