Добавлен полнофункциональный экспорт/импорт профилей
- Кнопки 'убрать фон' для всех элементов: профиль, группы, ссылки - Кнопка 'сбросить настройки интерфейса' с подтверждением - Django app export_import с полным API для бэкапа и восстановления - Экспорт: создание ZIP архивов с данными профиля и медиафайлами - Импорт: селективная загрузка групп, ссылок, стилей, медиа - Обработка мультипарт форм, Django транзакции, управление ошибками - Полное тестирование: экспорт → импорт данных между пользователями - API эндпоинты: /api/export/, /api/import/, превью архивов - Готовая система для производственного развертывания
This commit is contained in:
@@ -60,6 +60,7 @@ INSTALLED_APPS = [
|
||||
'links',
|
||||
'customization',
|
||||
'api',
|
||||
'export_import',
|
||||
'rest_framework',
|
||||
'rest_framework_simplejwt',
|
||||
'django_extensions',
|
||||
|
||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('api.urls')), # API endpoints
|
||||
path('api/users/', include('users.urls')), # User management API
|
||||
path('api/', include('export_import.urls')), # Export/Import API
|
||||
path('users/', include('users.urls')), # User management app
|
||||
path('links/', include('links.urls')), # Link management app
|
||||
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
|
||||
0
backend/export_import/__init__.py
Normal file
0
backend/export_import/__init__.py
Normal file
3
backend/export_import/admin.py
Normal file
3
backend/export_import/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
7
backend/export_import/apps.py
Normal file
7
backend/export_import/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ExportImportConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'export_import'
|
||||
verbose_name = 'Export/Import'
|
||||
63
backend/export_import/migrations/0001_initial.py
Normal file
63
backend/export_import/migrations/0001_initial.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-09 05:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExportTask',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('pending', 'Ожидает выполнения'), ('processing', 'В процессе'), ('completed', 'Завершен'), ('failed', 'Ошибка')], default='pending', max_length=20, verbose_name='Статус')),
|
||||
('include_groups', models.BooleanField(default=True, verbose_name='Включить группы')),
|
||||
('include_links', models.BooleanField(default=True, verbose_name='Включить ссылки')),
|
||||
('include_styles', models.BooleanField(default=True, verbose_name='Включить стили')),
|
||||
('include_media', models.BooleanField(default=True, verbose_name='Включить медиафайлы')),
|
||||
('export_file', models.FileField(blank=True, null=True, upload_to='exports/', verbose_name='Файл экспорта')),
|
||||
('error_message', models.TextField(blank=True, verbose_name='Сообщение об ошибке')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Задача экспорта',
|
||||
'verbose_name_plural': 'Задачи экспорта',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ImportTask',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('pending', 'Ожидает выполнения'), ('processing', 'В процессе'), ('completed', 'Завершен'), ('failed', 'Ошибка')], default='pending', max_length=20, verbose_name='Статус')),
|
||||
('import_file', models.FileField(upload_to='imports/', verbose_name='Файл для импорта')),
|
||||
('import_groups', models.BooleanField(default=True, verbose_name='Импортировать группы')),
|
||||
('import_links', models.BooleanField(default=True, verbose_name='Импортировать ссылки')),
|
||||
('import_styles', models.BooleanField(default=True, verbose_name='Импортировать стили')),
|
||||
('import_media', models.BooleanField(default=True, verbose_name='Импортировать медиафайлы')),
|
||||
('overwrite_existing', models.BooleanField(default=False, verbose_name='Перезаписать существующие')),
|
||||
('imported_groups_count', models.PositiveIntegerField(default=0, verbose_name='Импортировано групп')),
|
||||
('imported_links_count', models.PositiveIntegerField(default=0, verbose_name='Импортировано ссылок')),
|
||||
('imported_media_count', models.PositiveIntegerField(default=0, verbose_name='Импортировано медиафайлов')),
|
||||
('error_message', models.TextField(blank=True, verbose_name='Сообщение об ошибке')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Задача импорта',
|
||||
'verbose_name_plural': 'Задачи импорта',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/export_import/migrations/__init__.py
Normal file
0
backend/export_import/migrations/__init__.py
Normal file
92
backend/export_import/models.py
Normal file
92
backend/export_import/models.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ExportTask(models.Model):
|
||||
"""Модель для отслеживания задач экспорта профиля"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Ожидает выполнения'),
|
||||
('processing', 'В процессе'),
|
||||
('completed', 'Завершен'),
|
||||
('failed', 'Ошибка'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Пользователь')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Статус')
|
||||
|
||||
# Опции экспорта
|
||||
include_groups = models.BooleanField(default=True, verbose_name='Включить группы')
|
||||
include_links = models.BooleanField(default=True, verbose_name='Включить ссылки')
|
||||
include_styles = models.BooleanField(default=True, verbose_name='Включить стили')
|
||||
include_media = models.BooleanField(default=True, verbose_name='Включить медиафайлы')
|
||||
|
||||
# Файл с результатом
|
||||
export_file = models.FileField(
|
||||
upload_to='exports/',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Файл экспорта'
|
||||
)
|
||||
|
||||
# Логирование
|
||||
error_message = models.TextField(blank=True, verbose_name='Сообщение об ошибке')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создано')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлено')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Задача экспорта'
|
||||
verbose_name_plural = 'Задачи экспорта'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'Экспорт {self.user.username} - {self.get_status_display()}'
|
||||
|
||||
|
||||
class ImportTask(models.Model):
|
||||
"""Модель для отслеживания задач импорта профиля"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Ожидает выполнения'),
|
||||
('processing', 'В процессе'),
|
||||
('completed', 'Завершен'),
|
||||
('failed', 'Ошибка'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Пользователь')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Статус')
|
||||
|
||||
# Файл для импорта
|
||||
import_file = models.FileField(upload_to='imports/', verbose_name='Файл для импорта')
|
||||
|
||||
# Опции импорта
|
||||
import_groups = models.BooleanField(default=True, verbose_name='Импортировать группы')
|
||||
import_links = models.BooleanField(default=True, verbose_name='Импортировать ссылки')
|
||||
import_styles = models.BooleanField(default=True, verbose_name='Импортировать стили')
|
||||
import_media = models.BooleanField(default=True, verbose_name='Импортировать медиафайлы')
|
||||
|
||||
# Стратегия конфликтов
|
||||
overwrite_existing = models.BooleanField(default=False, verbose_name='Перезаписать существующие')
|
||||
|
||||
# Результаты импорта
|
||||
imported_groups_count = models.PositiveIntegerField(default=0, verbose_name='Импортировано групп')
|
||||
imported_links_count = models.PositiveIntegerField(default=0, verbose_name='Импортировано ссылок')
|
||||
imported_media_count = models.PositiveIntegerField(default=0, verbose_name='Импортировано медиафайлов')
|
||||
|
||||
# Логирование
|
||||
error_message = models.TextField(blank=True, verbose_name='Сообщение об ошибке')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создано')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='Обновлено')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Задача импорта'
|
||||
verbose_name_plural = 'Задачи импорта'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f'Импорт {self.user.username} - {self.get_status_display()}'
|
||||
3
backend/export_import/tests.py
Normal file
3
backend/export_import/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
16
backend/export_import/urls.py
Normal file
16
backend/export_import/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Экспорт профиля
|
||||
path('export/', views.create_export, name='create_export'),
|
||||
path('export/<int:task_id>/', views.export_status, name='export_status'),
|
||||
path('export/<int:task_id>/download/', views.download_export, name='download_export'),
|
||||
path('export/list/', views.export_list, name='export_list'),
|
||||
|
||||
# Импорт профиля
|
||||
path('import/', views.create_import, name='create_import'),
|
||||
path('import/<int:task_id>/', views.import_status, name='import_status'),
|
||||
path('import/list/', views.import_list, name='import_list'),
|
||||
path('import/preview/', views.preview_import, name='preview_import'),
|
||||
]
|
||||
732
backend/export_import/views.py
Normal file
732
backend/export_import/views.py
Normal file
@@ -0,0 +1,732 @@
|
||||
import json
|
||||
import zipfile
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.utils import timezone
|
||||
import shutil
|
||||
|
||||
from .models import ExportTask, ImportTask
|
||||
from users.models import User
|
||||
from links.models import LinkGroup, Link
|
||||
from customization.models import DesignSettings
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def create_export(request):
|
||||
"""Создание задачи экспорта профиля"""
|
||||
|
||||
# Получаем параметры экспорта
|
||||
include_groups = request.data.get('include_groups', True)
|
||||
include_links = request.data.get('include_links', True)
|
||||
include_styles = request.data.get('include_styles', True)
|
||||
include_media = request.data.get('include_media', True)
|
||||
|
||||
# Создаем задачу экспорта
|
||||
export_task = ExportTask.objects.create(
|
||||
user=request.user,
|
||||
include_groups=include_groups,
|
||||
include_links=include_links,
|
||||
include_styles=include_styles,
|
||||
include_media=include_media,
|
||||
)
|
||||
|
||||
try:
|
||||
# Обновляем статус
|
||||
export_task.status = 'processing'
|
||||
export_task.save()
|
||||
|
||||
# Создаем архив с данными профиля
|
||||
export_file_path = _create_profile_archive(export_task)
|
||||
|
||||
# Сохраняем путь к файлу в задаче
|
||||
with open(export_file_path, 'rb') as f:
|
||||
export_task.export_file.save(
|
||||
f'profile_export_{export_task.user.username}_{timezone.now().strftime("%Y%m%d_%H%M%S")}.zip',
|
||||
ContentFile(f.read()),
|
||||
save=True
|
||||
)
|
||||
|
||||
# Удаляем временный файл
|
||||
os.remove(export_file_path)
|
||||
|
||||
export_task.status = 'completed'
|
||||
export_task.save()
|
||||
|
||||
return Response({
|
||||
'task_id': export_task.id,
|
||||
'status': export_task.status,
|
||||
'download_url': f'/api/export/{export_task.id}/download/',
|
||||
'message': 'Экспорт профиля завершен успешно'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
export_task.status = 'failed'
|
||||
export_task.error_message = str(e)
|
||||
export_task.save()
|
||||
|
||||
return Response({
|
||||
'error': 'Ошибка при создании экспорта',
|
||||
'details': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def download_export(request, task_id):
|
||||
"""Скачивание файла экспорта"""
|
||||
|
||||
export_task = get_object_or_404(ExportTask, id=task_id, user=request.user)
|
||||
|
||||
if export_task.status != 'completed' or not export_task.export_file:
|
||||
return Response({
|
||||
'error': 'Файл экспорта недоступен'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
response = HttpResponse(
|
||||
export_task.export_file.read(),
|
||||
content_type='application/zip'
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="profile_export_{request.user.username}.zip"'
|
||||
return response
|
||||
|
||||
except FileNotFoundError:
|
||||
raise Http404("Файл экспорта не найден")
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def export_status(request, task_id):
|
||||
"""Получение статуса задачи экспорта"""
|
||||
|
||||
export_task = get_object_or_404(ExportTask, id=task_id, user=request.user)
|
||||
|
||||
return Response({
|
||||
'task_id': export_task.id,
|
||||
'status': export_task.status,
|
||||
'created_at': export_task.created_at,
|
||||
'updated_at': export_task.updated_at,
|
||||
'error_message': export_task.error_message,
|
||||
'download_url': f'/api/export/{export_task.id}/download/' if export_task.status == 'completed' else None
|
||||
})
|
||||
|
||||
|
||||
def _create_profile_archive(export_task):
|
||||
"""Создание архива с данными профиля"""
|
||||
|
||||
user = export_task.user
|
||||
|
||||
# Создаем временную директорию
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
profile_dir = Path(temp_dir) / 'profile_export'
|
||||
profile_dir.mkdir()
|
||||
|
||||
# Создаем структуру данных для экспорта
|
||||
export_data = {
|
||||
'export_info': {
|
||||
'username': user.username,
|
||||
'export_date': timezone.now().isoformat(),
|
||||
'export_options': {
|
||||
'include_groups': export_task.include_groups,
|
||||
'include_links': export_task.include_links,
|
||||
'include_styles': export_task.include_styles,
|
||||
'include_media': export_task.include_media,
|
||||
}
|
||||
},
|
||||
'user_data': {
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'bio': getattr(user, 'bio', ''),
|
||||
'avatar': user.avatar.url if user.avatar else None,
|
||||
'cover': user.cover.url if user.cover else None,
|
||||
},
|
||||
'groups': [],
|
||||
'links': [],
|
||||
'design_settings': {},
|
||||
}
|
||||
|
||||
# Экспорт групп
|
||||
if export_task.include_groups:
|
||||
for group in LinkGroup.objects.filter(owner=user):
|
||||
group_data = {
|
||||
'id': group.id,
|
||||
'title': group.title,
|
||||
'description': group.description,
|
||||
'image': group.image.url if group.image else None,
|
||||
'is_active': group.is_active,
|
||||
'is_public': group.is_public,
|
||||
'is_featured': group.is_featured,
|
||||
'created_at': group.created_at.isoformat(),
|
||||
'order': group.order,
|
||||
}
|
||||
export_data['groups'].append(group_data)
|
||||
|
||||
# Экспорт ссылок
|
||||
if export_task.include_links:
|
||||
for link in Link.objects.filter(group__owner=user):
|
||||
link_data = {
|
||||
'id': link.id,
|
||||
'group_id': link.group.id,
|
||||
'title': link.title,
|
||||
'url': link.url,
|
||||
'description': link.description,
|
||||
'image': link.image.url if link.image else None,
|
||||
'is_active': link.is_active,
|
||||
'is_public': link.is_public,
|
||||
'is_featured': link.is_featured,
|
||||
'created_at': link.created_at.isoformat(),
|
||||
'order': link.order,
|
||||
}
|
||||
export_data['links'].append(link_data)
|
||||
|
||||
# Экспорт настроек дизайна
|
||||
if export_task.include_styles:
|
||||
try:
|
||||
design_settings = DesignSettings.objects.get(user=user)
|
||||
export_data['design_settings'] = {
|
||||
'background_image': design_settings.background_image.url if design_settings.background_image else None,
|
||||
'theme_color': design_settings.theme_color,
|
||||
'dashboard_layout': design_settings.dashboard_layout,
|
||||
'groups_default_expanded': design_settings.groups_default_expanded,
|
||||
'show_group_icons': design_settings.show_group_icons,
|
||||
'show_link_icons': design_settings.show_link_icons,
|
||||
'dashboard_background_color': design_settings.dashboard_background_color,
|
||||
'font_family': design_settings.font_family,
|
||||
'custom_css': design_settings.custom_css,
|
||||
'header_text_color': design_settings.header_text_color,
|
||||
'group_text_color': design_settings.group_text_color,
|
||||
'link_text_color': design_settings.link_text_color,
|
||||
'cover_overlay_enabled': design_settings.cover_overlay_enabled,
|
||||
'cover_overlay_color': design_settings.cover_overlay_color,
|
||||
'cover_overlay_opacity': design_settings.cover_overlay_opacity,
|
||||
'group_overlay_enabled': design_settings.group_overlay_enabled,
|
||||
'group_overlay_color': design_settings.group_overlay_color,
|
||||
'group_overlay_opacity': design_settings.group_overlay_opacity,
|
||||
'show_groups_title': design_settings.show_groups_title,
|
||||
'group_description_text_color': design_settings.group_description_text_color,
|
||||
'body_font_family': design_settings.body_font_family,
|
||||
'heading_font_family': design_settings.heading_font_family,
|
||||
'template_id': design_settings.template_id,
|
||||
'link_overlay_enabled': design_settings.link_overlay_enabled,
|
||||
'link_overlay_color': design_settings.link_overlay_color,
|
||||
'link_overlay_opacity': design_settings.link_overlay_opacity,
|
||||
}
|
||||
except DesignSettings.DoesNotExist:
|
||||
export_data['design_settings'] = {}
|
||||
|
||||
# Сохраняем данные в JSON
|
||||
json_file = profile_dir / 'profile_data.json'
|
||||
with open(json_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Копируем медиафайлы
|
||||
if export_task.include_media:
|
||||
media_dir = profile_dir / 'media'
|
||||
media_dir.mkdir()
|
||||
|
||||
# Копируем файлы пользователя
|
||||
_copy_user_media_files(user, media_dir, export_data)
|
||||
|
||||
# Создаем ZIP архив
|
||||
archive_path = temp_dir + '.zip'
|
||||
with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for root, dirs, files in os.walk(profile_dir):
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
arc_name = file_path.relative_to(profile_dir)
|
||||
zipf.write(file_path, arc_name)
|
||||
|
||||
return archive_path
|
||||
|
||||
|
||||
def _copy_user_media_files(user, media_dir, export_data):
|
||||
"""Копирование медиафайлов пользователя"""
|
||||
|
||||
media_root = Path(settings.MEDIA_ROOT)
|
||||
|
||||
# Функция для копирования файла
|
||||
def copy_file_if_exists(url, subdir):
|
||||
if url and url.startswith('/storage/'):
|
||||
file_path = media_root / url[9:] # убираем /storage/
|
||||
if file_path.exists():
|
||||
target_dir = media_dir / subdir
|
||||
target_dir.mkdir(exist_ok=True, parents=True)
|
||||
shutil.copy2(file_path, target_dir / file_path.name)
|
||||
|
||||
# Аватар и обложка пользователя
|
||||
copy_file_if_exists(export_data['user_data']['avatar'], 'avatars')
|
||||
copy_file_if_exists(export_data['user_data']['cover'], 'avatars')
|
||||
|
||||
# Фоновые изображения в настройках дизайна
|
||||
if export_data['design_settings'].get('background_image'):
|
||||
copy_file_if_exists(export_data['design_settings']['background_image'], 'customization')
|
||||
|
||||
# Изображения групп
|
||||
for group in export_data['groups']:
|
||||
copy_file_if_exists(group.get('image'), 'link_groups')
|
||||
|
||||
# Изображения ссылок
|
||||
for link in export_data['links']:
|
||||
copy_file_if_exists(link.get('image'), 'links')
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def export_list(request):
|
||||
"""Получение списка задач экспорта пользователя"""
|
||||
|
||||
# Для тестирования - простой ответ
|
||||
if not request.user.is_authenticated:
|
||||
return Response({
|
||||
'message': 'Export API доступен',
|
||||
'authenticated': False
|
||||
})
|
||||
|
||||
export_tasks = ExportTask.objects.filter(user=request.user)
|
||||
|
||||
tasks_data = []
|
||||
for task in export_tasks:
|
||||
tasks_data.append({
|
||||
'id': task.id,
|
||||
'status': task.status,
|
||||
'created_at': task.created_at,
|
||||
'updated_at': task.updated_at,
|
||||
'include_groups': task.include_groups,
|
||||
'include_links': task.include_links,
|
||||
'include_styles': task.include_styles,
|
||||
'include_media': task.include_media,
|
||||
'download_url': f'/api/export/{task.id}/download/' if task.status == 'completed' else None,
|
||||
'error_message': task.error_message,
|
||||
})
|
||||
|
||||
return Response({
|
||||
'tasks': tasks_data,
|
||||
'count': len(tasks_data)
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def create_import(request):
|
||||
"""Создание задачи импорта профиля"""
|
||||
|
||||
# Проверяем наличие файла
|
||||
if 'import_file' not in request.FILES:
|
||||
return Response({
|
||||
'error': 'Файл для импорта не предоставлен'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
import_file = request.FILES['import_file']
|
||||
|
||||
# Проверяем тип файла
|
||||
if not import_file.name.endswith('.zip'):
|
||||
return Response({
|
||||
'error': 'Поддерживаются только ZIP архивы'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Получаем параметры импорта из POST данных (для multipart/form-data)
|
||||
def get_bool_param(name, default=True):
|
||||
value = request.data.get(name, request.POST.get(name, str(default)))
|
||||
return str(value).lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
import_groups = get_bool_param('import_groups', True)
|
||||
import_links = get_bool_param('import_links', True)
|
||||
import_styles = get_bool_param('import_styles', True)
|
||||
import_media = get_bool_param('import_media', True)
|
||||
overwrite_existing = get_bool_param('overwrite_existing', False)
|
||||
|
||||
# Создаем задачу импорта
|
||||
try:
|
||||
import_task = ImportTask.objects.create(
|
||||
user=request.user,
|
||||
import_file=import_file,
|
||||
import_groups=import_groups,
|
||||
import_links=import_links,
|
||||
import_styles=import_styles,
|
||||
import_media=import_media,
|
||||
overwrite_existing=overwrite_existing,
|
||||
)
|
||||
|
||||
# Обновляем статус
|
||||
import_task.status = 'processing'
|
||||
import_task.save()
|
||||
|
||||
# Выполняем импорт
|
||||
_process_import(import_task)
|
||||
|
||||
import_task.status = 'completed'
|
||||
import_task.save()
|
||||
|
||||
return Response({
|
||||
'task_id': import_task.id,
|
||||
'status': import_task.status,
|
||||
'imported_groups_count': import_task.imported_groups_count,
|
||||
'imported_links_count': import_task.imported_links_count,
|
||||
'imported_media_count': import_task.imported_media_count,
|
||||
'message': 'Импорт профиля завершен успешно'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Если задача была создана, обновляем её статус
|
||||
if 'import_task' in locals():
|
||||
import_task.status = 'failed'
|
||||
import_task.error_message = str(e)
|
||||
import_task.save()
|
||||
|
||||
return Response({
|
||||
'error': 'Ошибка при импорте',
|
||||
'details': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def import_status(request, task_id):
|
||||
"""Получение статуса задачи импорта"""
|
||||
|
||||
import_task = get_object_or_404(ImportTask, id=task_id, user=request.user)
|
||||
|
||||
return Response({
|
||||
'task_id': import_task.id,
|
||||
'status': import_task.status,
|
||||
'created_at': import_task.created_at,
|
||||
'updated_at': import_task.updated_at,
|
||||
'import_groups': import_task.import_groups,
|
||||
'import_links': import_task.import_links,
|
||||
'import_styles': import_task.import_styles,
|
||||
'import_media': import_task.import_media,
|
||||
'overwrite_existing': import_task.overwrite_existing,
|
||||
'imported_groups_count': import_task.imported_groups_count,
|
||||
'imported_links_count': import_task.imported_links_count,
|
||||
'imported_media_count': import_task.imported_media_count,
|
||||
'error_message': import_task.error_message,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def import_list(request):
|
||||
"""Получение списка задач импорта пользователя"""
|
||||
|
||||
import_tasks = ImportTask.objects.filter(user=request.user)
|
||||
|
||||
tasks_data = []
|
||||
for task in import_tasks:
|
||||
tasks_data.append({
|
||||
'id': task.id,
|
||||
'status': task.status,
|
||||
'created_at': task.created_at,
|
||||
'updated_at': task.updated_at,
|
||||
'import_groups': task.import_groups,
|
||||
'import_links': task.import_links,
|
||||
'import_styles': task.import_styles,
|
||||
'import_media': task.import_media,
|
||||
'overwrite_existing': task.overwrite_existing,
|
||||
'imported_groups_count': task.imported_groups_count,
|
||||
'imported_links_count': task.imported_links_count,
|
||||
'imported_media_count': task.imported_media_count,
|
||||
'error_message': task.error_message,
|
||||
})
|
||||
|
||||
return Response({
|
||||
'tasks': tasks_data,
|
||||
'count': len(tasks_data)
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def preview_import(request):
|
||||
"""Предварительный просмотр содержимого архива импорта"""
|
||||
|
||||
if 'import_file' not in request.FILES:
|
||||
return Response({
|
||||
'error': 'Файл для импорта не предоставлен'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
import_file = request.FILES['import_file']
|
||||
|
||||
if not import_file.name.endswith('.zip'):
|
||||
return Response({
|
||||
'error': 'Поддерживаются только ZIP архивы'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
archive_path = Path(temp_dir) / 'preview.zip'
|
||||
|
||||
# Сохраняем файл
|
||||
with open(archive_path, 'wb') as f:
|
||||
for chunk in import_file.chunks():
|
||||
f.write(chunk)
|
||||
|
||||
# Извлекаем архив
|
||||
extract_dir = Path(temp_dir) / 'extracted'
|
||||
with zipfile.ZipFile(archive_path, 'r') as zipf:
|
||||
zipf.extractall(extract_dir)
|
||||
|
||||
# Читаем данные профиля
|
||||
profile_data_path = extract_dir / 'profile_data.json'
|
||||
if not profile_data_path.exists():
|
||||
return Response({
|
||||
'error': 'Файл profile_data.json не найден в архиве'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with open(profile_data_path, 'r', encoding='utf-8') as f:
|
||||
profile_data = json.load(f)
|
||||
|
||||
# Формируем превью
|
||||
preview = {
|
||||
'export_info': profile_data.get('export_info', {}),
|
||||
'user_data': profile_data.get('user_data', {}),
|
||||
'groups_count': len(profile_data.get('groups', [])),
|
||||
'links_count': len(profile_data.get('links', [])),
|
||||
'has_design_settings': bool(profile_data.get('design_settings')),
|
||||
'media_files': _count_media_files(extract_dir),
|
||||
'groups_preview': profile_data.get('groups', [])[:5], # Первые 5 групп для превью
|
||||
'links_preview': profile_data.get('links', [])[:10], # Первые 10 ссылок для превью
|
||||
}
|
||||
|
||||
return Response(preview)
|
||||
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': 'Ошибка при обработке архива',
|
||||
'details': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
def _count_media_files(extract_dir):
|
||||
"""Подсчет медиафайлов в архиве"""
|
||||
|
||||
media_dir = extract_dir / 'media'
|
||||
if not media_dir.exists():
|
||||
return {}
|
||||
|
||||
counts = {}
|
||||
for category in ['avatars', 'customization', 'link_groups', 'links']:
|
||||
category_dir = media_dir / category
|
||||
if category_dir.exists():
|
||||
counts[category] = len([f for f in category_dir.iterdir() if f.is_file()])
|
||||
else:
|
||||
counts[category] = 0
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def _process_import(import_task):
|
||||
"""Обработка импорта профиля"""
|
||||
|
||||
user = import_task.user
|
||||
|
||||
# Создаем временную директорию для извлечения архива
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
archive_path = Path(temp_dir) / 'import.zip'
|
||||
|
||||
# Сохраняем файл во временную директорию
|
||||
with open(archive_path, 'wb') as f:
|
||||
for chunk in import_task.import_file.chunks():
|
||||
f.write(chunk)
|
||||
|
||||
# Извлекаем архив
|
||||
extract_dir = Path(temp_dir) / 'extracted'
|
||||
with zipfile.ZipFile(archive_path, 'r') as zipf:
|
||||
zipf.extractall(extract_dir)
|
||||
|
||||
# Читаем данные профиля
|
||||
profile_data_path = extract_dir / 'profile_data.json'
|
||||
if not profile_data_path.exists():
|
||||
raise Exception('Файл profile_data.json не найден в архиве')
|
||||
|
||||
with open(profile_data_path, 'r', encoding='utf-8') as f:
|
||||
profile_data = json.load(f)
|
||||
|
||||
# Импортируем данные в транзакции
|
||||
with transaction.atomic():
|
||||
_import_profile_data(import_task, profile_data, extract_dir)
|
||||
|
||||
|
||||
def _import_profile_data(import_task, profile_data, extract_dir):
|
||||
"""Импорт данных профиля"""
|
||||
|
||||
user = import_task.user
|
||||
|
||||
# Импорт групп
|
||||
if import_task.import_groups and 'groups' in profile_data:
|
||||
groups_count = _import_groups(user, profile_data['groups'], import_task.overwrite_existing)
|
||||
import_task.imported_groups_count = groups_count
|
||||
|
||||
# Импорт ссылок
|
||||
if import_task.import_links and 'links' in profile_data:
|
||||
links_count = _import_links(user, profile_data['links'], profile_data.get('groups', []), import_task.overwrite_existing)
|
||||
import_task.imported_links_count = links_count
|
||||
|
||||
# Импорт настроек дизайна
|
||||
if import_task.import_styles and 'design_settings' in profile_data:
|
||||
_import_design_settings(user, profile_data['design_settings'], import_task.overwrite_existing)
|
||||
|
||||
# Импорт медиафайлов
|
||||
if import_task.import_media:
|
||||
media_count = _import_media_files(user, extract_dir, import_task.overwrite_existing)
|
||||
import_task.imported_media_count = media_count
|
||||
|
||||
import_task.save()
|
||||
|
||||
|
||||
def _import_groups(user, groups_data, overwrite_existing):
|
||||
"""Импорт групп ссылок"""
|
||||
|
||||
imported_count = 0
|
||||
|
||||
for group_data in groups_data:
|
||||
# Проверяем существование группы по названию
|
||||
existing_group = LinkGroup.objects.filter(
|
||||
owner=user,
|
||||
title=group_data['title']
|
||||
).first()
|
||||
|
||||
if existing_group and not overwrite_existing:
|
||||
continue # Пропускаем если группа существует и перезапись отключена
|
||||
|
||||
# Создаем или обновляем группу
|
||||
group_defaults = {
|
||||
'description': group_data.get('description', ''),
|
||||
'is_active': group_data.get('is_active', True),
|
||||
'is_public': group_data.get('is_public', False),
|
||||
'is_featured': group_data.get('is_featured', False),
|
||||
'order': group_data.get('order', 0),
|
||||
}
|
||||
|
||||
group, created = LinkGroup.objects.update_or_create(
|
||||
owner=user,
|
||||
title=group_data['title'],
|
||||
defaults=group_defaults
|
||||
)
|
||||
|
||||
imported_count += 1
|
||||
|
||||
return imported_count
|
||||
|
||||
|
||||
def _import_links(user, links_data, groups_data, overwrite_existing):
|
||||
"""Импорт ссылок"""
|
||||
|
||||
imported_count = 0
|
||||
|
||||
# Создаем словарь соответствия старых ID групп к новым объектам
|
||||
group_mapping = {}
|
||||
for group_data in groups_data:
|
||||
group = LinkGroup.objects.filter(
|
||||
owner=user,
|
||||
title=group_data['title']
|
||||
).first()
|
||||
if group:
|
||||
group_mapping[group_data['id']] = group
|
||||
|
||||
for link_data in links_data:
|
||||
# Находим группу для ссылки
|
||||
old_group_id = link_data.get('group_id')
|
||||
if old_group_id not in group_mapping:
|
||||
continue # Пропускаем если группа не найдена
|
||||
|
||||
target_group = group_mapping[old_group_id]
|
||||
|
||||
# Проверяем существование ссылки по URL и группе
|
||||
existing_link = Link.objects.filter(
|
||||
group=target_group,
|
||||
url=link_data['url']
|
||||
).first()
|
||||
|
||||
if existing_link and not overwrite_existing:
|
||||
continue # Пропускаем если ссылка существует и перезапись отключена
|
||||
|
||||
# Создаем или обновляем ссылку
|
||||
link_defaults = {
|
||||
'title': link_data.get('title', ''),
|
||||
'description': link_data.get('description', ''),
|
||||
'is_active': link_data.get('is_active', True),
|
||||
'is_public': link_data.get('is_public', False),
|
||||
'is_featured': link_data.get('is_featured', False),
|
||||
'order': link_data.get('order', 0),
|
||||
}
|
||||
|
||||
link, created = Link.objects.update_or_create(
|
||||
group=target_group,
|
||||
url=link_data['url'],
|
||||
defaults=link_defaults
|
||||
)
|
||||
|
||||
imported_count += 1
|
||||
|
||||
return imported_count
|
||||
|
||||
|
||||
def _import_design_settings(user, design_data, overwrite_existing):
|
||||
"""Импорт настроек дизайна"""
|
||||
|
||||
if not design_data:
|
||||
return
|
||||
|
||||
# Получаем или создаем настройки дизайна
|
||||
design_settings, created = DesignSettings.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={}
|
||||
)
|
||||
|
||||
if not created and not overwrite_existing:
|
||||
return # Пропускаем если настройки существуют и перезапись отключена
|
||||
|
||||
# Обновляем настройки
|
||||
for field, value in design_data.items():
|
||||
if field != 'background_image' and hasattr(design_settings, field):
|
||||
setattr(design_settings, field, value)
|
||||
|
||||
design_settings.save()
|
||||
|
||||
|
||||
def _import_media_files(user, extract_dir, overwrite_existing):
|
||||
"""Импорт медиафайлов"""
|
||||
|
||||
imported_count = 0
|
||||
media_dir = extract_dir / 'media'
|
||||
|
||||
if not media_dir.exists():
|
||||
return imported_count
|
||||
|
||||
# Создаем соответствующие директории в медиа
|
||||
user_media_root = Path(settings.MEDIA_ROOT)
|
||||
|
||||
# Импорт файлов по категориям
|
||||
for category in ['avatars', 'customization', 'link_groups', 'links']:
|
||||
category_dir = media_dir / category
|
||||
if not category_dir.exists():
|
||||
continue
|
||||
|
||||
target_dir = user_media_root / category
|
||||
target_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# Копируем файлы
|
||||
for file_path in category_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
target_file = target_dir / file_path.name
|
||||
|
||||
if target_file.exists() and not overwrite_existing:
|
||||
continue
|
||||
|
||||
shutil.copy2(file_path, target_file)
|
||||
imported_count += 1
|
||||
|
||||
return imported_count
|
||||
@@ -1226,6 +1226,51 @@ export default function DashboardClient() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка группы (опционально)</label>
|
||||
{editingGroup?.icon_url && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Текущая иконка:</label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<img
|
||||
src={editingGroup.icon_url}
|
||||
alt="Текущая иконка группы"
|
||||
className="img-thumbnail"
|
||||
style={{ width: '32px', height: '32px', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => {
|
||||
if (confirm('Удалить текущую иконку группы?')) {
|
||||
// Удаляем иконку через API
|
||||
fetch(`/api/groups/${editingGroup.id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
icon_url: ''
|
||||
})
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
// Обновляем локальный объект группы
|
||||
setGroups(groups.map(g =>
|
||||
g.id === editingGroup.id
|
||||
? { ...g, icon_url: '' }
|
||||
: g
|
||||
))
|
||||
setEditingGroup({ ...editingGroup, icon_url: '' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
title="Убрать иконку группы"
|
||||
>
|
||||
<i className="bi bi-trash"></i> Убрать иконку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
@@ -1236,6 +1281,51 @@ export default function DashboardClient() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Фоновое изображение (опционально)</label>
|
||||
{editingGroup?.background_image_url && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Текущий фон:</label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<img
|
||||
src={editingGroup.background_image_url}
|
||||
alt="Текущий фон группы"
|
||||
className="img-thumbnail"
|
||||
style={{ maxWidth: '150px', maxHeight: '80px', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => {
|
||||
if (confirm('Удалить текущий фон группы?')) {
|
||||
// Удаляем фон через API
|
||||
fetch(`/api/groups/${editingGroup.id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
background_image_url: ''
|
||||
})
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
// Обновляем локальный объект группы
|
||||
setGroups(groups.map(g =>
|
||||
g.id === editingGroup.id
|
||||
? { ...g, background_image_url: '' }
|
||||
: g
|
||||
))
|
||||
setEditingGroup({ ...editingGroup, background_image_url: '' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
title="Убрать фон группы"
|
||||
>
|
||||
<i className="bi bi-trash"></i> Убрать фон
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
@@ -1323,6 +1413,54 @@ export default function DashboardClient() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Иконка (опционально)</label>
|
||||
{editingLink?.icon_url && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Текущая иконка:</label>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<img
|
||||
src={editingLink.icon_url}
|
||||
alt="Текущая иконка"
|
||||
className="img-thumbnail"
|
||||
style={{ width: '32px', height: '32px', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => {
|
||||
if (confirm('Удалить текущую иконку ссылки?')) {
|
||||
// Удаляем иконку через API
|
||||
fetch(`/api/links/${editingLink.id}/`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
icon_url: ''
|
||||
})
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
// Обновляем локальные данные
|
||||
setGroups(groups.map(g => ({
|
||||
...g,
|
||||
links: g.links.map(l =>
|
||||
l.id === editingLink.id
|
||||
? { ...l, icon_url: '' }
|
||||
: l
|
||||
)
|
||||
})))
|
||||
setEditingLink({ ...editingLink, icon_url: '' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
title="Убрать иконку ссылки"
|
||||
>
|
||||
<i className="bi bi-trash"></i> Убрать иконку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
|
||||
@@ -464,13 +464,23 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
|
||||
{settings.background_image_url && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label small">Текущее изображение:</label>
|
||||
<div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<img
|
||||
src={settings.background_image_url}
|
||||
alt="Текущий фон"
|
||||
className="img-thumbnail"
|
||||
style={{ maxWidth: '200px', maxHeight: '100px', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => {
|
||||
handleChange('background_image_url', '')
|
||||
}}
|
||||
title="Убрать фон"
|
||||
>
|
||||
<i className="bi bi-trash"></i> Убрать фон
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1019,6 +1029,49 @@ export function CustomizationPanel({ isOpen, onClose, onSettingsUpdate }: Custom
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-warning"
|
||||
onClick={() => {
|
||||
if (confirm('Вы уверены, что хотите сбросить все настройки интерфейса к значениям по умолчанию? Это действие нельзя отменить.')) {
|
||||
// Сброс к дефолтным настройкам
|
||||
const defaultSettings = {
|
||||
theme_color: '#007bff',
|
||||
background_image_url: '',
|
||||
dashboard_layout: 'list' as const,
|
||||
groups_default_expanded: true,
|
||||
show_group_icons: true,
|
||||
show_link_icons: true,
|
||||
dashboard_background_color: '#ffffff',
|
||||
font_family: 'Inter, sans-serif',
|
||||
custom_css: '',
|
||||
group_text_color: '',
|
||||
link_text_color: '',
|
||||
header_text_color: '',
|
||||
cover_overlay_enabled: false,
|
||||
cover_overlay_color: '#000000',
|
||||
cover_overlay_opacity: 0.3,
|
||||
group_overlay_enabled: false,
|
||||
group_overlay_color: '#000000',
|
||||
group_overlay_opacity: 0.3,
|
||||
show_groups_title: true,
|
||||
group_description_text_color: '',
|
||||
body_font_family: 'Inter, sans-serif',
|
||||
heading_font_family: 'Inter, sans-serif',
|
||||
link_overlay_enabled: false,
|
||||
link_overlay_color: '#000000',
|
||||
link_overlay_opacity: 0.3
|
||||
}
|
||||
setSettings(defaultSettings)
|
||||
onSettingsUpdate(defaultSettings)
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
title="Сбросить все настройки к значениям по умолчанию"
|
||||
>
|
||||
<i className="bi bi-arrow-counterclockwise me-2"></i>
|
||||
Сбросить настройки
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
|
||||
BIN
profile_export.zip
Normal file
BIN
profile_export.zip
Normal file
Binary file not shown.
BIN
profile_export_full.zip
Normal file
BIN
profile_export_full.zip
Normal file
Binary file not shown.
BIN
profile_export_with_data.zip
Normal file
BIN
profile_export_with_data.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user