Files
Andrey Smakotin 5ec5ee48d4 feat(integrations): add dynamic OpenRouter model loading
- 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>
2026-01-23 18:16:12 +03:00

481 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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