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:
@@ -10,56 +10,333 @@
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Доступные интеграции</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for value, label in integration_choices %}
|
||||
<div class="d-flex align-items-center justify-content-between py-2 border-bottom">
|
||||
<div>
|
||||
<span class="fw-medium">{{ label }}</span>
|
||||
<small class="text-muted d-block">Маркетплейс</small>
|
||||
<div class="card-body p-0">
|
||||
{% for key, integration in integrations.items %}
|
||||
<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>
|
||||
<span class="fw-medium">{{ integration.label }}</span>
|
||||
<small class="text-muted d-block">{{ integration.category }}</small>
|
||||
</div>
|
||||
{% 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">
|
||||
<div class="form-check form-switch m-0">
|
||||
<input class="form-check-input integration-toggle"
|
||||
type="checkbox"
|
||||
data-integration="{{ value }}"
|
||||
id="integration-{{ value }}">
|
||||
<label class="form-check-label" for="integration-{{ value }}"></label>
|
||||
data-integration="{{ key }}"
|
||||
id="toggle-{{ key }}"
|
||||
{% if integration.is_active %}checked{% endif %}
|
||||
style="cursor: pointer;">
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-4 text-center text-muted">
|
||||
Нет доступных интеграций
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая колонка: placeholder для настроек -->
|
||||
<!-- Правая колонка: форма настроек выбранной интеграции -->
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">Настройки интеграции</h5>
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0" id="settings-title">Настройки интеграции</h5>
|
||||
<span class="badge bg-secondary" id="settings-status" style="display: none;"></span>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
document.querySelectorAll('.integration-toggle').forEach(toggle => {
|
||||
toggle.addEventListener('change', function() {
|
||||
const integration = this.dataset.integration;
|
||||
const isEnabled = this.checked;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const csrfToken = '{{ csrf_token }}';
|
||||
let currentIntegration = null;
|
||||
|
||||
// TODO: отправить состояние на сервер
|
||||
console.log(`Интеграция ${integration}: ${isEnabled ? 'включена' : 'выключена'}`);
|
||||
// Toast helper
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user