From b1b56fbb2e10117ab9d7cca3b5e8a2ec2bf6a0e3 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Mon, 12 Jan 2026 00:57:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(integrations):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20Recommerce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен endpoint /test// для тестирования соединений - RecommerceService упрощён под реальное API (x-auth-token + store_url) - Кнопка "Проверить подключение" в UI с обработкой статусов - Миграция для удаления IntegrationConfig и обновления полей Co-Authored-By: Claude Opus 4.5 --- ...ecommerceintegration_api_token_and_more.py | 29 +++ .../services/marketplaces/recommerce.py | 206 +++++++----------- myproject/integrations/urls.py | 6 +- myproject/integrations/views.py | 45 ++++ .../0002_delete_integrationconfig.py | 16 ++ .../system_settings/integrations.html | 37 ++++ 6 files changed, 212 insertions(+), 127 deletions(-) create mode 100644 myproject/integrations/migrations/0002_alter_recommerceintegration_api_token_and_more.py create mode 100644 myproject/system_settings/migrations/0002_delete_integrationconfig.py diff --git a/myproject/integrations/migrations/0002_alter_recommerceintegration_api_token_and_more.py b/myproject/integrations/migrations/0002_alter_recommerceintegration_api_token_and_more.py new file mode 100644 index 0000000..74d2e82 --- /dev/null +++ b/myproject/integrations/migrations/0002_alter_recommerceintegration_api_token_and_more.py @@ -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'), + ), + ] diff --git a/myproject/integrations/services/marketplaces/recommerce.py b/myproject/integrations/services/marketplaces/recommerce.py index ad09f18..f2e76cc 100644 --- a/myproject/integrations/services/marketplaces/recommerce.py +++ b/myproject/integrations/services/marketplaces/recommerce.py @@ -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) diff --git a/myproject/integrations/urls.py b/myproject/integrations/urls.py index eb1c21a..7e59437 100644 --- a/myproject/integrations/urls.py +++ b/myproject/integrations/urls.py @@ -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//", toggle_integration, name="toggle"), path("settings//", save_integration_settings, name="settings"), path("form//", get_integration_form_data, name="form_data"), - - # TODO: добавить когда понадобится - # path("test//", test_connection, name="test_connection"), - # path("webhook//", webhook, name="webhook"), + path("test//", test_integration_connection, name="test"), ] diff --git a/myproject/integrations/views.py b/myproject/integrations/views.py index 1597be3..cd27012 100644 --- a/myproject/integrations/views.py +++ b/myproject/integrations/views.py @@ -103,6 +103,51 @@ def toggle_integration(request, integration_id: str): }) +@require_POST +def test_integration_connection(request, integration_id: str): + """ + Тестировать соединение с интеграцией. + POST /integrations/test// + """ + 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): """ diff --git a/myproject/system_settings/migrations/0002_delete_integrationconfig.py b/myproject/system_settings/migrations/0002_delete_integrationconfig.py new file mode 100644 index 0000000..f71f27e --- /dev/null +++ b/myproject/system_settings/migrations/0002_delete_integrationconfig.py @@ -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', + ), + ] diff --git a/myproject/system_settings/templates/system_settings/integrations.html b/myproject/system_settings/templates/system_settings/integrations.html index 4c875ac..61a3969 100644 --- a/myproject/system_settings/templates/system_settings/integrations.html +++ b/myproject/system_settings/templates/system_settings/integrations.html @@ -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 = 'Проверка...'; + + 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; + } + }); }); {% endblock %}