feat(integrations): архитектура включения/выключения интеграций
- Удалена лишняя модель 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,44 +1,32 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from system_settings.models import IntegrationConfig
|
from .models import RecommerceIntegration, WooCommerceIntegration
|
||||||
|
|
||||||
|
|
||||||
@admin.register(IntegrationConfig)
|
@admin.register(RecommerceIntegration)
|
||||||
class IntegrationConfigAdmin(admin.ModelAdmin):
|
class RecommerceIntegrationAdmin(admin.ModelAdmin):
|
||||||
"""Админка для настроек интеграций (тумблеров)"""
|
"""Админка для Recommerce интеграции"""
|
||||||
list_display = ['get_integration_id_display', 'is_enabled', 'last_sync_at', 'updated_at']
|
list_display = ['__str__', 'is_active', 'is_configured', 'updated_at']
|
||||||
list_filter = ['is_enabled', 'integration_id']
|
list_filter = ['is_active']
|
||||||
search_fields = ['integration_id']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
list_editable = ['is_enabled']
|
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
fieldsets = (
|
||||||
"""Запретить добавление новых интеграций вручную"""
|
('Основное', {'fields': ('name', 'is_active')}),
|
||||||
return False
|
('API настройки', {'fields': ('store_url', 'api_token')}),
|
||||||
|
('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}),
|
||||||
def has_delete_permission(self, request, obj=None):
|
('Служебное', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}),
|
||||||
"""Запретить удаление интеграций"""
|
)
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# Регистрация конкретных интеграций (когда будут готовы):
|
@admin.register(WooCommerceIntegration)
|
||||||
#
|
class WooCommerceIntegrationAdmin(admin.ModelAdmin):
|
||||||
# @admin.register(WooCommerceIntegration)
|
"""Админка для WooCommerce интеграции"""
|
||||||
# class WooCommerceIntegrationAdmin(admin.ModelAdmin):
|
list_display = ['__str__', 'is_active', 'is_configured', 'updated_at']
|
||||||
# list_display = ['name', 'store_url', 'is_active', 'is_configured', 'updated_at']
|
list_filter = ['is_active']
|
||||||
# list_filter = ['is_active', 'auto_sync_products']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
# 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')}),
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
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',)}),
|
||||||
|
)
|
||||||
|
|||||||
116
myproject/integrations/fields.py
Normal file
116
myproject/integrations/fields.py
Normal file
@@ -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
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
myproject/integrations/migrations/__init__.py
Normal file
0
myproject/integrations/migrations/__init__.py
Normal file
@@ -1,6 +1,5 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationType(models.TextChoices):
|
class IntegrationType(models.TextChoices):
|
||||||
@@ -12,7 +11,9 @@ class IntegrationType(models.TextChoices):
|
|||||||
class BaseIntegration(models.Model):
|
class BaseIntegration(models.Model):
|
||||||
"""
|
"""
|
||||||
Абстрактный базовый класс для всех интеграций.
|
Абстрактный базовый класс для всех интеграций.
|
||||||
Содержит общие поля и логику валидации.
|
|
||||||
|
Singleton-паттерн: каждая конкретная интеграция имеет только одну запись в БД.
|
||||||
|
Поле is_active служит глобальным тумблером включения/выключения.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
integration_type = models.CharField(
|
integration_type = models.CharField(
|
||||||
@@ -23,16 +24,18 @@ class BaseIntegration(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
is_active = models.BooleanField(
|
is_active = models.BooleanField(
|
||||||
default=True,
|
default=False,
|
||||||
verbose_name="Активна",
|
verbose_name="Активна",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Интеграция используется только если включена здесь и в системных настройках"
|
help_text="Глобальный тумблер включения интеграции"
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
verbose_name="Название",
|
verbose_name="Название",
|
||||||
help_text="Произвольное название для удобства"
|
help_text="Произвольное название для удобства (опционально)"
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at = models.DateTimeField(
|
created_at = models.DateTimeField(
|
||||||
@@ -45,21 +48,6 @@ class BaseIntegration(models.Model):
|
|||||||
verbose_name="Дата обновления"
|
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 для гибкости
|
# Дополнительные настройки в JSON для гибкости
|
||||||
extra_config = models.JSONField(
|
extra_config = models.JSONField(
|
||||||
default=dict,
|
default=dict,
|
||||||
@@ -73,21 +61,29 @@ class BaseIntegration(models.Model):
|
|||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['is_active', 'integration_type']),
|
models.Index(fields=['is_active', 'integration_type']),
|
||||||
]
|
]
|
||||||
|
# Singleton: только одна запись на тип интеграции
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=['integration_type'],
|
||||||
|
name='%(class)s_singleton'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
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
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Проверить, есть ли необходимые credentials.
|
Проверить, есть ли необходимые credentials.
|
||||||
Можно переопределить в наследниках.
|
Переопределить в наследниках.
|
||||||
"""
|
"""
|
||||||
return bool(self.api_key)
|
return False
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Валидацияcredentials"""
|
"""Валидация: нельзя включить ненастроенную интеграцию"""
|
||||||
if self.is_active and not self.is_configured:
|
if self.is_active and not self.is_configured:
|
||||||
raise ValidationError({
|
raise ValidationError(
|
||||||
'api_key': 'API ключ обязателен для активной интеграции'
|
'Невозможно включить интеграцию без настроенных credentials'
|
||||||
})
|
)
|
||||||
|
|||||||
@@ -1,59 +1,36 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from .base import MarketplaceIntegration
|
from .base import MarketplaceIntegration
|
||||||
|
from ...fields import EncryptedCharField
|
||||||
|
|
||||||
|
|
||||||
class RecommerceIntegration(MarketplaceIntegration):
|
class RecommerceIntegration(MarketplaceIntegration):
|
||||||
"""
|
"""
|
||||||
Интеграция с Recommerce.
|
Интеграция с Recommerce.
|
||||||
Recommerce - сервис для управления товарами на маркетплейсах.
|
|
||||||
|
Recommerce - платформа для управления интернет-магазином.
|
||||||
|
API документация: запросы отправляются на домен магазина с заголовком x-auth-token.
|
||||||
|
|
||||||
|
Обязательные настройки:
|
||||||
|
- store_url: URL магазина (домен для API запросов)
|
||||||
|
- api_token: токен авторизации (передаётся в заголовке x-auth-token)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# API endpoint (может отличаться от store_url)
|
# API токен (x-auth-token) - ЗАШИФРОВАН
|
||||||
api_endpoint = models.URLField(
|
api_token = EncryptedCharField(
|
||||||
blank=True,
|
|
||||||
verbose_name="API Endpoint",
|
|
||||||
help_text="URL API Recommerce (если отличается от URL магазина)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# API токен (основной метод авторизации)
|
|
||||||
api_token = models.CharField(
|
|
||||||
max_length=500,
|
max_length=500,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="API Токен",
|
verbose_name="API Токен (x-auth-token)",
|
||||||
help_text="Токен авторизации Recommerce API"
|
help_text="Токен авторизации из панели управления Recommerce"
|
||||||
)
|
|
||||||
|
|
||||||
# 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="Обновлять остатки на маркетплейсе"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Recommerce"
|
verbose_name = "Recommerce"
|
||||||
verbose_name_plural = "Recommerce"
|
verbose_name_plural = "Recommerce"
|
||||||
managed = False # Пока заготовка - без создания таблицы
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Recommerce: {self.name or self.merchant_id}"
|
return f"Recommerce: {self.name or self.store_url or 'не настроен'}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
"""Recommerce требует api_token"""
|
"""Recommerce требует store_url и api_token"""
|
||||||
return bool(self.api_token)
|
return bool(self.store_url and self.api_token)
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from .base import MarketplaceIntegration
|
from .base import MarketplaceIntegration
|
||||||
|
from ...fields import EncryptedCharField
|
||||||
|
|
||||||
|
|
||||||
class WooCommerceIntegration(MarketplaceIntegration):
|
class WooCommerceIntegration(MarketplaceIntegration):
|
||||||
"""Интеграция с WooCommerce"""
|
"""
|
||||||
|
Интеграция с WooCommerce.
|
||||||
|
WooCommerce - плагин электронной коммерции для WordPress.
|
||||||
|
"""
|
||||||
|
|
||||||
# WooCommerce-specific credentials
|
# WooCommerce REST API credentials - ЗАШИФРОВАНЫ
|
||||||
consumer_key = models.CharField(
|
consumer_key = EncryptedCharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
blank=True,
|
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,
|
max_length=255,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="Consumer Secret"
|
verbose_name="Consumer Secret",
|
||||||
|
help_text="REST API Consumer Secret (хранится зашифрованным)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# API версия (WooCommerce REST API v1, v2, v3)
|
# API версия (WooCommerce REST API v1, v2, v3)
|
||||||
@@ -23,18 +29,18 @@ class WooCommerceIntegration(MarketplaceIntegration):
|
|||||||
max_length=10,
|
max_length=10,
|
||||||
default='v3',
|
default='v3',
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="Версия API"
|
verbose_name="Версия API",
|
||||||
|
help_text="Версия WooCommerce REST API"
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "WooCommerce"
|
verbose_name = "WooCommerce"
|
||||||
verbose_name_plural = "WooCommerce"
|
verbose_name_plural = "WooCommerce"
|
||||||
managed = False # Пока заготовка - без создания таблицы
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"WooCommerce: {self.name or self.store_url}"
|
return f"WooCommerce: {self.name or self.store_url or 'не настроен'}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
"""WooCommerce требует consumer_key и consumer_secret"""
|
"""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)
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
from django.urls import path
|
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'
|
app_name = 'integrations'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# Страница со списком интеграций
|
||||||
path("", IntegrationsListView.as_view(), name="list"),
|
path("", IntegrationsListView.as_view(), name="list"),
|
||||||
# Здесь будут добавляться endpoint'ы для интеграций
|
|
||||||
# path("test/<int:id>/", views.test_connection, name="test_connection"),
|
# API endpoints для управления интеграциями
|
||||||
# path("webhook/<str:integration_type>/", views.webhook, name="webhook"),
|
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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
|
import json
|
||||||
from django.views.generic import TemplateView
|
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 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):
|
class IntegrationsListView(OwnerRequiredMixin, TemplateView):
|
||||||
@@ -8,6 +49,176 @@ class IntegrationsListView(OwnerRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
from system_settings.models import IntegrationConfig
|
integrations = get_all_integrations_status()
|
||||||
context['integration_choices'] = IntegrationConfig.INTEGRATION_CHOICES
|
context['integrations'] = integrations
|
||||||
|
# JSON для JavaScript
|
||||||
|
context['integrations_json'] = json.dumps(integrations, ensure_ascii=False)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def toggle_integration(request, integration_id: str):
|
||||||
|
"""
|
||||||
|
API endpoint для включения/выключения интеграции.
|
||||||
|
POST /integrations/toggle/<integration_id>/
|
||||||
|
|
||||||
|
Создаёт запись если не существует (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/<integration_id>/
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -613,3 +613,21 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
# Увеличиваем лимиты для загрузки больших списков (10 000+ клиентов)
|
# Увеличиваем лимиты для загрузки больших списков (10 000+ клиентов)
|
||||||
DATA_UPLOAD_MAX_NUMBER_FIELDS = 50000
|
DATA_UPLOAD_MAX_NUMBER_FIELDS = 50000
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 104857600 # 100MB
|
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())\""
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ click-didyoumean==0.3.1
|
|||||||
click-plugins==1.1.1.2
|
click-plugins==1.1.1.2
|
||||||
click-repl==0.3.0
|
click-repl==0.3.0
|
||||||
colorama==0.4.6
|
colorama==0.4.6
|
||||||
|
cryptography==44.0.0
|
||||||
Django==5.0.10
|
Django==5.0.10
|
||||||
django-celery-results==2.5.1
|
django-celery-results==2.5.1
|
||||||
django-debug-toolbar==6.1.0
|
django-debug-toolbar==6.1.0
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
from .integration_config import IntegrationConfig
|
# Models for system_settings app
|
||||||
|
# IntegrationConfig removed - using integrations app models directly
|
||||||
__all__ = ['IntegrationConfig']
|
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
@@ -10,56 +10,333 @@
|
|||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0">Доступные интеграции</h5>
|
<h5 class="mb-0">Доступные интеграции</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-0">
|
||||||
{% for value, label in integration_choices %}
|
{% for key, integration in integrations.items %}
|
||||||
<div class="d-flex align-items-center justify-content-between py-2 border-bottom">
|
<div class="integration-item d-flex align-items-center justify-content-between p-3 border-bottom {% if integration.is_active %}bg-success bg-opacity-10{% endif %}"
|
||||||
|
data-integration="{{ key }}"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<span class="fw-medium">{{ label }}</span>
|
<span class="fw-medium">{{ integration.label }}</span>
|
||||||
<small class="text-muted d-block">Маркетплейс</small>
|
<small class="text-muted d-block">{{ integration.category }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch">
|
{% if integration.is_configured %}
|
||||||
|
<span class="badge bg-success ms-2" title="Настроена">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch m-0">
|
||||||
<input class="form-check-input integration-toggle"
|
<input class="form-check-input integration-toggle"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-integration="{{ value }}"
|
data-integration="{{ key }}"
|
||||||
id="integration-{{ value }}">
|
id="toggle-{{ key }}"
|
||||||
<label class="form-check-label" for="integration-{{ value }}"></label>
|
{% if integration.is_active %}checked{% endif %}
|
||||||
|
style="cursor: pointer;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="p-4 text-center text-muted">
|
||||||
|
Нет доступных интеграций
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Правая колонка: placeholder для настроек -->
|
<!-- Правая колонка: форма настроек выбранной интеграции -->
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0">Настройки интеграции</h5>
|
<h5 class="mb-0" id="settings-title">Настройки интеграции</h5>
|
||||||
|
<span class="badge bg-secondary" id="settings-status" style="display: none;"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="text-center text-muted py-5">
|
<!-- Placeholder когда ничего не выбрано -->
|
||||||
|
<div id="settings-placeholder" class="text-center text-muted py-5">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-plug mb-3" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-plug mb-3" viewBox="0 0 16 16">
|
||||||
<path d="M6 0a.5.5 0 0 1 .5.5V3h3V.5a.5.5 0 0 1 1 0V3h1v2.5a1.5 1.5 0 0 1-1 1.25v4.5a.5.5 0 0 1-1 0v-4.25c-.286.14-.6.25-1 .25a2.5 2.5 0 0 1-1-.25v4.25a.5.5 0 0 1-1 0v-4.5a1.5 1.5 0 0 1-1-1.25V3h1V.5A.5.5 0 0 1 6 0Zm0 3a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 6 3Zm3.5-.5a.5.5 0 0 1 1 0v2a.5.5 0 0 1-1 0v-2Z"/>
|
<path d="M6 0a.5.5 0 0 1 .5.5V3h3V.5a.5.5 0 0 1 1 0V3h1a.5.5 0 0 1 .5.5v3A3.5 3.5 0 0 1 8.5 10c-.002.434-.01.845-.04 1.22-.041.514-.126 1.003-.317 1.424a2.083 2.083 0 0 1-.97 1.028C6.725 13.9 6.169 14 5.5 14c-.998 0-1.61.33-1.974.718A1.922 1.922 0 0 0 3 16H2c0-.616.232-1.367.797-1.968C3.374 13.42 4.261 13 5.5 13c.581 0 .962-.088 1.218-.219.241-.123.4-.3.514-.55.121-.266.193-.621.23-1.09.027-.34.035-.718.037-1.141A3.5 3.5 0 0 1 4 6.5v-3a.5.5 0 0 1 .5-.5h1V.5A.5.5 0 0 1 6 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="mb-0">Выберите интеграцию слева для настройки</p>
|
<p class="mb-0">Выберите интеграцию слева для настройки</p>
|
||||||
<small class="text-muted">Здесь появится форма с настройками выбранной интеграции</small>
|
<small class="text-muted">Кликните на название или тумблер</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма настроек (скрыта по умолчанию) -->
|
||||||
|
<div id="settings-form-container" style="display: none;">
|
||||||
|
<form id="settings-form">
|
||||||
|
<div id="settings-fields">
|
||||||
|
<!-- Поля генерируются динамически -->
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Сохранить настройки
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="test-connection-btn" style="display: none;">
|
||||||
|
Проверить подключение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Индикатор загрузки -->
|
||||||
|
<div id="settings-loading" class="text-center py-5" style="display: none;">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- JavaScript для переключения интеграций (placeholder) -->
|
<!-- Toast для уведомлений -->
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
<div id="notification-toast" class="toast" role="alert">
|
||||||
|
<div class="toast-header">
|
||||||
|
<strong class="me-auto" id="toast-title">Уведомление</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body" id="toast-message"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.querySelectorAll('.integration-toggle').forEach(toggle => {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
toggle.addEventListener('change', function() {
|
const csrfToken = '{{ csrf_token }}';
|
||||||
const integration = this.dataset.integration;
|
let currentIntegration = null;
|
||||||
const isEnabled = this.checked;
|
|
||||||
|
|
||||||
// TODO: отправить состояние на сервер
|
// Toast helper
|
||||||
console.log(`Интеграция ${integration}: ${isEnabled ? 'включена' : 'выключена'}`);
|
function showToast(title, message, isError = false) {
|
||||||
|
const toast = document.getElementById('notification-toast');
|
||||||
|
document.getElementById('toast-title').textContent = title;
|
||||||
|
document.getElementById('toast-message').textContent = message;
|
||||||
|
toast.classList.toggle('bg-danger', isError);
|
||||||
|
toast.classList.toggle('text-white', isError);
|
||||||
|
const bsToast = new bootstrap.Toast(toast);
|
||||||
|
bsToast.show();
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: показать/скрыть блок настроек справа
|
// Загрузка формы настроек
|
||||||
|
async function loadSettingsForm(integrationId) {
|
||||||
|
currentIntegration = integrationId;
|
||||||
|
|
||||||
|
// Показать загрузку
|
||||||
|
document.getElementById('settings-placeholder').style.display = 'none';
|
||||||
|
document.getElementById('settings-form-container').style.display = 'none';
|
||||||
|
document.getElementById('settings-loading').style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/settings/integrations/form/${integrationId}/`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
showToast('Ошибка', data.error, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить заголовок
|
||||||
|
const integrationData = {{ integrations_json|safe }};
|
||||||
|
document.getElementById('settings-title').textContent =
|
||||||
|
`Настройки: ${integrationData[integrationId]?.label || integrationId}`;
|
||||||
|
|
||||||
|
// Обновить статус
|
||||||
|
const statusBadge = document.getElementById('settings-status');
|
||||||
|
if (data.is_active) {
|
||||||
|
statusBadge.textContent = 'Включена';
|
||||||
|
statusBadge.className = 'badge bg-success';
|
||||||
|
} else {
|
||||||
|
statusBadge.textContent = 'Выключена';
|
||||||
|
statusBadge.className = 'badge bg-secondary';
|
||||||
|
}
|
||||||
|
statusBadge.style.display = 'inline';
|
||||||
|
|
||||||
|
// Построить форму
|
||||||
|
buildForm(data.fields, data.data || {});
|
||||||
|
|
||||||
|
// Показать форму
|
||||||
|
document.getElementById('settings-loading').style.display = 'none';
|
||||||
|
document.getElementById('settings-form-container').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading form:', error);
|
||||||
|
showToast('Ошибка', 'Не удалось загрузить настройки', true);
|
||||||
|
document.getElementById('settings-loading').style.display = 'none';
|
||||||
|
document.getElementById('settings-placeholder').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Построение формы из метаданных полей
|
||||||
|
function buildForm(fields, data) {
|
||||||
|
const container = document.getElementById('settings-fields');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'mb-3';
|
||||||
|
|
||||||
|
if (field.type === 'checkbox') {
|
||||||
|
div.className = 'form-check mb-3';
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="checkbox" class="form-check-input" id="field-${field.name}"
|
||||||
|
name="${field.name}" ${data[field.name] ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="field-${field.name}">${field.label}</label>
|
||||||
|
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text');
|
||||||
|
const value = data[field.name] || '';
|
||||||
|
const placeholder = field.type === 'password' && value === '........' ? 'Введите новое значение для изменения' : '';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<label class="form-label" for="field-${field.name}">
|
||||||
|
${field.label}
|
||||||
|
${field.required ? '<span class="text-danger">*</span>' : ''}
|
||||||
|
</label>
|
||||||
|
<input type="${inputType}" class="form-control" id="field-${field.name}"
|
||||||
|
name="${field.name}" value="${value !== '........' ? value : ''}"
|
||||||
|
placeholder="${placeholder}"
|
||||||
|
${field.required ? 'required' : ''}>
|
||||||
|
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик клика на интеграцию
|
||||||
|
document.querySelectorAll('.integration-item').forEach(item => {
|
||||||
|
item.addEventListener('click', function(e) {
|
||||||
|
// Не обрабатывать клик на самом тумблере
|
||||||
|
if (e.target.classList.contains('integration-toggle')) return;
|
||||||
|
|
||||||
|
const integrationId = this.dataset.integration;
|
||||||
|
loadSettingsForm(integrationId);
|
||||||
|
|
||||||
|
// Подсветить выбранный
|
||||||
|
document.querySelectorAll('.integration-item').forEach(i => i.classList.remove('border-primary'));
|
||||||
|
this.classList.add('border-primary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчик тумблера
|
||||||
|
document.querySelectorAll('.integration-toggle').forEach(toggle => {
|
||||||
|
toggle.addEventListener('change', async function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const integrationId = this.dataset.integration;
|
||||||
|
const checkbox = this;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/settings/integrations/toggle/${integrationId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Откатить состояние
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
showToast('Ошибка', data.error || 'Не удалось изменить состояние', true);
|
||||||
|
|
||||||
|
// Открыть настройки если не настроено
|
||||||
|
if (!data.is_configured) {
|
||||||
|
loadSettingsForm(integrationId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить UI
|
||||||
|
const item = checkbox.closest('.integration-item');
|
||||||
|
if (data.is_active) {
|
||||||
|
item.classList.add('bg-success', 'bg-opacity-10');
|
||||||
|
showToast('Успех', 'Интеграция включена');
|
||||||
|
} else {
|
||||||
|
item.classList.remove('bg-success', 'bg-opacity-10');
|
||||||
|
showToast('Успех', 'Интеграция выключена');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить статус в форме если открыта
|
||||||
|
if (currentIntegration === integrationId) {
|
||||||
|
const statusBadge = document.getElementById('settings-status');
|
||||||
|
if (data.is_active) {
|
||||||
|
statusBadge.textContent = 'Включена';
|
||||||
|
statusBadge.className = 'badge bg-success';
|
||||||
|
} else {
|
||||||
|
statusBadge.textContent = 'Выключена';
|
||||||
|
statusBadge.className = 'badge bg-secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
showToast('Ошибка', 'Ошибка сети', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сохранение настроек
|
||||||
|
document.getElementById('settings-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!currentIntegration) return;
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const data = {};
|
||||||
|
|
||||||
|
// Собрать данные формы
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
// Пропустить пустые password поля (не менять если не введено)
|
||||||
|
const input = document.getElementById(`field-${key}`);
|
||||||
|
if (input && input.type === 'password' && !value) continue;
|
||||||
|
data[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавить checkbox'ы (они не попадают в FormData если не checked)
|
||||||
|
this.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||||
|
data[cb.name] = cb.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/settings/integrations/settings/${currentIntegration}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
showToast('Ошибка', result.error || 'Не удалось сохранить', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Успех', 'Настройки сохранены');
|
||||||
|
|
||||||
|
// Обновить бейдж "настроено" в списке
|
||||||
|
const item = document.querySelector(`.integration-item[data-integration="${currentIntegration}"]`);
|
||||||
|
if (result.is_configured) {
|
||||||
|
if (!item.querySelector('.badge.bg-success')) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'badge bg-success ms-2';
|
||||||
|
badge.title = 'Настроена';
|
||||||
|
badge.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>';
|
||||||
|
item.querySelector('.fw-medium').after(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Ошибка', 'Ошибка сети', true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user