- Remove hardcoded OPENROUTER_MODEL_CHOICES from openrouter.py
- Add API endpoint /integrations/openrouter/models/ to fetch models dynamically
- Models loaded from OpenRouter API with free models (':free') at top
- Update OpenRouterIntegration model_name field (remove choices, blank=True)
- Add async buildForm() with dynamic_choices support
- Show asterisks (********) for saved API keys with helpful placeholder
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
481 lines
20 KiB
Python
481 lines
20 KiB
Python
import json
|
||
import logging
|
||
from django.views.generic import TemplateView
|
||
from django.http import JsonResponse
|
||
from django.views.decorators.http import require_POST, require_GET
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from user_roles.mixins import OwnerRequiredMixin
|
||
from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration
|
||
from integrations.recommerce.tasks import sync_products_batch_task
|
||
|
||
|
||
# Реестр доступных интеграций
|
||
# Ключ = идентификатор для URL/JS, значение = (модель, название для UI)
|
||
INTEGRATION_REGISTRY = {
|
||
'recommerce': (RecommerceIntegration, 'Recommerce', 'Маркетплейс'),
|
||
'woocommerce': (WooCommerceIntegration, 'WooCommerce', 'Маркетплейс'),
|
||
'glm': (GLMIntegration, 'GLM от Z.AI', 'Сервис ИИ'),
|
||
'openrouter': (OpenRouterIntegration, 'OpenRouter.ai', 'Сервис ИИ'),
|
||
# Добавлять новые интеграции здесь:
|
||
# '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):
|
||
"""Страница настроек интеграций (доступна только владельцу)"""
|
||
template_name = "system_settings/integrations.html"
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
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 test_integration_connection(request, integration_id: str):
|
||
"""
|
||
Тестировать соединение с интеграцией.
|
||
POST /integrations/test/<integration_id>/
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
logger.info(f"=== Начало тестирования интеграции: {integration_id} ===")
|
||
|
||
if not hasattr(request, 'user') or not request.user.is_authenticated:
|
||
logger.error("Пользователь не авторизован")
|
||
return JsonResponse({'error': 'Unauthorized'}, status=401)
|
||
|
||
model = get_integration_model(integration_id)
|
||
if not model:
|
||
logger.error(f"Неизвестная интеграция: {integration_id}")
|
||
return JsonResponse({'error': f'Unknown integration: {integration_id}'}, status=404)
|
||
|
||
instance = model.objects.first()
|
||
if not instance:
|
||
logger.error(f"Интеграция {integration_id} не настроена (нет записи в БД)")
|
||
return JsonResponse({'error': 'Интеграция не настроена'}, status=400)
|
||
|
||
logger.info(f"Найдена интеграция: {instance.name}, is_active={instance.is_active}, is_configured={instance.is_configured}")
|
||
|
||
if not instance.is_configured:
|
||
logger.error(f"Интеграция {integration_id} не сконфигурирована")
|
||
return JsonResponse({'error': 'Заполните настройки интеграции'}, status=400)
|
||
|
||
# Получить сервис для интеграции
|
||
try:
|
||
logger.info(f"Попытка получить сервис для {integration_id}...")
|
||
service = get_integration_service(integration_id, instance)
|
||
if not service:
|
||
logger.error(f"Сервис для {integration_id} не реализован")
|
||
return JsonResponse({'error': 'Сервис не реализован'}, status=501)
|
||
logger.info(f"Сервис успешно создан: {service.__class__.__name__}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при создании сервиса для {integration_id}: {str(e)}", exc_info=True)
|
||
return JsonResponse({'error': f'Ошибка при создании сервиса: {str(e)}'}, status=500)
|
||
|
||
# Выполнить тест
|
||
try:
|
||
logger.info(f"Выполнение теста соединения...")
|
||
success, message = service.test_connection()
|
||
logger.info(f"Результат теста: success={success}, message={message}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при выполнении теста соединения: {str(e)}", exc_info=True)
|
||
return JsonResponse({'error': f'Ошибка при тестировании: {str(e)}'}, status=500)
|
||
|
||
return JsonResponse({
|
||
'success': success,
|
||
'message': message,
|
||
})
|
||
|
||
|
||
def get_integration_service(integration_id: str, instance):
|
||
"""Получить сервис для интеграции"""
|
||
if integration_id == 'recommerce':
|
||
from .services.marketplaces.recommerce import RecommerceService
|
||
return RecommerceService(instance)
|
||
elif integration_id == 'woocommerce':
|
||
from .services.marketplaces.woocommerce import WooCommerceService
|
||
return WooCommerceService(instance)
|
||
elif integration_id == 'glm':
|
||
from .services.ai_services.glm_service import GLMIntegrationService
|
||
return GLMIntegrationService(instance)
|
||
elif integration_id == 'openrouter':
|
||
from .services.ai_services.openrouter_service import OpenRouterIntegrationService
|
||
return OpenRouterIntegrationService(instance)
|
||
return None
|
||
|
||
|
||
@require_GET
|
||
def get_openrouter_models(request):
|
||
"""
|
||
GET /settings/integrations/openrouter/models/
|
||
Возвращает список моделей OpenRouter (бесплатные сверху)
|
||
"""
|
||
import requests
|
||
|
||
try:
|
||
response = requests.get('https://openrouter.ai/api/v1/models', timeout=10)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
models = data.get('data', [])
|
||
|
||
# Разделить на бесплатные и платные
|
||
free_models = []
|
||
paid_models = []
|
||
|
||
for model in models:
|
||
model_id = model.get('id', '')
|
||
model_name = model.get('name', model_id)
|
||
|
||
if ':free' in model_id:
|
||
free_models.append({'id': model_id, 'name': f"{model_name} (Бесплатная)"})
|
||
else:
|
||
paid_models.append({'id': model_id, 'name': model_name})
|
||
|
||
# Бесплатные сверху
|
||
all_models = free_models + paid_models
|
||
|
||
return JsonResponse({'models': all_models})
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error fetching OpenRouter models: {e}")
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
|
||
|
||
class RecommerceBatchSyncView(TemplateView):
|
||
"""
|
||
API View для запуска массовой синхронизации с Recommerce.
|
||
POST /integrations/recommerce/sync/
|
||
"""
|
||
def dispatch(self, request, *args, **kwargs):
|
||
# Временное логирование для отладки
|
||
from user_roles.services import RoleService
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.info(f"User: {request.user}, Authenticated: {request.user.is_authenticated}")
|
||
user_role = RoleService.get_user_role(request.user)
|
||
logger.info(f"User role: {user_role}")
|
||
return super().dispatch(request, *args, **kwargs)
|
||
def post(self, request, *args, **kwargs):
|
||
try:
|
||
data = json.loads(request.body)
|
||
product_ids = data.get('product_ids', [])
|
||
options = data.get('options', {})
|
||
|
||
if not product_ids:
|
||
return JsonResponse({'error': 'No products selected'}, status=400)
|
||
|
||
# Запуск Celery задачи с передачей schema_name
|
||
from django_tenants.utils import get_tenant_model
|
||
Tenant = get_tenant_model()
|
||
schema_name = request.tenant.schema_name
|
||
task = sync_products_batch_task.delay(product_ids, options, schema_name)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'task_id': task.id,
|
||
'message': f'Запущена синхронизация {len(product_ids)} товаров'
|
||
})
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
|
||
|
||
@require_POST
|
||
def save_integration_settings(request, integration_id: str):
|
||
"""
|
||
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 = []
|
||
editable_fields = get_editable_fields(model)
|
||
|
||
# Для GLM отображаем только имя и API ключ
|
||
if model.__name__ == 'GLMIntegration':
|
||
for field_name in editable_fields:
|
||
if field_name in ['name', 'api_key']:
|
||
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 '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)
|
||
elif field_name == 'model_name':
|
||
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': 'select', # dropdown
|
||
'choices': getattr(field, 'choices', [])
|
||
}
|
||
|
||
fields.append(field_info)
|
||
# Для OpenRouter отображаем только имя, API ключ, модель и температуру
|
||
elif model.__name__ == 'OpenRouterIntegration':
|
||
for field_name in editable_fields:
|
||
if field_name in ['name', 'api_key']:
|
||
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': 'password' if field_name == 'api_key' else 'text',
|
||
}
|
||
fields.append(field_info)
|
||
|
||
elif field_name == 'temperature':
|
||
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': 'select',
|
||
'choices': getattr(field, 'choices', [])
|
||
}
|
||
fields.append(field_info)
|
||
|
||
elif field_name == 'model_name':
|
||
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': 'select',
|
||
'dynamic_choices': True,
|
||
'choices_url': '/settings/integrations/openrouter/models/'
|
||
}
|
||
fields.append(field_info)
|
||
# Для WooCommerce показываем только базовые поля для подключения
|
||
elif model.__name__ == 'WooCommerceIntegration':
|
||
basic_fields = ['store_url', 'consumer_key', 'consumer_secret']
|
||
for field_name in editable_fields:
|
||
if field_name in basic_fields:
|
||
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',
|
||
}
|
||
|
||
# Определить тип поля
|
||
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 'key' in field_name.lower():
|
||
field_info['type'] = 'password'
|
||
|
||
fields.append(field_info)
|
||
else:
|
||
# Для других интеграций - все редактируемые поля
|
||
for field_name in editable_fields:
|
||
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 '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
|