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:
2026-01-12 00:29:04 +03:00
parent 4629369823
commit 37394121e1
14 changed files with 804 additions and 200 deletions

View File

@@ -1,44 +1,32 @@
from django.contrib import admin
from system_settings.models import IntegrationConfig
from .models import RecommerceIntegration, WooCommerceIntegration
@admin.register(IntegrationConfig)
class IntegrationConfigAdmin(admin.ModelAdmin):
"""Админка для настроек интеграций (тумблеров)"""
list_display = ['get_integration_id_display', 'is_enabled', 'last_sync_at', 'updated_at']
list_filter = ['is_enabled', 'integration_id']
search_fields = ['integration_id']
list_editable = ['is_enabled']
@admin.register(RecommerceIntegration)
class RecommerceIntegrationAdmin(admin.ModelAdmin):
"""Админка для Recommerce интеграции"""
list_display = ['__str__', 'is_active', 'is_configured', 'updated_at']
list_filter = ['is_active']
readonly_fields = ['created_at', 'updated_at']
def has_add_permission(self, request):
"""Запретить добавление новых интеграций вручную"""
return False
def has_delete_permission(self, request, obj=None):
"""Запретить удаление интеграций"""
return False
fieldsets = (
('Основное', {'fields': ('name', 'is_active')}),
('API настройки', {'fields': ('store_url', 'api_token')}),
('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}),
('Служебное', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}),
)
# Регистрация конкретных интеграций (когда будут готовы):
#
# @admin.register(WooCommerceIntegration)
# class WooCommerceIntegrationAdmin(admin.ModelAdmin):
# list_display = ['name', 'store_url', 'is_active', 'is_configured', 'updated_at']
# list_filter = ['is_active', 'auto_sync_products']
# fieldsets = (
# ('Основное', {'fields': ('name', 'is_active')}),
# ('API настройки', {'fields': ('store_url', 'consumer_key', 'consumer_secret')}),
# ('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}),
# )
#
#
# @admin.register(RecommerceIntegration)
# class RecommerceIntegrationAdmin(admin.ModelAdmin):
# list_display = ['name', 'merchant_id', 'is_active', 'is_configured', 'updated_at']
# list_filter = ['is_active', 'sync_prices', 'sync_stock']
# fieldsets = (
# ('Основное', {'fields': ('name', 'is_active')}),
# ('API настройки', {'fields': ('store_url', 'api_endpoint', 'api_token', 'merchant_id')}),
# ('Синхронизация', {'fields': ('auto_sync_products', 'import_orders', 'sync_prices', 'sync_stock')}),
# )
@admin.register(WooCommerceIntegration)
class WooCommerceIntegrationAdmin(admin.ModelAdmin):
"""Админка для WooCommerce интеграции"""
list_display = ['__str__', 'is_active', 'is_configured', 'updated_at']
list_filter = ['is_active']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Основное', {'fields': ('name', 'is_active')}),
('API настройки', {'fields': ('store_url', 'consumer_key', 'consumer_secret', 'api_version')}),
('Синхронизация', {'fields': ('auto_sync_products', 'import_orders')}),
('Служебное', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}),
)

View 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

View File

@@ -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',
},
),
]

View File

