From 37394121e19505c9fbf6e71d413d29728215dca6 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Mon, 12 Jan 2026 00:29:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(integrations):=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D0=B0=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F/=D0=B2=D1=8B=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалена лишняя модель IntegrationConfig из system_settings - Singleton-паттерн: одна запись на интеграцию с is_active тумблером - Добавлено шифрование токенов (EncryptedCharField с Fernet AES-128) - UI: тумблеры слева, форма настроек справа - API endpoints: toggle, settings, form_data - Модель Recommerce: store_url + api_token (x-auth-token) - Модель WooCommerce: store_url + consumer_key/secret Co-Authored-By: Claude Opus 4.5 --- myproject/integrations/admin.py | 62 ++-- myproject/integrations/fields.py | 116 +++++++ .../migrations/0001_add_integration_models.py | 57 +++ myproject/integrations/migrations/__init__.py | 0 myproject/integrations/models/base.py | 50 ++- .../models/marketplaces/recommerce.py | 53 +-- .../models/marketplaces/woocommerce.py | 26 +- myproject/integrations/urls.py | 20 +- myproject/integrations/views.py | 215 +++++++++++- myproject/myproject/settings.py | 20 +- myproject/requirements.txt | 1 + myproject/system_settings/models/__init__.py | 5 +- .../models/integration_config.py | 54 --- .../system_settings/integrations.html | 325 ++++++++++++++++-- 14 files changed, 804 insertions(+), 200 deletions(-) create mode 100644 myproject/integrations/fields.py create mode 100644 myproject/integrations/migrations/0001_add_integration_models.py create mode 100644 myproject/integrations/migrations/__init__.py delete mode 100644 myproject/system_settings/models/integration_config.py diff --git a/myproject/integrations/admin.py b/myproject/integrations/admin.py index 4e1eda0..be93628 100644 --- a/myproject/integrations/admin.py +++ b/myproject/integrations/admin.py @@ -1,44 +1,32 @@ from django.contrib import admin -from system_settings.models import IntegrationConfig +from .models import RecommerceIntegration, WooCommerceIntegration -@admin.register(IntegrationConfig) -class IntegrationConfigAdmin(admin.ModelAdmin): - """Админка для настроек интеграций (тумблеров)""" - list_display = ['get_integration_id_display', 'is_enabled', 'last_sync_at', 'updated_at'] - list_filter = ['is_enabled', 'integration_id'] - search_fields = ['integration_id'] - list_editable = ['is_enabled'] +@admin.register(RecommerceIntegration) +class RecommerceIntegrationAdmin(admin.ModelAdmin): + """Админка для Recommerce интеграции""" + list_display = ['__str__', 'is_active', 'is_configured', 'updated_at'] + list_filter = ['is_active'] + readonly_fields = ['created_at', 'updated_at'] - def has_add_permission(self, request): - """Запретить добавление новых интеграций вручную""" - return False - - def has_delete_permission(self, request, obj=None): - """Запретить удаление интеграций""" - return False + fieldsets = ( + ('Основное', {'fields': ('name', 'is_active')}), + ('API настройки', {'fields': ('store_url', 'api_token')}), + ('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}), + ('Служебное', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}), + ) -# Регистрация конкретных интеграций (когда будут готовы): -# -# @admin.register(WooCommerceIntegration) -# class WooCommerceIntegrationAdmin(admin.ModelAdmin): -# list_display = ['name', 'store_url', 'is_active', 'is_configured', 'updated_at'] -# list_filter = ['is_active', 'auto_sync_products'] -# fieldsets = ( -# ('Основное', {'fields': ('name', 'is_active')}), -# ('API настройки', {'fields': ('store_url', 'consumer_key', 'consumer_secret')}), -# ('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}), -# ) -# -# -# @admin.register(RecommerceIntegration) -# class RecommerceIntegrationAdmin(admin.ModelAdmin): -# list_display = ['name', 'merchant_id', 'is_active', 'is_configured', 'updated_at'] -# list_filter = ['is_active', 'sync_prices', 'sync_stock'] -# fieldsets = ( -# ('Основное', {'fields': ('name', 'is_active')}), -# ('API настройки', {'fields': ('store_url', 'api_endpoint', 'api_token', 'merchant_id')}), -# ('Синхронизация', {'fields': ('auto_sync_products', 'import_orders', 'sync_prices', 'sync_stock')}), -# ) +@admin.register(WooCommerceIntegration) +class WooCommerceIntegrationAdmin(admin.ModelAdmin): + """Админка для WooCommerce интеграции""" + list_display = ['__str__', 'is_active', 'is_configured', 'updated_at'] + list_filter = ['is_active'] + readonly_fields = ['created_at', 'updated_at'] + fieldsets = ( + ('Основное', {'fields': ('name', 'is_active')}), + ('API настройки', {'fields': ('store_url', 'consumer_key', 'consumer_secret', 'api_version')}), + ('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}), + ('Служебное', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}), + ) diff --git a/myproject/integrations/fields.py b/myproject/integrations/fields.py new file mode 100644 index 0000000..38d4a56 --- /dev/null +++ b/myproject/integrations/fields.py @@ -0,0 +1,116 @@ +""" +Кастомные поля Django с шифрованием для безопасного хранения credentials. + +Использует Fernet (AES-128-CBC) из библиотеки cryptography. +""" +from django.db import models +from django.conf import settings +from cryptography.fernet import Fernet, InvalidToken + + +class EncryptedCharField(models.CharField): + """ + CharField с прозрачным шифрованием/дешифрованием. + + Данные шифруются при сохранении в БД и дешифруются при чтении. + В БД хранится зашифрованная строка (base64). + + Требует ENCRYPTION_KEY в settings.py: + from cryptography.fernet import Fernet + ENCRYPTION_KEY = Fernet.generate_key() # сгенерировать один раз! + + Пример использования: + api_token = EncryptedCharField(max_length=500, blank=True) + """ + + description = "Encrypted CharField using Fernet" + + def __init__(self, *args, **kwargs): + # Зашифрованные данные длиннее исходных, увеличиваем max_length + if 'max_length' in kwargs: + # Fernet добавляет ~100 байт overhead + kwargs['max_length'] = max(kwargs['max_length'] * 2, 500) + super().__init__(*args, **kwargs) + + def _get_fernet(self): + """Получить инстанс Fernet с ключом из settings""" + key = getattr(settings, 'ENCRYPTION_KEY', None) + if not key: + raise ValueError( + "ENCRYPTION_KEY не найден в settings. " + "Сгенерируйте ключ: from cryptography.fernet import Fernet; Fernet.generate_key()" + ) + # Ключ может быть строкой или bytes + if isinstance(key, str): + key = key.encode() + return Fernet(key) + + def get_prep_value(self, value): + """Шифрование перед сохранением в БД""" + value = super().get_prep_value(value) + if value is None or value == '': + return value + try: + f = self._get_fernet() + encrypted = f.encrypt(value.encode('utf-8')) + return encrypted.decode('utf-8') + except Exception as e: + raise ValueError(f"Ошибка шифрования: {e}") + + def from_db_value(self, value, expression, connection): + """Дешифрование при чтении из БД""" + if value is None or value == '': + return value + try: + f = self._get_fernet() + decrypted = f.decrypt(value.encode('utf-8')) + return decrypted.decode('utf-8') + except InvalidToken: + # Данные не зашифрованы или ключ изменился + # Возвращаем как есть (для миграции старых данных) + return value + except Exception: + return value + + def to_python(self, value): + """Преобразование в Python-объект (не дешифруем, т.к. это для форм)""" + return super().to_python(value) + + +class EncryptedTextField(models.TextField): + """ + TextField с шифрованием для больших данных (например JSON credentials). + """ + + description = "Encrypted TextField using Fernet" + + def _get_fernet(self): + key = getattr(settings, 'ENCRYPTION_KEY', None) + if not key: + raise ValueError("ENCRYPTION_KEY не найден в settings.") + if isinstance(key, str): + key = key.encode() + return Fernet(key) + + def get_prep_value(self, value): + value = super().get_prep_value(value) + if value is None or value == '': + return value + try: + f = self._get_fernet() + encrypted = f.encrypt(value.encode('utf-8')) + return encrypted.decode('utf-8') + except Exception as e: + raise ValueError(f"Ошибка шифрования: {e}") + + def from_db_value(self, value, expression, connection): + if value is None or value == '': + return value + try: + f = self._get_fernet() + decrypted = f.decrypt(value.encode('utf-8')) + return decrypted.decode('utf-8') + except InvalidToken: + return value + except Exception: + return value diff --git a/myproject/integrations/migrations/0001_add_integration_models.py b/myproject/integrations/migrations/0001_add_integration_models.py new file mode 100644 index 0000000..3bf6ebb --- /dev/null +++ b/myproject/integrations/migrations/0001_add_integration_models.py @@ -0,0 +1,57 @@ +# Generated by Django 5.0.10 on 2026-01-11 21:19 + +import integrations.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='RecommerceIntegration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_active', models.BooleanField(db_index=True, default=False, help_text='Глобальный тумблер включения интеграции', verbose_name='Активна')), + ('name', models.CharField(blank=True, default='', help_text='Произвольное название для удобства (опционально)', max_length=100, verbose_name='Название')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('extra_config', models.JSONField(blank=True, default=dict, verbose_name='Доп. настройки')), + ('integration_type', models.CharField(choices=[('marketplace', 'Маркетплейс'), ('payment', 'Платёжная система'), ('shipping', 'Служба доставки')], default='marketplace', editable=False, max_length=20)), + ('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)')), + ], + options={ + 'verbose_name': 'Recommerce', + 'verbose_name_plural': 'Recommerce', + }, + ), + migrations.CreateModel( + name='WooCommerceIntegration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_active', models.BooleanField(db_index=True, default=False, help_text='Глобальный тумблер включения интеграции', verbose_name='Активна')), + ('name', models.CharField(blank=True, default='', help_text='Произвольное название для удобства (опционально)', max_length=100, verbose_name='Название')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('extra_config', models.JSONField(blank=True, default=dict, verbose_name='Доп. настройки')), + ('integration_type', models.CharField(choices=[('marketplace', 'Маркетплейс'), ('payment', 'Платёжная система'), ('shipping', 'Служба доставки')], default='marketplace', editable=False, max_length=20)), + ('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')), + ('api_version', models.CharField(blank=True, default='v3', help_text='Версия WooCommerce REST API', max_length=10, verbose_name='Версия API')), + ], + options={ + 'verbose_name': 'WooCommerce', + 'verbose_name_plural': 'WooCommerce', + }, + ), + ] diff --git a/myproject/integrations/migrations/__init__.py b/myproject/integrations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/integrations/models/base.py b/myproject/integrations/models/base.py index c7e9226..672f198 100644 --- a/myproject/integrations/models/base.py +++ b/myproject/integrations/models/base.py @@ -1,6 +1,5 @@ from django.db import models from django.core.exceptions import ValidationError -from abc import ABC, abstractmethod class IntegrationType(models.TextChoices): @@ -12,7 +11,9 @@ class IntegrationType(models.TextChoices): class BaseIntegration(models.Model): """ Абстрактный базовый класс для всех интеграций. - Содержит общие поля и логику валидации. + + Singleton-паттерн: каждая конкретная интеграция имеет только одну запись в БД. + Поле is_active служит глобальным тумблером включения/выключения. """ integration_type = models.CharField( @@ -23,16 +24,18 @@ class BaseIntegration(models.Model): ) is_active = models.BooleanField( - default=True, + default=False, verbose_name="Активна", db_index=True, - help_text="Интеграция используется только если включена здесь и в системных настройках" + help_text="Глобальный тумблер включения интеграции" ) name = models.CharField( max_length=100, + blank=True, + default='', verbose_name="Название", - help_text="Произвольное название для удобства" + help_text="Произвольное название для удобства (опционально)" ) created_at = models.DateTimeField( @@ -45,21 +48,6 @@ class BaseIntegration(models.Model): verbose_name="Дата обновления" ) - # Общие credential поля (можно переопределить в наследниках) - api_key = models.CharField( - max_length=255, - blank=True, - verbose_name="API ключ", - help_text="Будет зашифрован при сохранении" - ) - - api_secret = models.CharField( - max_length=255, - blank=True, - verbose_name="API секрет", - help_text="Будет зашифрован при сохранении" - ) - # Дополнительные настройки в JSON для гибкости extra_config = models.JSONField( default=dict, @@ -73,21 +61,29 @@ class BaseIntegration(models.Model): indexes = [ models.Index(fields=['is_active', 'integration_type']), ] + # Singleton: только одна запись на тип интеграции + constraints = [ + models.UniqueConstraint( + fields=['integration_type'], + name='%(class)s_singleton' + ) + ] def __str__(self): - return f"{self.get_integration_type_display()}: {self.name}" + status = "вкл" if self.is_active else "выкл" + return f"{self.get_integration_type_display()}: {self.name or self._meta.verbose_name} ({status})" @property def is_configured(self) -> bool: """ Проверить, есть ли необходимые credentials. - Можно переопределить в наследниках. + Переопределить в наследниках. """ - return bool(self.api_key) + return False def clean(self): - """Валидацияcredentials""" + """Валидация: нельзя включить ненастроенную интеграцию""" if self.is_active and not self.is_configured: - raise ValidationError({ - 'api_key': 'API ключ обязателен для активной интеграции' - }) + raise ValidationError( + 'Невозможно включить интеграцию без настроенных credentials' + ) diff --git a/myproject/integrations/models/marketplaces/recommerce.py b/myproject/integrations/models/marketplaces/recommerce.py index dda604d..0011b8a 100644 --- a/myproject/integrations/models/marketplaces/recommerce.py +++ b/myproject/integrations/models/marketplaces/recommerce.py @@ -1,59 +1,36 @@ from django.db import models from .base import MarketplaceIntegration +from ...fields import EncryptedCharField class RecommerceIntegration(MarketplaceIntegration): """ Интеграция с Recommerce. - Recommerce - сервис для управления товарами на маркетплейсах. + + Recommerce - платформа для управления интернет-магазином. + API документация: запросы отправляются на домен магазина с заголовком x-auth-token. + + Обязательные настройки: + - store_url: URL магазина (домен для API запросов) + - api_token: токен авторизации (передаётся в заголовке x-auth-token) """ - # API endpoint (может отличаться от store_url) - api_endpoint = models.URLField( - blank=True, - verbose_name="API Endpoint", - help_text="URL API Recommerce (если отличается от URL магазина)" - ) - - # API токен (основной метод авторизации) - api_token = models.CharField( + # API токен (x-auth-token) - ЗАШИФРОВАН + api_token = EncryptedCharField( max_length=500, blank=True, - verbose_name="API Токен", - help_text="Токен авторизации Recommerce API" - ) - - # ID магазина в системе Recommerce - merchant_id = models.CharField( - max_length=100, - blank=True, - verbose_name="ID магазина", - help_text="Идентификатор магазина в Recommerce" - ) - - # Синхронизация цен - sync_prices = models.BooleanField( - default=True, - verbose_name="Синхронизировать цены", - help_text="Обновлять цены на маркетплейсе" - ) - - # Синхронизация остатков - sync_stock = models.BooleanField( - default=True, - verbose_name="Синхронизировать остатки", - help_text="Обновлять остатки на маркетплейсе" + verbose_name="API Токен (x-auth-token)", + help_text="Токен авторизации из панели управления Recommerce" ) class Meta: verbose_name = "Recommerce" verbose_name_plural = "Recommerce" - managed = False # Пока заготовка - без создания таблицы def __str__(self): - return f"Recommerce: {self.name or self.merchant_id}" + return f"Recommerce: {self.name or self.store_url or 'не настроен'}" @property def is_configured(self) -> bool: - """Recommerce требует api_token""" - return bool(self.api_token) + """Recommerce требует store_url и api_token""" + return bool(self.store_url and self.api_token) diff --git a/myproject/integrations/models/marketplaces/woocommerce.py b/myproject/integrations/models/marketplaces/woocommerce.py index 780beed..6b5207a 100644 --- a/myproject/integrations/models/marketplaces/woocommerce.py +++ b/myproject/integrations/models/marketplaces/woocommerce.py @@ -1,21 +1,27 @@ from django.db import models from .base import MarketplaceIntegration +from ...fields import EncryptedCharField class WooCommerceIntegration(MarketplaceIntegration): - """Интеграция с WooCommerce""" + """ + Интеграция с WooCommerce. + WooCommerce - плагин электронной коммерции для WordPress. + """ - # WooCommerce-specific credentials - consumer_key = models.CharField( + # WooCommerce REST API credentials - ЗАШИФРОВАНЫ + consumer_key = EncryptedCharField( max_length=255, blank=True, - verbose_name="Consumer Key" + verbose_name="Consumer Key", + help_text="REST API Consumer Key (хранится зашифрованным)" ) - consumer_secret = models.CharField( + consumer_secret = EncryptedCharField( max_length=255, blank=True, - verbose_name="Consumer Secret" + verbose_name="Consumer Secret", + help_text="REST API Consumer Secret (хранится зашифрованным)" ) # API версия (WooCommerce REST API v1, v2, v3) @@ -23,18 +29,18 @@ class WooCommerceIntegration(MarketplaceIntegration): max_length=10, default='v3', blank=True, - verbose_name="Версия API" + verbose_name="Версия API", + help_text="Версия WooCommerce REST API" ) class Meta: verbose_name = "WooCommerce" verbose_name_plural = "WooCommerce" - managed = False # Пока заготовка - без создания таблицы def __str__(self): - return f"WooCommerce: {self.name or self.store_url}" + return f"WooCommerce: {self.name or self.store_url or 'не настроен'}" @property def is_configured(self) -> bool: """WooCommerce требует consumer_key и consumer_secret""" - return bool(self.consumer_key and self.consumer_secret) + return bool(self.consumer_key and self.consumer_secret and self.store_url) diff --git a/myproject/integrations/urls.py b/myproject/integrations/urls.py index 32c1d9f..eb1c21a 100644 --- a/myproject/integrations/urls.py +++ b/myproject/integrations/urls.py @@ -1,11 +1,23 @@ from django.urls import path -from .views import IntegrationsListView +from .views import ( + IntegrationsListView, + toggle_integration, + save_integration_settings, + get_integration_form_data, +) app_name = 'integrations' urlpatterns = [ + # Страница со списком интеграций path("", IntegrationsListView.as_view(), name="list"), - # Здесь будут добавляться endpoint'ы для интеграций - # path("test//", views.test_connection, name="test_connection"), - # path("webhook//", views.webhook, name="webhook"), + + # API endpoints для управления интеграциями + 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"), ] diff --git a/myproject/integrations/views.py b/myproject/integrations/views.py index 99b1979..1597be3 100644 --- a/myproject/integrations/views.py +++ b/myproject/integrations/views.py @@ -1,5 +1,46 @@ +import json from django.views.generic import TemplateView +from django.http import JsonResponse +from django.views.decorators.http import require_POST + from user_roles.mixins import OwnerRequiredMixin +from .models import RecommerceIntegration, WooCommerceIntegration + + +# Реестр доступных интеграций +# Ключ = идентификатор для URL/JS, значение = (модель, название для UI) +INTEGRATION_REGISTRY = { + 'recommerce': (RecommerceIntegration, 'Recommerce', 'Маркетплейс'), + 'woocommerce': (WooCommerceIntegration, 'WooCommerce', 'Маркетплейс'), + # Добавлять новые интеграции здесь: + # 'shopify': (ShopifyIntegration, 'Shopify', 'Маркетплейс'), +} + + +def get_integration_model(integration_id: str): + """Получить модель интеграции по идентификатору""" + if integration_id not in INTEGRATION_REGISTRY: + return None + return INTEGRATION_REGISTRY[integration_id][0] + + +def get_all_integrations_status(): + """ + Получить статус всех интеграций. + Возвращает dict с информацией для UI. + """ + result = {} + for key, (model, label, category) in INTEGRATION_REGISTRY.items(): + instance = model.objects.first() + result[key] = { + 'id': key, + 'label': label, + 'category': category, + 'is_active': instance.is_active if instance else False, + 'is_configured': instance.is_configured if instance else False, + 'exists': instance is not None, + } + return result class IntegrationsListView(OwnerRequiredMixin, TemplateView): @@ -8,6 +49,176 @@ class IntegrationsListView(OwnerRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - from system_settings.models import IntegrationConfig - context['integration_choices'] = IntegrationConfig.INTEGRATION_CHOICES + integrations = get_all_integrations_status() + context['integrations'] = integrations + # JSON для JavaScript + context['integrations_json'] = json.dumps(integrations, ensure_ascii=False) return context + + +@require_POST +def toggle_integration(request, integration_id: str): + """ + API endpoint для включения/выключения интеграции. + POST /integrations/toggle// + + Создаёт запись если не существует (singleton). + Переключает is_active. + """ + # Проверка прав (только владелец) + 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) + + # Получить или создать singleton + instance, created = model.objects.get_or_create( + defaults={ + 'name': INTEGRATION_REGISTRY[integration_id][1], + 'is_active': False, + } + ) + + # Переключить состояние + new_state = not instance.is_active + + # Проверка: нельзя включить ненастроенную интеграцию + if new_state and not instance.is_configured: + return JsonResponse({ + 'error': 'Сначала настройте интеграцию (введите credentials)', + 'is_active': instance.is_active, + 'is_configured': False, + }, status=400) + + instance.is_active = new_state + instance.save(update_fields=['is_active', 'updated_at']) + + return JsonResponse({ + 'success': True, + 'is_active': instance.is_active, + 'is_configured': instance.is_configured, + }) + + +@require_POST +def save_integration_settings(request, integration_id: str): + """ + API endpoint для сохранения настроек интеграции. + POST /integrations/settings// + + Body: JSON с полями для обновления + """ + 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, created = model.objects.get_or_create( + defaults={'name': INTEGRATION_REGISTRY[integration_id][1]} + ) + + # Парсинг данных + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({'error': 'Invalid JSON'}, status=400) + + # Обновить только разрешённые поля (не is_active - для этого toggle) + allowed_fields = get_editable_fields(model) + updated_fields = [] + + for field in allowed_fields: + if field in data: + setattr(instance, field, data[field]) + updated_fields.append(field) + + if updated_fields: + updated_fields.append('updated_at') + instance.save(update_fields=updated_fields) + + return JsonResponse({ + 'success': True, + 'is_configured': instance.is_configured, + 'updated_fields': updated_fields, + }) + + +def get_editable_fields(model): + """Получить список редактируемых полей модели (исключая служебные)""" + excluded = {'id', 'integration_type', 'is_active', 'created_at', 'updated_at', 'extra_config'} + fields = [] + for field in model._meta.get_fields(): + if hasattr(field, 'name') and field.name not in excluded: + if not field.is_relation: # Исключаем FK/M2M + fields.append(field.name) + return fields + + +def get_integration_form_data(request, integration_id: str): + """ + GET endpoint для получения текущих настроек интеграции. + Используется для заполнения формы справа. + """ + 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({ + 'exists': False, + 'fields': get_form_fields_meta(model), + }) + + # Собрать данные полей (без чувствительных данных полностью) + data = {} + for field_name in get_editable_fields(model): + field = model._meta.get_field(field_name) + value = getattr(instance, field_name, None) + + # Маскировать секреты + if 'token' in field_name.lower() or 'secret' in field_name.lower() or 'key' in field_name.lower(): + data[field_name] = '••••••••' if value else '' + else: + data[field_name] = value + + return JsonResponse({ + 'exists': True, + 'is_active': instance.is_active, + 'is_configured': instance.is_configured, + 'data': data, + 'fields': get_form_fields_meta(model), + }) + + +def get_form_fields_meta(model): + """Получить метаданные полей для построения формы на фронте""" + fields = [] + for field_name in get_editable_fields(model): + field = model._meta.get_field(field_name) + field_info = { + 'name': field_name, + 'label': getattr(field, 'verbose_name', field_name), + 'help_text': getattr(field, 'help_text', ''), + 'required': not getattr(field, 'blank', True), + 'type': 'text', # default + } + + # Определить тип поля + if isinstance(field, model._meta.get_field(field_name).__class__): + if 'BooleanField' in field.__class__.__name__: + field_info['type'] = 'checkbox' + elif 'URLField' in field.__class__.__name__: + field_info['type'] = 'url' + elif 'secret' in field_name.lower() or 'token' in field_name.lower() or 'key' in field_name.lower(): + field_info['type'] = 'password' + + fields.append(field_info) + return fields diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 9efaa12..a8cb2b7 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -611,5 +611,23 @@ CELERY_BEAT_SCHEDULE = { # ============================================ # Увеличиваем лимиты для загрузки больших списков (10 000+ клиентов) -DATA_UPLOAD_MAX_NUMBER_FIELDS = 50000 +DATA_UPLOAD_MAX_NUMBER_FIELDS = 50000 DATA_UPLOAD_MAX_MEMORY_SIZE = 104857600 # 100MB + + +# ============================================ +# ENCRYPTION SETTINGS (for sensitive data like API tokens) +# ============================================ + +# Ключ шифрования для EncryptedCharField (Fernet AES-128) +# ВАЖНО: Сгенерировать один раз и сохранить в .env! +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +ENCRYPTION_KEY = env('ENCRYPTION_KEY', default=None) + +# Проверка наличия ключа в production +if not DEBUG and not ENCRYPTION_KEY: + import warnings + warnings.warn( + "ENCRYPTION_KEY not set! Encrypted fields will fail. " + "Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + ) diff --git a/myproject/requirements.txt b/myproject/requirements.txt index 7ebcfd6..e7d7869 100644 --- a/myproject/requirements.txt +++ b/myproject/requirements.txt @@ -7,6 +7,7 @@ click-didyoumean==0.3.1 click-plugins==1.1.1.2 click-repl==0.3.0 colorama==0.4.6 +cryptography==44.0.0 Django==5.0.10 django-celery-results==2.5.1 django-debug-toolbar==6.1.0 diff --git a/myproject/system_settings/models/__init__.py b/myproject/system_settings/models/__init__.py index e8b18d6..438f3d4 100644 --- a/myproject/system_settings/models/__init__.py +++ b/myproject/system_settings/models/__init__.py @@ -1,3 +1,2 @@ -from .integration_config import IntegrationConfig - -__all__ = ['IntegrationConfig'] +# Models for system_settings app +# IntegrationConfig removed - using integrations app models directly diff --git a/myproject/system_settings/models/integration_config.py b/myproject/system_settings/models/integration_config.py deleted file mode 100644 index 5bc8aae..0000000 --- a/myproject/system_settings/models/integration_config.py +++ /dev/null @@ -1,54 +0,0 @@ -from django.db import models - - -class IntegrationConfig(models.Model): - """ - Глобальные тумблеры для включения/выключения интеграций. - Одна запись на доступную интеграцию. - """ - - INTEGRATION_CHOICES = [ - ('woocommerce', 'WooCommerce'), - ('recommerce', 'Recommerce'), - # Здесь добавлять новые интеграции: - # ('shopify', 'Shopify'), - # ('telegram', 'Telegram'), - ] - - integration_id = models.CharField( - max_length=50, - choices=INTEGRATION_CHOICES, - unique=True, - verbose_name="Интеграция" - ) - - is_enabled = models.BooleanField( - default=False, - verbose_name="Включена", - help_text="Глобальное включение интеграции для тенанта" - ) - - last_sync_at = models.DateTimeField( - null=True, - blank=True, - verbose_name="Последняя синхронизация" - ) - - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name="Дата создания" - ) - - updated_at = models.DateTimeField( - auto_now=True, - verbose_name="Дата обновления" - ) - - class Meta: - verbose_name = "Настройка интеграции" - verbose_name_plural = "Настройки интеграций" - ordering = ['integration_id'] - - def __str__(self): - status = "вкл" if self.is_enabled else "выкл" - return f"{self.get_integration_id_display()}: {status}" diff --git a/myproject/system_settings/templates/system_settings/integrations.html b/myproject/system_settings/templates/system_settings/integrations.html index 87dcdff..4c875ac 100644 --- a/myproject/system_settings/templates/system_settings/integrations.html +++ b/myproject/system_settings/templates/system_settings/integrations.html @@ -10,56 +10,333 @@
Доступные интеграции
-
- {% for value, label in integration_choices %} -
-
- {{ label }} - Маркетплейс +
+ {% for key, integration in integrations.items %} +
+
+
+ {{ integration.label }} + {{ integration.category }} +
+ {% if integration.is_configured %} + + + + + + {% endif %}
-
+
- + data-integration="{{ key }}" + id="toggle-{{ key }}" + {% if integration.is_active %}checked{% endif %} + style="cursor: pointer;">
+ {% empty %} +
+ Нет доступных интеграций +
{% endfor %}
- +
-
-
Настройки интеграции
+
+
Настройки интеграции
+
-
+ +
- +

Выберите интеграцию слева для настройки

- Здесь появится форма с настройками выбранной интеграции + Кликните на название или тумблер +
+ + + + + +
- + +
+ +
+