+ Приведены все функции приложения в рабочий вид
+ Наведен порядок в файлах проекта + Наведен порядок в документации + Настроены скрипты установки, развертки и так далее, расширен MakeFile
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-31 09:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0005_alter_link_icon_alter_linkgroup_is_favorite_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='linkgroup',
|
||||
name='background_image',
|
||||
field=models.ImageField(blank=True, help_text='Фоновое изображение группы', null=True, upload_to='group_backgrounds/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='linkgroup',
|
||||
name='display_style',
|
||||
field=models.CharField(choices=[('grid', 'Сетка'), ('list', 'Список'), ('cards', 'Карточки')], default='list', help_text='Стиль отображения ссылок в группе', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='linkgroup',
|
||||
name='header_color',
|
||||
field=models.CharField(default='#ffffff', help_text='Цвет заголовка группы (hex)', max_length=7),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='linkgroup',
|
||||
name='is_expanded',
|
||||
field=models.BooleanField(default=True, help_text='Развернута ли группа по умолчанию'),
|
||||
),
|
||||
]
|
||||
18
backend/api/migrations/0007_link_description.py
Normal file
18
backend/api/migrations/0007_link_description.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-31 11:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0006_linkgroup_background_image_linkgroup_display_style_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='link',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
18
backend/api/migrations/0008_alter_linkgroup_display_style.py
Normal file
18
backend/api/migrations/0008_alter_linkgroup_display_style.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-31 20:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0007_link_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='linkgroup',
|
||||
name='display_style',
|
||||
field=models.CharField(choices=[('list', 'Список'), ('grid', 'Сетка'), ('cards', 'Карточки'), ('compact', 'Компактный'), ('sidebar', 'Боковая панель'), ('masonry', 'Кладка'), ('timeline', 'Лента времени'), ('magazine', 'Журнальный')], default='list', help_text='Стиль отображения ссылок в группе', max_length=20),
|
||||
),
|
||||
]
|
||||
13
backend/api/migrations/0009_auto_20251101_0850.py
Normal file
13
backend/api/migrations/0009_auto_20251101_0850.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-01 08:50
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0008_alter_linkgroup_display_style'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@@ -18,6 +18,39 @@ class LinkGroup(models.Model):
|
||||
blank=True,
|
||||
help_text='Иконка группы ссылок'
|
||||
)
|
||||
|
||||
# Новые поля для кастомизации
|
||||
header_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#ffffff',
|
||||
help_text='Цвет заголовка группы (hex)'
|
||||
)
|
||||
background_image = models.ImageField(
|
||||
upload_to='group_backgrounds/',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Фоновое изображение группы'
|
||||
)
|
||||
is_expanded = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Развернута ли группа по умолчанию'
|
||||
)
|
||||
display_style = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('list', 'Список'),
|
||||
('grid', 'Сетка'),
|
||||
('cards', 'Карточки'),
|
||||
('compact', 'Компактный'),
|
||||
('sidebar', 'Боковая панель'),
|
||||
('masonry', 'Кладка'),
|
||||
('timeline', 'Лента времени'),
|
||||
('magazine', 'Журнальный'),
|
||||
],
|
||||
default='list',
|
||||
help_text='Стиль отображения ссылок в группе'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
is_public = models.BooleanField(default=False)
|
||||
@@ -42,6 +75,7 @@ class Link(models.Model):
|
||||
)
|
||||
title = models.CharField(max_length=200)
|
||||
url = models.URLField()
|
||||
description = models.TextField(blank=True, null=True)
|
||||
icon = models.ImageField(
|
||||
upload_to='links/',
|
||||
null=True,
|
||||
|
||||
@@ -26,23 +26,110 @@ class RegisterSerializer(serializers.ModelSerializer):
|
||||
|
||||
# сериализатор для ссылок
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'full_name', 'bio', 'avatar', 'last_login', 'date_joined']
|
||||
fields = ['id', 'username', 'email', 'full_name', 'bio', 'avatar', 'avatar_url', 'last_login', 'date_joined']
|
||||
read_only_fields = ['avatar_url']
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Возвращает полный URL аватара"""
|
||||
if obj.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
absolute_uri = request.build_absolute_uri(obj.avatar.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
absolute_uri = absolute_uri.replace('http://web:8000', 'http://localhost:8000')
|
||||
absolute_uri = absolute_uri.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
absolute_uri = absolute_uri.replace('http://backend:8000', 'http://localhost:8000')
|
||||
return absolute_uri
|
||||
return f'http://localhost:8000{obj.avatar.url}'
|
||||
return None
|
||||
|
||||
|
||||
class LinkGroupSerializer(serializers.ModelSerializer):
|
||||
icon = serializers.ImageField(required=False, allow_null=True)
|
||||
background_image = serializers.ImageField(required=False, allow_null=True)
|
||||
icon_url = serializers.SerializerMethodField()
|
||||
background_image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = LinkGroup
|
||||
fields = ['id', 'name', 'description', 'icon', 'order', 'is_public', 'is_favorite',
|
||||
'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
fields = [
|
||||
'id', 'name', 'description', 'icon', 'icon_url', 'order',
|
||||
'is_public', 'is_favorite', 'created_at', 'updated_at',
|
||||
# Новые поля кастомизации
|
||||
'header_color', 'background_image', 'background_image_url',
|
||||
'is_expanded', 'display_style'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'icon_url', 'background_image_url']
|
||||
|
||||
def get_icon_url(self, obj):
|
||||
"""Возвращает полный URL иконки"""
|
||||
if obj.icon:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
absolute_uri = request.build_absolute_uri(obj.icon.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
absolute_uri = absolute_uri.replace('http://web:8000', 'http://localhost:8000')
|
||||
absolute_uri = absolute_uri.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
absolute_uri = absolute_uri.replace('http://backend:8000', 'http://localhost:8000')
|
||||
return absolute_uri
|
||||
return f'http://localhost:8000{obj.icon.url}'
|
||||
return None
|
||||
|
||||
def get_background_image_url(self, obj):
|
||||
"""Возвращает полный URL фонового изображения"""
|
||||
if obj.background_image:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
absolute_uri = request.build_absolute_uri(obj.background_image.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
absolute_uri = absolute_uri.replace('http://web:8000', 'http://localhost:8000')
|
||||
absolute_uri = absolute_uri.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
absolute_uri = absolute_uri.replace('http://backend:8000', 'http://localhost:8000')
|
||||
return absolute_uri
|
||||
return f'http://localhost:8000{obj.background_image.url}'
|
||||
return None
|
||||
|
||||
def validate_header_color(self, value):
|
||||
"""Валидация цвета заголовка"""
|
||||
if not value.startswith('#') or len(value) != 7:
|
||||
raise serializers.ValidationError('Цвет должен быть в формате #RRGGBB')
|
||||
try:
|
||||
int(value[1:], 16)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError('Некорректный hex цвет')
|
||||
return value
|
||||
|
||||
def validate_display_style(self, value):
|
||||
"""Валидация стиля отображения"""
|
||||
valid_styles = ['list', 'grid', 'cards', 'compact', 'sidebar', 'masonry', 'timeline', 'magazine']
|
||||
if value not in valid_styles:
|
||||
raise serializers.ValidationError(f'Стиль должен быть одним из: {", ".join(valid_styles)}')
|
||||
return value
|
||||
|
||||
|
||||
class LinkSerializer(serializers.ModelSerializer):
|
||||
icon = serializers.ImageField(required=False, allow_null=True)
|
||||
icon_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Link
|
||||
fields = ['id', 'title', 'url', 'icon', 'group', 'order']
|
||||
read_only_fields = ['id']
|
||||
fields = ['id', 'title', 'url', 'icon', 'icon_url', 'group', 'order']
|
||||
read_only_fields = ['id', 'icon_url']
|
||||
|
||||
def get_icon_url(self, obj):
|
||||
"""Возвращает полный URL иконки ссылки"""
|
||||
if obj.icon:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
absolute_uri = request.build_absolute_uri(obj.icon.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
absolute_uri = absolute_uri.replace('http://web:8000', 'http://localhost:8000')
|
||||
absolute_uri = absolute_uri.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
absolute_uri = absolute_uri.replace('http://backend:8000', 'http://localhost:8000')
|
||||
return absolute_uri
|
||||
return f'http://localhost:8000{obj.icon.url}'
|
||||
return None
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.urls import path
|
||||
from django.urls import path, include
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
from .views import (
|
||||
RegisterView,
|
||||
@@ -32,6 +32,8 @@ urlpatterns = [
|
||||
PublicUserGroupsView.as_view(),
|
||||
name='public-user-groups'
|
||||
),
|
||||
# Добавляем кастомизацию
|
||||
path('customization/', include('customization.urls')),
|
||||
# схема OpenAPI
|
||||
path('schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
# Swagger UI, берёт шаблон из drf_spectacular_sidecar
|
||||
|
||||
@@ -83,38 +83,127 @@ class PublicUserGroupsView(APIView):
|
||||
# 1. Ищем пользователя
|
||||
user = get_object_or_404(User, username=username)
|
||||
|
||||
# 2. Берём его группы со ссылками
|
||||
groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links')
|
||||
# 2. Получаем настройки дизайна пользователя
|
||||
from customization.models import DesignSettings
|
||||
try:
|
||||
design_settings = DesignSettings.objects.get(user=user)
|
||||
# Заменяем Docker URL на localhost для клиента
|
||||
background_image_url = None
|
||||
if design_settings.background_image:
|
||||
background_image_url = request.build_absolute_uri(design_settings.background_image.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
background_image_url = background_image_url.replace('http://web:8000', 'http://localhost:8000')
|
||||
background_image_url = background_image_url.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
background_image_url = background_image_url.replace('http://backend:8000', 'http://localhost:8000')
|
||||
|
||||
design_data = {
|
||||
'theme_color': design_settings.theme_color,
|
||||
'background_image': background_image_url,
|
||||
'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,
|
||||
'header_text_color': getattr(design_settings, 'header_text_color', '#000000'),
|
||||
'group_text_color': getattr(design_settings, 'group_text_color', '#333333'),
|
||||
'link_text_color': getattr(design_settings, 'link_text_color', '#666666'),
|
||||
'cover_overlay_enabled': getattr(design_settings, 'cover_overlay_enabled', False),
|
||||
'cover_overlay_color': getattr(design_settings, 'cover_overlay_color', '#000000'),
|
||||
'cover_overlay_opacity': getattr(design_settings, 'cover_overlay_opacity', 0.5),
|
||||
}
|
||||
except DesignSettings.DoesNotExist:
|
||||
# Настройки по умолчанию
|
||||
design_data = {
|
||||
'theme_color': '#ffffff',
|
||||
'background_image': None,
|
||||
'dashboard_layout': 'list',
|
||||
'groups_default_expanded': True,
|
||||
'show_group_icons': True,
|
||||
'show_link_icons': True,
|
||||
'dashboard_background_color': '#f8f9fa',
|
||||
'font_family': 'sans-serif',
|
||||
}
|
||||
|
||||
# 3. Берём только публичные группы со ссылками
|
||||
groups_qs = LinkGroup.objects.filter(
|
||||
owner=user,
|
||||
is_public=True # Показываем только публичные группы
|
||||
).prefetch_related('links')
|
||||
|
||||
# Формируем URL аватара и обложки с заменой Docker URL
|
||||
avatar_url = None
|
||||
if user.avatar:
|
||||
avatar_url = request.build_absolute_uri(user.avatar.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
avatar_url = avatar_url.replace('http://web:8000', 'http://localhost:8000')
|
||||
avatar_url = avatar_url.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
avatar_url = avatar_url.replace('http://backend:8000', 'http://localhost:8000')
|
||||
|
||||
cover_url = None
|
||||
if user.cover:
|
||||
cover_url = request.build_absolute_uri(user.cover.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
cover_url = cover_url.replace('http://web:8000', 'http://localhost:8000')
|
||||
cover_url = cover_url.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
cover_url = cover_url.replace('http://backend:8000', 'http://localhost:8000')
|
||||
|
||||
result = {
|
||||
"username": user.username,
|
||||
"full_name": user.full_name,
|
||||
"bio": user.bio,
|
||||
"avatar": avatar_url,
|
||||
"cover": cover_url,
|
||||
"design_settings": design_data,
|
||||
"groups": []
|
||||
}
|
||||
|
||||
for grp in groups_qs:
|
||||
# icon у группы (абсолютный URL)
|
||||
# icon у группы (абсолютный URL с заменой Docker URL)
|
||||
grp_icon_url = None
|
||||
if grp.icon:
|
||||
grp_icon_url = request.build_absolute_uri(grp.icon.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
grp_icon_url = grp_icon_url.replace('http://web:8000', 'http://localhost:8000')
|
||||
grp_icon_url = grp_icon_url.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
grp_icon_url = grp_icon_url.replace('http://backend:8000', 'http://localhost:8000')
|
||||
|
||||
# background_image у группы
|
||||
grp_bg_url = None
|
||||
if grp.background_image:
|
||||
grp_bg_url = request.build_absolute_uri(grp.background_image.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
grp_bg_url = grp_bg_url.replace('http://web:8000', 'http://localhost:8000')
|
||||
grp_bg_url = grp_bg_url.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
grp_bg_url = grp_bg_url.replace('http://backend:8000', 'http://localhost:8000')
|
||||
|
||||
grp_data = {
|
||||
"id": grp.id,
|
||||
"name": grp.name,
|
||||
"icon": grp_icon_url,
|
||||
"description": grp.description,
|
||||
"icon_url": grp_icon_url, # Используем icon_url для консистентности с API
|
||||
"background_image": grp_bg_url,
|
||||
"header_color": grp.header_color,
|
||||
"is_favorite": grp.is_favorite,
|
||||
"links": [],
|
||||
}
|
||||
|
||||
for ln in grp.links.all():
|
||||
# icon у ссылки
|
||||
# icon у ссылки с заменой Docker URL
|
||||
ln_icon_url = None
|
||||
if ln.icon:
|
||||
ln_icon_url = request.build_absolute_uri(ln.icon.url)
|
||||
# Заменяем различные варианты внутренних Docker URL
|
||||
ln_icon_url = ln_icon_url.replace('http://web:8000', 'http://localhost:8000')
|
||||
ln_icon_url = ln_icon_url.replace('http://links-web-1:8000', 'http://localhost:8000')
|
||||
ln_icon_url = ln_icon_url.replace('http://backend:8000', 'http://localhost:8000')
|
||||
|
||||
grp_data["links"].append({
|
||||
"id": ln.id,
|
||||
"title": ln.title,
|
||||
"url": ln.url,
|
||||
"icon": ln_icon_url,
|
||||
"icon_url": ln_icon_url, # Используем icon_url для консистентности
|
||||
"description": ln.description,
|
||||
})
|
||||
|
||||
result["groups"].append(grp_data)
|
||||
|
||||
@@ -29,7 +29,7 @@ SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True'
|
||||
|
||||
ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',')
|
||||
ALLOWED_HOSTS = ['*'] # Разрешаем доступ с любых хостов для разработки
|
||||
|
||||
# Отключаем APPEND_SLASH для корректной работы API с Next.js proxy
|
||||
APPEND_SLASH = False
|
||||
@@ -39,6 +39,10 @@ CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://localhost:3001",
|
||||
"http://192.168.219.108:3000",
|
||||
"http://192.168.219.108:3001",
|
||||
"http://192.168.219.108:8000",
|
||||
"http://192.168.219.108:8001",
|
||||
]
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True # Для разработки
|
||||
|
||||
@@ -7,9 +7,9 @@ from django.conf.urls.static import static
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('api.urls')), # API endpoints
|
||||
path('api/users/', include('users.urls')), # User management API
|
||||
path('users/', include('users.urls')), # User management app
|
||||
path('links/', include('links.urls')), # Link management app
|
||||
path('customization/', include('customization.urls')), # Design customization app
|
||||
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-31 09:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customization', '0002_alter_designsettings_background_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='dashboard_background_color',
|
||||
field=models.CharField(default='#f8f9fa', help_text='Цвет фона дашборда (hex)', max_length=7),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='dashboard_layout',
|
||||
field=models.CharField(choices=[('sidebar', 'Боковая панель'), ('grid', 'Сетка'), ('list', 'Список')], default='list', help_text='Стиль отображения дашборда', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='groups_default_expanded',
|
||||
field=models.BooleanField(default=True, help_text='Развернуты ли группы по умолчанию'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='show_group_icons',
|
||||
field=models.BooleanField(default=True, help_text='Показывать иконки групп'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='show_link_icons',
|
||||
field=models.BooleanField(default=True, help_text='Показывать иконки ссылок'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-31 11:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customization', '0003_designsettings_dashboard_background_color_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='group_text_color',
|
||||
field=models.CharField(default='#333333', help_text='Цвет названий групп (hex)', max_length=7),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='header_text_color',
|
||||
field=models.CharField(default='#000000', help_text='Цвет заголовков (hex)', max_length=7),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='link_text_color',
|
||||
field=models.CharField(default='#666666', help_text='Цвет названий ссылок (hex)', max_length=7),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-01 09:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customization', '0004_designsettings_group_text_color_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='designsettings',
|
||||
name='dashboard_layout',
|
||||
field=models.CharField(choices=[('sidebar', 'Боковая панель'), ('grid', 'Сетка'), ('list', 'Список'), ('cards', 'Карточки'), ('compact', 'Компактный'), ('masonry', 'Кладка'), ('timeline', 'Временная линия'), ('magazine', 'Журнальный')], default='list', help_text='Стиль отображения дашборда', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-01 10:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customization', '0005_alter_designsettings_dashboard_layout'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='cover_overlay_color',
|
||||
field=models.CharField(default='#000000', help_text='Цвет перекрытия обложки (hex)', max_length=7),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='cover_overlay_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Включить цветовое перекрытие обложки'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='designsettings',
|
||||
name='cover_overlay_opacity',
|
||||
field=models.FloatField(default=0.5, help_text='Прозрачность перекрытия (0.0 - 1.0)'),
|
||||
),
|
||||
]
|
||||
@@ -25,6 +25,41 @@ class DesignSettings(models.Model):
|
||||
blank=True,
|
||||
help_text='Фоновое изображение'
|
||||
)
|
||||
|
||||
# Новые поля для дашборда
|
||||
dashboard_layout = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('sidebar', 'Боковая панель'),
|
||||
('grid', 'Сетка'),
|
||||
('list', 'Список'),
|
||||
('cards', 'Карточки'),
|
||||
('compact', 'Компактный'),
|
||||
('masonry', 'Кладка'),
|
||||
('timeline', 'Временная линия'),
|
||||
('magazine', 'Журнальный'),
|
||||
],
|
||||
default='list',
|
||||
help_text='Стиль отображения дашборда'
|
||||
)
|
||||
groups_default_expanded = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Развернуты ли группы по умолчанию'
|
||||
)
|
||||
show_group_icons = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Показывать иконки групп'
|
||||
)
|
||||
show_link_icons = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Показывать иконки ссылок'
|
||||
)
|
||||
dashboard_background_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#f8f9fa',
|
||||
help_text='Цвет фона дашборда (hex)'
|
||||
)
|
||||
|
||||
font_family = models.CharField(
|
||||
max_length=100,
|
||||
default='sans-serif',
|
||||
@@ -34,6 +69,39 @@ class DesignSettings(models.Model):
|
||||
blank=True,
|
||||
help_text='Дополнительный CSS'
|
||||
)
|
||||
|
||||
# Новые поля для цветов текста
|
||||
header_text_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#000000',
|
||||
help_text='Цвет заголовков (hex)'
|
||||
)
|
||||
group_text_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#333333',
|
||||
help_text='Цвет названий групп (hex)'
|
||||
)
|
||||
link_text_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#666666',
|
||||
help_text='Цвет названий ссылок (hex)'
|
||||
)
|
||||
|
||||
# Поля для настройки обложки
|
||||
cover_overlay_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text='Включить цветовое перекрытие обложки'
|
||||
)
|
||||
cover_overlay_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#000000',
|
||||
help_text='Цвет перекрытия обложки (hex)'
|
||||
)
|
||||
cover_overlay_opacity = models.FloatField(
|
||||
default=0.5,
|
||||
help_text='Прозрачность перекрытия (0.0 - 1.0)'
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text='Дата и время последнего изменения'
|
||||
@@ -84,7 +152,7 @@ class DesignSettings(models.Model):
|
||||
"""
|
||||
if self.background_image:
|
||||
self.background_image.delete(save=False)
|
||||
super().delete(*args, **kwargs)
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
# Вспомогательные методы для доступа к данным пользователя
|
||||
|
||||
|
||||
225
backend/customization/serializers.py
Normal file
225
backend/customization/serializers.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from rest_framework import serializers
|
||||
from .models import DesignSettings
|
||||
|
||||
|
||||
class DesignSettingsSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Сериализатор для настроек дизайна пользователя
|
||||
"""
|
||||
background_image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DesignSettings
|
||||
fields = [
|
||||
'id',
|
||||
'theme_color',
|
||||
'background_image',
|
||||
'background_image_url',
|
||||
'dashboard_layout',
|
||||
'groups_default_expanded',
|
||||
'show_group_icons',
|
||||
'show_link_icons',
|
||||
'dashboard_background_color',
|
||||
'font_family',
|
||||
'custom_css',
|
||||
'header_text_color',
|
||||
'group_text_color',
|
||||
'link_text_color',
|
||||
'cover_overlay_enabled',
|
||||
'cover_overlay_color',
|
||||
'cover_overlay_opacity',
|
||||
'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'updated_at', 'background_image_url']
|
||||
# Делаем background_image необязательным
|
||||
extra_kwargs = {
|
||||
'background_image': {'required': False}
|
||||
}
|
||||
|
||||
def get_background_image_url(self, obj):
|
||||
"""
|
||||
Возвращает полный URL фонового изображения
|
||||
"""
|
||||
if obj.background_image:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.background_image.url)
|
||||
return obj.background_image.url
|
||||
return None
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
Переопределяем метод update для обработки случая, когда background_image не передается
|
||||
"""
|
||||
# Специально обрабатываем background_image
|
||||
background_image = validated_data.pop('background_image', None)
|
||||
|
||||
# Обновляем остальные поля
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
# Обновляем background_image только если передан новый файл
|
||||
if background_image is not None:
|
||||
instance.background_image = background_image
|
||||
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def validate_background_image(self, value):
|
||||
"""
|
||||
Валидация загружаемого изображения
|
||||
"""
|
||||
if value:
|
||||
# Проверяем размер файла (максимум 10MB)
|
||||
if value.size > 10 * 1024 * 1024:
|
||||
raise serializers.ValidationError('Размер файла не должен превышать 10MB')
|
||||
|
||||
# Проверяем тип файла
|
||||
if not value.content_type.startswith('image/'):
|
||||
raise serializers.ValidationError('Файл должен быть изображением')
|
||||
|
||||
# Допустимые форматы
|
||||
allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
||||
if value.content_type not in allowed_types:
|
||||
raise serializers.ValidationError('Поддерживаются только форматы: JPEG, PNG, GIF, WebP')
|
||||
|
||||
return value
|
||||
|
||||
def validate_theme_color(self, value):
|
||||
"""
|
||||
Валидация цвета темы (должен быть в формате hex)
|
||||
"""
|
||||
if not value.startswith('#') or len(value) != 7:
|
||||
raise serializers.ValidationError('Цвет должен быть в формате #RRGGBB')
|
||||
try:
|
||||
int(value[1:], 16)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError('Некорректный hex цвет')
|
||||
return value
|
||||
|
||||
def validate_dashboard_background_color(self, value):
|
||||
"""
|
||||
Валидация цвета фона дашборда
|
||||
"""
|
||||
if not value.startswith('#') or len(value) != 7:
|
||||
raise serializers.ValidationError('Цвет должен быть в формате #RRGGBB')
|
||||
try:
|
||||
int(value[1:], 16)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError('Некорректный hex цвет')
|
||||
return value
|
||||
|
||||
def validate_dashboard_layout(self, value):
|
||||
"""
|
||||
Валидация типа макета дашборда
|
||||
"""
|
||||
valid_layouts = ['sidebar', 'grid', 'list', 'cards', 'compact', 'masonry', 'timeline', 'magazine']
|
||||
if value not in valid_layouts:
|
||||
raise serializers.ValidationError(f'Макет должен быть одним из: {", ".join(valid_layouts)}')
|
||||
return value
|
||||
|
||||
def validate_font_family(self, value):
|
||||
"""
|
||||
Валидация шрифта
|
||||
"""
|
||||
if len(value) > 100:
|
||||
raise serializers.ValidationError('Название шрифта слишком длинное')
|
||||
return value
|
||||
|
||||
def validate_custom_css(self, value):
|
||||
"""
|
||||
Базовая валидация пользовательского CSS
|
||||
"""
|
||||
if len(value) > 10000: # Ограничение на размер CSS
|
||||
raise serializers.ValidationError('CSS код слишком длинный (максимум 10000 символов)')
|
||||
|
||||
# Простая проверка на потенциально опасные директивы
|
||||
dangerous_patterns = ['@import', 'javascript:', 'expression(']
|
||||
for pattern in dangerous_patterns:
|
||||
if pattern in value.lower():
|
||||
raise serializers.ValidationError(f'Обнаружена потенциально опасная директива: {pattern}')
|
||||
|
||||
return value
|
||||
|
||||
def validate_header_text_color(self, value):
|
||||
"""
|
||||
Валидация цвета заголовков
|
||||
"""
|
||||
if not value.startswith('#') or len(value) != 7:
|
||||
raise serializers.ValidationError('Цвет должен быть в формате #RRGGBB')
|
||||
try:
|
||||
int(value[1:], 16)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError('Некорректный hex цвет')
|
||||
return value
|
||||
|
||||
def validate_group_text_color(self, value):
|
||||
"""
|
||||
Валидация цвета названий групп
|
||||
"""
|
||||
if not value.startswith('#') or len(value) != 7:
|
||||
raise serializers.ValidationError('Цвет должен быть в формате #RRGGBB')
|
||||
try:
|
||||
int(value[1:], 16)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError('Некорректный hex цвет')
|
||||
return value
|
||||
|
||||
def validate_link_text_color(self, value):
|
||||
"""
|
||||
Валидация цвета названий ссылок
|
||||
"""
|
||||
if not value.startswith('#') or len(value) != 7:
|
||||
raise serializers.ValidationError('Цвет должен быть в формате #RRGGBB')
|
||||
try:
|
||||
int(value[1:], 16)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError('Некорректный hex цвет')
|
||||
return value
|
||||
|
||||
def validate_cover_overlay_color(self, value):
|
||||
"""
|
||||
Валидация цвета перекрытия обложки
|
||||
"""
|
||||
if not value.startswith('#') or len(value) != 7:
|
||||
raise serializers.ValidationError('Цвет должен быть в формате #RRGGBB')
|
||||
try:
|
||||
int(value[1:], 16)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError('Некорректный hex цвет')
|
||||
return value
|
||||
|
||||
def validate_cover_overlay_opacity(self, value):
|
||||
"""
|
||||
Валидация прозрачности перекрытия обложки
|
||||
"""
|
||||
if not 0.0 <= value <= 1.0:
|
||||
raise serializers.ValidationError('Прозрачность должна быть между 0.0 и 1.0')
|
||||
return value
|
||||
|
||||
|
||||
class PublicDesignSettingsSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Публичный сериализатор для настроек дизайна (только для отображения)
|
||||
"""
|
||||
background_image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DesignSettings
|
||||
fields = [
|
||||
'theme_color',
|
||||
'background_image_url',
|
||||
'font_family',
|
||||
'custom_css'
|
||||
]
|
||||
|
||||
def get_background_image_url(self, obj):
|
||||
"""
|
||||
Возвращает полный URL фонового изображения
|
||||
"""
|
||||
if obj.background_image:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.background_image.url)
|
||||
return obj.background_image.url
|
||||
return None
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.urls import path
|
||||
from .views import DesignSettingsView, get_user_design_settings
|
||||
|
||||
urlpatterns = [
|
||||
# дополнительные эндпоинты по ссылкам
|
||||
path('settings/', DesignSettingsView.as_view(), name='design-settings'),
|
||||
path('settings/<str:username>/', get_user_design_settings, name='user-design-settings'),
|
||||
]
|
||||
@@ -1,3 +1,66 @@
|
||||
from django.shortcuts import render
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
from django.shortcuts import get_object_or_404
|
||||
from .models import DesignSettings
|
||||
from .serializers import DesignSettingsSerializer, PublicDesignSettingsSerializer
|
||||
|
||||
# Create your views here.
|
||||
|
||||
class DesignSettingsView(generics.RetrieveUpdateAPIView):
|
||||
"""
|
||||
API для получения и обновления настроек дизайна пользователя
|
||||
"""
|
||||
serializer_class = DesignSettingsSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser] # Поддержка файлов и JSON
|
||||
|
||||
def get_object(self):
|
||||
# Получаем или создаем настройки для текущего пользователя
|
||||
settings, created = DesignSettings.objects.get_or_create(
|
||||
user=self.request.user
|
||||
)
|
||||
return settings
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def get_user_design_settings(request, username=None):
|
||||
"""
|
||||
Получение публичных настроек дизайна пользователя для отображения его страницы
|
||||
"""
|
||||
if username:
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
try:
|
||||
settings = DesignSettings.objects.get(user=user)
|
||||
# Возвращаем только публичные настройки
|
||||
serializer = PublicDesignSettingsSerializer(settings, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
except DesignSettings.DoesNotExist:
|
||||
# Возвращаем настройки по умолчанию, если пользователь еще не настроил дизайн
|
||||
default_settings = {
|
||||
'theme_color': '#ffffff',
|
||||
'dashboard_layout': 'list',
|
||||
'groups_default_expanded': True,
|
||||
'show_group_icons': True,
|
||||
'show_link_icons': True,
|
||||
'dashboard_background_color': '#f8f9fa',
|
||||
'font_family': 'sans-serif'
|
||||
}
|
||||
return Response(default_settings)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'Пользователь не найден'}, status=404)
|
||||
else:
|
||||
# Для получения настроек текущего пользователя требуется авторизация
|
||||
if not request.user.is_authenticated:
|
||||
return Response({'error': 'Требуется авторизация'}, status=401)
|
||||
# Возвращаем настройки текущего пользователя
|
||||
settings, created = DesignSettings.objects.get_or_create(user=request.user)
|
||||
serializer = DesignSettingsSerializer(settings, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# дополнительные эндпоинты по ссылкам
|
||||
path('profile/', views.user_profile, name='user-profile'),
|
||||
]
|
||||
@@ -1,3 +1,63 @@
|
||||
from django.shortcuts import render
|
||||
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 rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
# Create your views here.
|
||||
User = get_user_model()
|
||||
|
||||
class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор для профиля пользователя"""
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
cover_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'first_name', 'last_name',
|
||||
'full_name', 'bio', 'avatar', 'cover', 'avatar_url', 'cover_url']
|
||||
read_only_fields = ['id', 'username']
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
if obj.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
# Заменяем внутренний Docker URL на localhost для клиента
|
||||
absolute_uri = request.build_absolute_uri(obj.avatar.url)
|
||||
return absolute_uri.replace('http://web:8000', 'http://localhost:8000')
|
||||
return None
|
||||
|
||||
def get_cover_url(self, obj):
|
||||
if obj.cover:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
# Заменяем внутренний Docker URL на localhost для клиента
|
||||
absolute_uri = request.build_absolute_uri(obj.cover.url)
|
||||
return absolute_uri.replace('http://web:8000', 'http://localhost:8000')
|
||||
return None
|
||||
|
||||
@api_view(['GET', 'PUT', 'PATCH'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def user_profile(request):
|
||||
"""
|
||||
Получение и обновление профиля пользователя
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
if request.method == 'GET':
|
||||
serializer = UserProfileSerializer(user, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
|
||||
elif request.method in ['PUT', 'PATCH']:
|
||||
partial = request.method == 'PATCH'
|
||||
serializer = UserProfileSerializer(
|
||||
user,
|
||||
data=request.data,
|
||||
partial=partial,
|
||||
context={'request': request}
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
Reference in New Issue
Block a user