diff --git a/myproject/integrations/fields.py b/myproject/integrations/fields.py index 38d4a56..c4be8bb 100644 --- a/myproject/integrations/fields.py +++ b/myproject/integrations/fields.py @@ -26,12 +26,22 @@ class EncryptedCharField(models.CharField): description = "Encrypted CharField using Fernet" def __init__(self, *args, **kwargs): + # Сохраняем оригинальный max_length для deconstruct() + self._original_max_length = kwargs.get('max_length') # Зашифрованные данные длиннее исходных, увеличиваем max_length if 'max_length' in kwargs: # Fernet добавляет ~100 байт overhead kwargs['max_length'] = max(kwargs['max_length'] * 2, 500) super().__init__(*args, **kwargs) + def deconstruct(self): + """Возвращаем оригинальные параметры для миграций""" + name, path, args, kwargs = super().deconstruct() + # Восстанавливаем оригинальный max_length + if self._original_max_length is not None: + kwargs['max_length'] = self._original_max_length + return name, path, args, kwargs + def _get_fernet(self): """Получить инстанс Fernet с ключом из settings""" key = getattr(settings, 'ENCRYPTION_KEY', None) diff --git a/myproject/integrations/migrations/0001_add_integration_models.py b/myproject/integrations/migrations/0001_add_integration_models.py index 3bf6ebb..f9b1932 100644 --- a/myproject/integrations/migrations/0001_add_integration_models.py +++ b/myproject/integrations/migrations/0001_add_integration_models.py @@ -25,7 +25,7 @@ class Migration(migrations.Migration): ('store_url', models.URLField(blank=True, help_text='Адрес магазина (например, https://shop.example.com)', verbose_name='URL магазина')), ('auto_sync_products', models.BooleanField(default=False, help_text='Автоматически обновлять товары на маркетплейсе', verbose_name='Авто-синхронизация товаров')), ('import_orders', models.BooleanField(default=False, help_text='Импортировать заказы с маркетплейса', verbose_name='Импорт заказов')), - ('api_token', integrations.fields.EncryptedCharField(blank=True, help_text='Токен авторизации из панели управления Recommerce', max_length=2000, verbose_name='API Токен (x-auth-token)')), + ('api_token', integrations.fields.EncryptedCharField(blank=True, help_text='Токен авторизации из панели управления Recommerce', max_length=500, verbose_name='API Токен (x-auth-token)')), ], options={ 'verbose_name': 'Recommerce', @@ -45,8 +45,8 @@ class Migration(migrations.Migration): ('store_url', models.URLField(blank=True, help_text='Адрес магазина (например, https://shop.example.com)', verbose_name='URL магазина')), ('auto_sync_products', models.BooleanField(default=False, help_text='Автоматически обновлять товары на маркетплейсе', verbose_name='Авто-синхронизация товаров')), ('import_orders', models.BooleanField(default=False, help_text='Импортировать заказы с маркетплейса', verbose_name='Импорт заказов')), - ('consumer_key', integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Key (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Key')), - ('consumer_secret', integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Secret (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Secret')), + ('consumer_key', integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Key (хранится зашифрованным)', max_length=255, verbose_name='Consumer Key')), + ('consumer_secret', integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Secret (хранится зашифрованным)', max_length=255, verbose_name='Consumer Secret')), ('api_version', models.CharField(blank=True, default='v3', help_text='Версия WooCommerce REST API', max_length=10, verbose_name='Версия API')), ], options={ 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 deleted file mode 100644 index 74d2e82..0000000 --- a/myproject/integrations/migrations/0002_alter_recommerceintegration_api_token_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# 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/migrations/0003_alter_recommerceintegration_api_token_and_more.py b/myproject/integrations/migrations/0003_alter_recommerceintegration_api_token_and_more.py deleted file mode 100644 index 96e93c0..0000000 --- a/myproject/integrations/migrations/0003_alter_recommerceintegration_api_token_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.0.10 on 2026-01-11 23:29 - -import integrations.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('integrations', '0002_alter_recommerceintegration_api_token_and_more'), - ] - - 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/recommerce/client.py b/myproject/integrations/recommerce/client.py index 831ce94..412cf40 100644 --- a/myproject/integrations/recommerce/client.py +++ b/myproject/integrations/recommerce/client.py @@ -1,3 +1,4 @@ +import logging import requests from typing import Dict, Any, Optional, List from .exceptions import ( @@ -6,6 +7,8 @@ from .exceptions import ( RecommerceAPIError ) +logger = logging.getLogger(__name__) + class RecommerceClient: """ @@ -26,12 +29,13 @@ class RecommerceClient: token = self.api_token.encode('ascii', 'ignore').decode('ascii') except (AttributeError, ValueError): token = '' - + return { 'x-auth-token': token, 'Accept': 'application/json', - 'Content-Type': 'application/json', # В доках сказано form-data для POST, но обычно JSON тоже работает. Проверим. - # Если API строго требует form-data, то requests сам поставит нужный Content-Type, если передать files или data вместо json + # НЕ указываем Content-Type - requests сам поставит правильный: + # - application/x-www-form-urlencoded для data=... + # - application/json для json=... } def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: @@ -79,6 +83,7 @@ class RecommerceClient: if response.status_code in [401, 403]: raise RecommerceAuthError(f"Auth Error: {response.status_code}") + logger.error(f"Recommerce API error {response.status_code}: {response.text}") raise RecommerceAPIError( status_code=response.status_code, message=f"API Request failed: {url}", @@ -97,13 +102,16 @@ class RecommerceClient: return self._request('GET', f'catalog/products/{sku}') def update_product(self, sku: str, data: Dict[str, Any]) -> Dict[str, Any]: - """Обновить товар""" - # В документации POST используется для обновления - return self._request('POST', f'catalog/products/{sku}', json=data) + """Обновить товар (использует form-data как указано в документации)""" + # Recommerce API требует form-data для POST запросов + # data должен быть плоским словарём с ключами вида price[amount], price[currency] + logger.info(f"Recommerce update_product {sku}: {data}") + return self._request('POST', f'catalog/products/{sku}', data=data) def create_product(self, data: Dict[str, Any]) -> Dict[str, Any]: - """Создать товар""" - return self._request('POST', 'catalog/products', json=data) + """Создать товар (использует form-data как указано в документации)""" + # Recommerce API требует form-data для POST запросов + return self._request('POST', 'catalog/products', data=data) def get_orders(self, updated_after: Optional[str] = None) -> List[Dict[str, Any]]: """ diff --git a/myproject/integrations/recommerce/mappers.py b/myproject/integrations/recommerce/mappers.py index 9b49998..b13ad98 100644 --- a/myproject/integrations/recommerce/mappers.py +++ b/myproject/integrations/recommerce/mappers.py @@ -2,63 +2,90 @@ from typing import Dict, Any, List, Optional from decimal import Decimal -def to_api_product(product: Any, stock_count: Optional[int] = None, fields: Optional[List[str]] = None) -> Dict[str, Any]: +def to_api_product( + product: Any, + stock_count: Optional[int] = None, + fields: Optional[List[str]] = None, + for_create: bool = False +) -> Dict[str, Any]: """ Преобразование внутреннего товара в формат Recommerce API. - + + Recommerce API использует form-data для POST запросов, поэтому вложенные поля + представляются в плоском формате: price[amount], price[currency] + Args: product: Экземпляр модели Product stock_count: Остаток товара (вычисляется отдельно, т.к. зависит от склада) fields: Список полей для экспорта. Если None - экспортируются все базовые поля. Поддерживаемые поля: 'sku', 'name', 'price', 'description', 'count', 'images'. - + for_create: Если True - включает все обязательные поля для создания товара. + Returns: - Dict: JSON-совместимый словарь для API + Dict: Плоский словарь для form-data запроса """ data = {} - + # Если поля не указаны, берем базовый набор (без тяжелых полей типа картинок) if fields is None: fields = ['sku', 'name', 'price'] - + + # Для создания товара добавляем все обязательные поля + if for_create: + fields = list(set(fields) | {'sku', 'name', 'price', 'parent_category_sku'}) + # SKU (Артикул) - обязательное поле для идентификации - # Если product.sku нет, используем id (как fallback, но лучше sku) sku = getattr(product, 'sku', str(product.id)) - - # Всегда добавляем SKU если это не обновление, но для update метода API SKU идет в URL. - # Но маппер просто готовит данные. - + if 'sku' in fields: data['sku'] = sku - + if 'name' in fields: data['name'] = product.name - + + if 'parent_category_sku' in fields: + # TODO: Добавить поле recommerce_category_sku в модель Product или Category + # Пока пытаемся взять из атрибута, если он есть + category_sku = getattr(product, 'recommerce_category_sku', None) + if category_sku: + data['parent_category_sku'] = category_sku + if 'price' in fields: - # Recommerce ожидает price[amount] и price[currency] - # Предполагаем, что валюта магазина совпадает с валютой Recommerce (BYN) - data['price'] = { - 'amount': float(product.sale_price or 0), - 'currency': 'BYN' # Можно вынести в настройки - } - + # Recommerce ожидает price[amount] и price[currency] в form-data формате + # Значения передаём как строки (так работает в проверенном скрипте) + has_discount = product.sale_price and product.price and product.sale_price < product.price + + if has_discount: + # Есть скидка: текущая цена = sale_price, старая = price + data['price[amount]'] = str(float(product.sale_price)) + data['price[currency]'] = 'BYN' + # price_old - как в рабочем скрипте (не old_price из документации) + data['price_old[amount]'] = str(float(product.price)) + data['price_old[currency]'] = 'BYN' + # TODO: is_special='true' принимается API но игнорируется при обновлении. + # Возможно работает только при создании товара. + else: + # Нет скидки: только основная цена + data['price[amount]'] = str(float(product.price or 0)) + data['price[currency]'] = 'BYN' + + if 'content' in fields: + # content включает название и описание + data['name'] = product.name + data['description'] = product.description or '' + if 'description' in fields: data['description'] = product.description or '' - + if 'count' in fields and stock_count is not None: data['count'] = stock_count - + if 'images' in fields: - # Примерная логика для картинок - # Recommerce ожидает массив URL или объектов. - # Документация: images[] - Картинки товара - images = [] - if hasattr(product, 'images'): - for img in product.images.all(): - if img.image: - images.append(img.image.url) - if images: - data['images'] = images + # Recommerce ожидает images[] для массива картинок + if hasattr(product, 'photos'): + for idx, photo in enumerate(product.photos.all()): + if photo.image: + data[f'images[{idx}]'] = photo.image.url return data diff --git a/myproject/integrations/recommerce/services.py b/myproject/integrations/recommerce/services.py index 9d730f9..78a533a 100644 --- a/myproject/integrations/recommerce/services.py +++ b/myproject/integrations/recommerce/services.py @@ -67,12 +67,26 @@ class RecommerceService: raise e def create_product(self, product: Any) -> bool: - """Создать товар в Recommerce""" + """ + Создать товар в Recommerce. + + Raises: + RecommerceError: Если отсутствует обязательное поле parent_category_sku + """ # Для создания нужны все поля stock = product.stocks.first() stock_count = int(stock.quantity_free) if stock else 0 - - data = to_api_product(product, stock_count=stock_count, fields=None) + + data = to_api_product(product, stock_count=stock_count, fields=None, for_create=True) + + # Проверяем обязательное поле для создания + if 'parent_category_sku' not in data: + raise RecommerceError( + f"Невозможно создать товар '{product.sku}' в Recommerce: " + f"не указана категория (parent_category_sku). " + f"Добавьте поле recommerce_category_sku к товару или категории." + ) + self.client.create_product(data) return True diff --git a/myproject/integrations/recommerce/tasks.py b/myproject/integrations/recommerce/tasks.py new file mode 100644 index 0000000..96c6367 --- /dev/null +++ b/myproject/integrations/recommerce/tasks.py @@ -0,0 +1,119 @@ +import logging +from celery import shared_task +from django.db import transaction +from django_tenants.utils import schema_context +from integrations.models import RecommerceIntegration +from integrations.recommerce.services import RecommerceService +from products.models import Product +from integrations.recommerce.exceptions import RecommerceError, RecommerceAPIError + +logger = logging.getLogger(__name__) + +@shared_task(bind=True) +def sync_products_batch_task(self, product_ids, options=None, schema_name=None): + """ + Celery задача для массовой синхронизации товаров с Recommerce. + + Args: + product_ids (list): Список ID товаров (PK) для синхронизации + options (dict): Настройки синхронизации + - fields (list): Список полей для обновления ['price', 'count', 'content', 'images'] + - create_if_missing (bool): Создавать товар, если он не найден (404) + schema_name (str): Имя схемы тенанта для выполнения запросов + """ + if options is None: + options = {} + + fields = options.get('fields', []) + create_if_missing = options.get('create_if_missing', False) + + # Используем schema_context для выполнения запросов в правильной tenant схеме + if schema_name: + with schema_context(schema_name): + return _do_sync(product_ids, fields, create_if_missing) + else: + return _do_sync(product_ids, fields, create_if_missing) + + +def _do_sync(product_ids, fields, create_if_missing): + """ + Внутренняя функция для выполнения синхронизации в контексте схемы. + + Args: + product_ids (list): Список ID товаров (PK) для синхронизации + fields (list): Список полей для обновления ['price', 'count', 'content', 'images'] + create_if_missing (bool): Создавать товар, если он не найден (404) + """ + # 1. Получаем интеграцию + integration = RecommerceIntegration.objects.filter(is_active=True).first() + if not integration or not integration.is_configured: + msg = "Recommerce integration is not active or configured." + logger.error(msg) + return {"success": False, "error": msg} + + service = RecommerceService(integration) + + # 2. Получаем товары + products = Product.objects.filter(pk__in=product_ids) + + results = { + "total": len(product_ids), + "success": 0, + "failed": 0, + "created": 0, + "updated": 0, + "errors": [] + } + + logger.info(f"Starting Recommerce sync for {len(product_ids)} products. Fields: {fields}, create_if_missing: {create_if_missing}") + + for product in products: + try: + # Если fields пустой или содержит 'all' - обновляем всё (передаем None в сервис) + # Иначе передаем список полей. + # В сервисе update_product(fields=None) обновляет всё. + + # Маппинг опций фронтенда на логику сервиса + # Фронт: ['price', 'count', 'content', 'images'] + # Сервис: ожидает список полей или None (всё). + # Если выбраны не все галочки, передаем конкретные поля. + # Но 'content' и 'images' в сервисе могут не поддерживаться напрямую как ключи, + # нужно смотреть реализацию to_api_product. + # Пока передаем как есть, предполагая, что сервис или маппер разберется, + # либо если выбрано "все", передаем None. + + # Упрощение: если выбраны все основные группы, считаем это полным обновлением + is_full_update = False + if 'content' in fields and 'images' in fields and 'price' in fields and 'count' in fields: + is_full_update = True + + service_fields = None if is_full_update else fields + + # Если список полей пуст (ничего не выбрано), но задача запущена - странно, но пропустим + if not is_full_update and not service_fields: + logger.warning(f"Product {product.id}: No fields selected for update.") + continue + + try: + service.update_product(product, fields=service_fields) + results["updated"] += 1 + results["success"] += 1 + + except RecommerceAPIError as e: + # Если товар не найден (404) и разрешено создание + if e.status_code == 404 and create_if_missing: + logger.info(f"Product {product.sku} not found. Creating...") + service.create_product(product) + results["created"] += 1 + results["success"] += 1 + else: + raise e + + except Exception as e: + results["failed"] += 1 + error_msg = f"Product {product.sku} ({product.id}): {str(e)}" + logger.error(error_msg) + results["errors"].append(error_msg) + + logger.info(f"Recommerce sync completed. Results: {results}") + return results \ No newline at end of file diff --git a/myproject/integrations/urls.py b/myproject/integrations/urls.py index 7e59437..dab9ef1 100644 --- a/myproject/integrations/urls.py +++ b/myproject/integrations/urls.py @@ -5,6 +5,7 @@ from .views import ( save_integration_settings, get_integration_form_data, test_integration_connection, + RecommerceBatchSyncView, ) app_name = 'integrations' @@ -18,4 +19,7 @@ urlpatterns = [ path("settings//", save_integration_settings, name="settings"), path("form//", get_integration_form_data, name="form_data"), path("test//", test_integration_connection, name="test"), + + # Синхронизация + path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"), ] diff --git a/myproject/integrations/views.py b/myproject/integrations/views.py index cd27012..7e3a5aa 100644 --- a/myproject/integrations/views.py +++ b/myproject/integrations/views.py @@ -5,6 +5,7 @@ from django.views.decorators.http import require_POST from user_roles.mixins import OwnerRequiredMixin from .models import RecommerceIntegration, WooCommerceIntegration +from integrations.recommerce.tasks import sync_products_batch_task # Реестр доступных интеграций @@ -148,6 +149,44 @@ def get_integration_service(integration_id: str, instance): return None +class RecommerceBatchSyncView(TemplateView): + """ + API View для запуска массовой синхронизации с Recommerce. + POST /integrations/recommerce/sync/ + """ + def dispatch(self, request, *args, **kwargs): + # Временное логирование для отладки + from user_roles.services import RoleService + import logging + logger = logging.getLogger(__name__) + logger.info(f"User: {request.user}, Authenticated: {request.user.is_authenticated}") + user_role = RoleService.get_user_role(request.user) + logger.info(f"User role: {user_role}") + return super().dispatch(request, *args, **kwargs) + def post(self, request, *args, **kwargs): + try: + data = json.loads(request.body) + product_ids = data.get('product_ids', []) + options = data.get('options', {}) + + if not product_ids: + return JsonResponse({'error': 'No products selected'}, status=400) + + # Запуск Celery задачи с передачей schema_name + from django_tenants.utils import get_tenant_model + Tenant = get_tenant_model() + schema_name = request.tenant.schema_name + task = sync_products_batch_task.delay(product_ids, options, schema_name) + + return JsonResponse({ + 'success': True, + 'task_id': task.id, + 'message': f'Запущена синхронизация {len(product_ids)} товаров' + }) + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + @require_POST def save_integration_settings(request, integration_id: str): """ diff --git a/myproject/products/static/products/js/recommerce-sync.js b/myproject/products/static/products/js/recommerce-sync.js new file mode 100644 index 0000000..90f9201 --- /dev/null +++ b/myproject/products/static/products/js/recommerce-sync.js @@ -0,0 +1,128 @@ +document.addEventListener('DOMContentLoaded', function() { + const syncBtn = document.getElementById('bulk-recommerce-sync'); + const modalEl = document.getElementById('recommerceSyncModal'); + const startBtn = document.getElementById('startRecommerceSyncBtn'); + + if (!syncBtn || !modalEl) return; + + const modal = new bootstrap.Modal(modalEl); + + syncBtn.addEventListener('click', function(e) { + e.preventDefault(); + + // Получаем выбранные элементы + // Предполагается, что на странице есть механизм BatchSelection или просто чекбоксы + let selectedItems = []; + + // Проверяем глобальный объект BatchSelection (из batch-selection.js) + if (window.BatchSelection && typeof window.BatchSelection.getSelectedItems === 'function') { + selectedItems = window.BatchSelection.getSelectedItems(); + } else { + // Fallback: собираем вручную + document.querySelectorAll('.item-checkbox:checked').forEach(cb => { + selectedItems.push(cb.value); + }); + } + + // Фильтруем только товары (формат value="product:123") + // Комплекты (kit:123) пока игнорируем, так как бэкенд ожидает Product ID + const productIds = selectedItems + .filter(val => val.startsWith('product:')) + .map(val => val.split(':')[1]); + + if (productIds.length === 0) { + // Если выбраны только комплекты или ничего не выбрано + if (selectedItems.length > 0) { + alert('Для синхронизации с Recommerce выберите товары (комплекты пока не поддерживаются).'); + } else { + alert('Выберите товары для синхронизации.'); + } + return; + } + + // Обновляем UI модального окна + document.getElementById('recommerceSyncCount').textContent = productIds.length; + + // Сохраняем ID для отправки + startBtn.dataset.productIds = JSON.stringify(productIds); + + modal.show(); + }); + + startBtn.addEventListener('click', function() { + const productIds = JSON.parse(this.dataset.productIds || '[]'); + + const options = { + fields: [], + create_if_missing: document.getElementById('syncCreateNew').checked + }; + + if (document.getElementById('syncPrice').checked) options.fields.push('price'); + if (document.getElementById('syncStock').checked) options.fields.push('count'); + if (document.getElementById('syncContent').checked) options.fields.push('content'); + if (document.getElementById('syncImages').checked) options.fields.push('images'); + + // Блокируем кнопку + startBtn.disabled = true; + const originalText = startBtn.innerHTML; + startBtn.innerHTML = ' Запуск...'; + + fetch('/settings/integrations/recommerce/sync/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ + product_ids: productIds, + options: options + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + modal.hide(); + // Используем стандартный alert или toast, если есть + alert(`Синхронизация запущена успешно!\nID задачи: ${data.task_id}\nОбрабатывается товаров: ${productIds.length}`); + + // Снимаем выделение + if (window.BatchSelection && typeof window.BatchSelection.clearSelection === 'function') { + window.BatchSelection.clearSelection(); + } + } else { + alert('Ошибка при запуске: ' + (data.error || 'Неизвестная ошибка')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Ошибка сети или сервера'); + }) + .finally(() => { + startBtn.disabled = false; + startBtn.innerHTML = originalText; + }); + }); + + // Helper для получения CSRF токена + function getCsrfToken() { + // Сначала пробуем получить из скрытого поля (для CSRF_USE_SESSIONS = True) + const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]'); + if (csrfInput) { + return csrfInput.value; + } + // Fallback: из куки + const name = 'csrftoken'; + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } +}); \ No newline at end of file diff --git a/myproject/products/templates/products/includes/recommerce_sync_modal.html b/myproject/products/templates/products/includes/recommerce_sync_modal.html new file mode 100644 index 0000000..ddd9139 --- /dev/null +++ b/myproject/products/templates/products/includes/recommerce_sync_modal.html @@ -0,0 +1,70 @@ + + \ No newline at end of file diff --git a/myproject/products/templates/products/products_list.html b/myproject/products/templates/products/products_list.html index 8c93f41..6ce9f1c 100644 --- a/myproject/products/templates/products/products_list.html +++ b/myproject/products/templates/products/products_list.html @@ -4,6 +4,7 @@ {% block title %}Товары и комплекты{% endblock %} {% block content %} +{% csrf_token %}

Товары и комплекты @@ -188,6 +189,12 @@ Изменить категории +
  • +
  • + + Синхронизация с Recommerce + +
  • @@ -398,6 +405,8 @@ {% endif %} +{% include "products/includes/recommerce_sync_modal.html" %} +