@@ -1,6 +1,5 @@
from django.db import models
from django.core.exceptions import ValidationError
from abc import ABC, abstractmethod
class IntegrationType(models.TextChoices):
@@ -12,7 +11,9 @@ class IntegrationType(models.TextChoices):
class BaseIntegration(models.Model):
"""
Абстрактный базовый класс для всех интеграций.
Содержит общие поля и логику валидации.
Singleton-паттерн: каждая конкретная интеграция имеет только одну запись в БД.
Поле is_active служит глобальным тумблером включения/выключения.
"""
integration_type = models.CharField(
@@ -23,16 +24,18 @@ class BaseIntegration(models.Model):
)
is_active = models.BooleanField(
default=True,
default=False,
verbose_name="Активна",
db_index=True,
help_text="Интеграция используется только если включена здесь и в системных настройках"
help_text="Глобальный тумблер включения интеграции"
)
name = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name="Название",
help_text="Произвольное название для удобства"
help_text="Произвольное название для удобства (опционально)"
)
created_at = models.DateTimeField(
@@ -45,21 +48,6 @@ class BaseIntegration(models.Model):
verbose_name="Дата обновления"
)
# Общие credential поля (можно переопределить в наследниках)
api_key = models.CharField(
max_length=255,
blank=True,
verbose_name="API ключ",
help_text="Будет зашифрован при сохранении"
)
api_secret = models.CharField(
max_length=255,
blank=True,
verbose_name="API секрет",
help_text="Будет зашифрован при сохранении"
)
# Дополнительные настройки в JSON для гибкости
extra_config = models.JSONField(
default=dict,
@@ -73,21 +61,29 @@ class BaseIntegration(models.Model):
indexes = [
models.Index(fields=['is_active', 'integration_type']),
]
# Singleton: только одна запись на тип интеграции
constraints = [
models.UniqueConstraint(
fields=['integration_type'],
name='%(class)s_singleton'
)
]
def __str__(self):
return f"{self.get_integration_type_display()}: {self.name}"
status = "вкл" if self.is_active else "выкл"
return f"{self.get_integration_type_display()}: {self.name or self._meta.verbose_name} ({status})"
@property
def is_configured(self) -> bool:
"""
Проверить, есть ли необходимые credentials.
Можно переопределить в наследниках.
Переопределить в наследниках.
"""
return bool(self.api_key)
return False
def clean(self):
"""Валидацияcredentials"""
"""Валидация: нельзя включить ненастроенную интеграцию"""
if self.is_active and not self.is_configured:
raise ValidationError({
'api_key': 'API ключ обязателен для активной интеграции'
})
raise ValidationError(
'Невозможно включить интеграцию без настроенных credentials'
)

View File

@@ -1,59 +1,36 @@
from django.db import models
from .base import MarketplaceIntegration
from ...fields import EncryptedCharField
class RecommerceIntegration(MarketplaceIntegration):
"""
Интеграция с Recommerce.
Recommerce - сервис для управления товарами на маркетплейсах.
Recommerce - платформа для управления интернет-магазином.
API документация: запросы отправляются на домен магазина с заголовком x-auth-token.
Обязательные настройки:
- store_url: URL магазина (домен для API запросов)
- api_token: токен авторизации (передаётся в заголовке x-auth-token)
"""
# API endpoint (может отличаться от store_url)
api_endpoint = models.URLField(
blank=True,
verbose_name="API Endpoint",
help_text="URL API Recommerce (если отличается от URL магазина)"
)
# API токен (основной метод авторизации)
api_token = models.CharField(
# API токен (x-auth-token) - ЗАШИФРОВАН
api_token = EncryptedCharField(
max_length=500,
blank=True,
verbose_name="API Токен",
help_text="Токен авторизации Recommerce API"
)
# ID магазина в системе Recommerce
merchant_id = models.CharField(
max_length=100,
blank=True,
verbose_name="ID магазина",
help_text="Идентификатор магазина в Recommerce"
)
# Синхронизация цен
sync_prices = models.BooleanField(
default=True,
verbose_name="Синхронизировать цены",
help_text="Обновлять цены на маркетплейсе"
)
# Синхронизация остатков
sync_stock = models.BooleanField(
default=True,
verbose_name="Синхронизировать остатки",
help_text="Обновлять остатки на маркетплейсе"
verbose_name="API Токен (x-auth-token)",
help_text="Токен авторизации из панели управления Recommerce"
)
class Meta:
verbose_name = "Recommerce"
verbose_name_plural = "Recommerce"
managed = False # Пока заготовка - без создания таблицы
def __str__(self):
return f"Recommerce: {self.name or self.merchant_id}"
return f"Recommerce: {self.name or self.store_url or 'не настроен'}"
@property
def is_configured(self) -> bool:
"""Recommerce требует api_token"""
return bool(self.api_token)
"""Recommerce требует store_url и api_token"""
return bool(self.store_url and self.api_token)

View File

@@ -1,21 +1,27 @@
from django.db import models
from .base import MarketplaceIntegration
from ...fields import EncryptedCharField
class WooCommerceIntegration(MarketplaceIntegration):
"""Интеграция с WooCommerce"""
"""
Интеграция с WooCommerce.
WooCommerce - плагин электронной коммерции для WordPress.
"""
# WooCommerce-specific credentials
consumer_key = models.CharField(
# WooCommerce REST API credentials - ЗАШИФРОВАНЫ
consumer_key = EncryptedCharField(
max_length=255,
blank=True,
verbose_name="Consumer Key"
verbose_name="Consumer Key",
help_text="REST API Consumer Key (хранится зашифрованным)"
)
consumer_secret = models.CharField(
consumer_secret = EncryptedCharField(
max_length=255,
blank=True,
verbose_name="Consumer Secret"
verbose_name="Consumer Secret",
help_text="REST API Consumer Secret (хранится зашифрованным)"
)
# API версия (WooCommerce REST API v1, v2, v3)
@@ -23,18 +29,18 @@ class WooCommerceIntegration(MarketplaceIntegration):
max_length=10,
default='v3',
blank=True,
verbose_name="Версия API"
verbose_name="Версия API",
help_text="Версия WooCommerce REST API"
)
class Meta:
verbose_name = "WooCommerce"
verbose_name_plural = "WooCommerce"
managed = False # Пока заготовка - без создания таблицы
def __str__(self):
return f"WooCommerce: {self.name or self.store_url}"
return f"WooCommerce: {self.name or self.store_url or 'не настроен'}"
@property
def is_configured(self) -> bool:
"""WooCommerce требует consumer_key и consumer_secret"""
return bool(self.consumer_key and self.consumer_secret)
return bool(self.consumer_key and self.consumer_secret and self.store_url)

View File

@@ -1,11 +1,23 @@
from django.urls import path
from .views import IntegrationsListView
from .views import (
IntegrationsListView,
toggle_integration,
save_integration_settings,
get_integration_form_data,
)
app_name = 'integrations'
urlpatterns = [
# Страница со списком интеграций
path("", IntegrationsListView.as_view(), name="list"),
# Здесь будут добавляться endpoint'ы для интеграций
# path("test/<int:id>/", views.test_connection, name="test_connection"),
# path("webhook/<str:integration_type>/", views.webhook, name="webhook"),
# API endpoints для управления интеграциями
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"),
]

View File

@@ -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