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,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/<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
|
||||
|
||||
Reference in New Issue
Block a user