refactor(db): консолидация миграций и рефакторинг кода

Объединены изменения из промежуточных миграций в начальные миграции для упрощения истории базы данных.
Удалены миграции: accounts/0002, discounts/0002, orders/0003-0004, products/0002-0005, user_roles/0002, system_settings/0001-0002, integrations/0001-0002.
Добавлена автоматическая creation пользователя при установке пароля.
Обновлен UI страницы установки пароля с кастомным стилем.
Добавлен conditional rendering для кнопки синхронизации Recommerce.
Исправлены редиректы с 'index' на '/' в accounts views.
Добавлена проверка request.tenant в navbar и authenticate метод в auth backend.
This commit is contained in:
2026-01-14 16:30:28 +03:00
parent e7672588c6
commit caeb3f80bd
31 changed files with 238 additions and 558 deletions

View File

@@ -1,6 +1,5 @@
# Generated by Django 5.0.10 on 2026-01-08 15:58
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.contrib.auth.validators
import django.utils.timezone
import uuid
from django.db import migrations, models
@@ -11,7 +10,6 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
@@ -21,21 +19,16 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(max_length=254, unique=True)),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('is_staff', models.BooleanField(default=False)),
('is_superuser', models.BooleanField(default=False)),
('date_joined', models.DateTimeField(default=django.utils.timezone.now)),
('is_email_confirmed', models.BooleanField(default=False)),
('email_confirmation_token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('email_confirmed_at', models.DateTimeField(blank=True, null=True)),
('password_reset_token', models.UUIDField(blank=True, editable=False, null=True, unique=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='custom_user_set', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='custom_user_set', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'Пользователь магазина',

View File

@@ -1,54 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-09 21:04
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='customuser',
name='first_name',
),
migrations.RemoveField(
model_name='customuser',
name='groups',
),
migrations.RemoveField(
model_name='customuser',
name='last_name',
),
migrations.RemoveField(
model_name='customuser',
name='user_permissions',
),
migrations.RemoveField(
model_name='customuser',
name='username',
),
migrations.AlterField(
model_name='customuser',
name='date_joined',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='customuser',
name='is_active',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='customuser',
name='is_staff',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='customuser',
name='is_superuser',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,35 +1,108 @@
{% extends 'base.html' %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Установка пароля</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
padding: 40px;
max-width: 400px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 24px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
color: #333;
font-weight: 500;
margin-bottom: 8px;
}
input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
input[type="password"]:focus {
outline: none;
border-color: #667eea;
}
.btn {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
}
.btn:hover {
opacity: 0.9;
}
.messages {
margin-bottom: 20px;
}
.messages .error {
background: #fee;
color: #c33;
padding: 10px;
border-radius: 6px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<h1>Установка пароля</h1>
<p class="subtitle">для {{ tenant.name }}</p>
{% block title %}Установка пароля{% endblock %}
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% block content %}
<div class="container">
<div class="form-container">
<h2 class="text-center mb-4">Установка пароля</h2>
<!-- Приветственное сообщение -->
<div class="alert alert-info">
<strong>Добро пожаловать!</strong> Ваш магазин <strong>{{ tenant.name }}</strong> активирован.
<br>Установите пароль для входа в систему.
</div>
<div class="tab-content">
<div class="tab-pane fade show active" id="setup-password">
<form method="post">
{% csrf_token %}
{% include 'accounts/password_input.html' with field_name='password1' field_label='Пароль' required=True %}
{% include 'accounts/password_input.html' with field_name='password2' field_label='Подтверждение пароля' required=True %}
<button type="submit" class="btn btn-primary w-100">Установить пароль и войти</button>
</form>
<!-- Информация -->
<div class="text-center mt-3">
<small class="text-muted">
После установки пароля вы автоматически войдете в свой магазин.
</small>
</div>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="id_password1">Пароль</label>
<input type="password" name="password1" id="id_password1" required>
</div>
</div>
<div class="form-group">
<label for="id_password2">Подтвердите пароль</label>
<input type="password" name="password2" id="id_password2" required>
</div>
<button type="submit" class="btn">Установить пароль</button>
</form>
</div>
</div>
{% endblock %}
</body>
</html>

View File

@@ -61,7 +61,7 @@ def login_view(request):
def logout_view(request):
logout(request)
return redirect('index')
return redirect('/')
@login_required
@@ -142,7 +142,7 @@ def password_reset_confirm(request, token):
user = CustomUser.objects.get(password_reset_token=token)
except CustomUser.DoesNotExist:
messages.error(request, 'Ссылка для восстановления пароля недействительна.')
return redirect('index')
return redirect('/')
if request.method == 'POST':
password1 = request.POST.get('password1')
@@ -178,7 +178,7 @@ def password_setup_confirm(request, token):
)
except TenantRegistration.DoesNotExist:
messages.error(request, 'Ссылка для настройки пароля недействительна.')
return redirect('index')
return redirect('/')
# Проверить истечение токена (7 дней)
if registration.password_setup_token_created_at:
@@ -188,24 +188,30 @@ def password_setup_confirm(request, token):
request,
'Ссылка для настройки пароля истекла. Пожалуйста, свяжитесь с поддержкой.'
)
return redirect('index')
return redirect('/')
# Получить тенант и пользователя-владельца
from django.db import connection
tenant = registration.tenant
if not tenant:
messages.error(request, 'Тенант не найден.')
return redirect('index')
return redirect('/')
# Переключиться на схему тенанта чтобы найти владельца
connection.set_tenant(tenant)
User = get_user_model()
try:
owner = User.objects.get(email=registration.owner_email)
except User.DoesNotExist:
connection.set_schema_to_public()
messages.error(request, 'Пользователь не найден.')
return redirect('index')
from accounts.models import CustomUser
# Создаём пользователя если он не существует (для случаев когда активация прошла без создания пользователя)
owner, created = CustomUser.objects.get_or_create(
email=registration.owner_email,
defaults={
'name': registration.owner_name,
'is_active': False,
}
)
if created:
owner.is_email_confirmed = True
owner.save()
# Обработать POST - установить пароль
if request.method == 'POST':

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-08 15:58
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.db.models.deletion
import phonenumber_field.modelfields

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-08 15:58
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.db.models.deletion
from django.db import migrations, models

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-10 21:07
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.db.models.deletion
from django.db import migrations, models
@@ -9,10 +9,10 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0002_remove_customuser_first_name_and_more'),
('accounts', '0001_initial'),
('customers', '0002_initial'),
('orders', '0002_initial'),
('products', '0002_alter_configurableproduct_archived_by_and_more'),
('products', '0001_initial'),
]
operations = [
@@ -34,6 +34,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('min_order_amount', models.DecimalField(blank=True, decimal_places=2, help_text='Скидка применяется только если сумма заказа >= этого значения', max_digits=10, null=True, verbose_name='Мин. сумма заказа')),
('is_auto', models.BooleanField(default=False, help_text='Применяется автоматически при выполнении условий', verbose_name='Автоматическая')),
('combine_mode', models.CharField(choices=[('stack', 'Складывать (суммировать)'), ('max_only', 'Только максимум'), ('exclusive', 'Исключающая (отменяет остальные)')], default='max_only', help_text='stack = суммировать с другими, max_only = применить максимальную, exclusive = отменить остальные', max_length=20, verbose_name='Режим объединения')),
('categories', models.ManyToManyField(blank=True, related_name='discounts', to='products.productcategory', verbose_name='Категории')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_discounts', to='accounts.customuser', verbose_name='Создал')),
('excluded_products', models.ManyToManyField(blank=True, related_name='excluded_from_discounts', to='products.product', verbose_name='Исключенные товары')),

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-10 23:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('discounts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='discount',
name='combine_mode',
field=models.CharField(choices=[('stack', 'Складывать (суммировать)'), ('max_only', 'Только максимум'), ('exclusive', 'Исключающая (отменяет остальные)')], default='max_only', help_text='stack = суммировать с другими, max_only = применить максимальную, exclusive = отменить остальные', max_length=20, verbose_name='Режим объединения'),
),
]

View File

@@ -1,5 +1,6 @@
# Generated by Django 5.0.10 on 2026-01-11 21:19
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.db.models.deletion
import integrations.fields
from django.db import migrations, models
@@ -9,6 +10,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('products', '0001_initial'),
]
operations = [
@@ -54,4 +56,22 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'WooCommerce',
},
),
migrations.CreateModel(
name='IntegrationCategoryMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('integration_type', models.CharField(choices=[('recommerce', 'Recommerce'), ('woocommerce', 'WooCommerce')], db_index=True, max_length=20, verbose_name='Интеграция')),
('external_category_sku', models.CharField(help_text='SKU или ID категории на внешней площадке', max_length=100, verbose_name='Артикул категории во внешней системе')),
('external_category_name', models.CharField(blank=True, help_text='Для справки, не обязательно', max_length=200, verbose_name='Название категории во внешней системе')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_mappings', to='products.productcategory', verbose_name='Категория')),
],
options={
'verbose_name': 'Маппинг категории',
'verbose_name_plural': 'Маппинги категорий',
'indexes': [models.Index(fields=['integration_type', 'external_category_sku'], name='integration_integra_450473_idx')],
'unique_together': {('category', 'integration_type')},
},
),
]

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-13 21:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0001_add_integration_models'),
('products', '0004_configurableproduct_primary_category_and_more'),
]
operations = [
migrations.CreateModel(
name='IntegrationCategoryMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('integration_type', models.CharField(choices=[('recommerce', 'Recommerce'), ('woocommerce', 'WooCommerce')], db_index=True, max_length=20, verbose_name='Интеграция')),
('external_category_sku', models.CharField(help_text='SKU или ID категории на внешней площадке', max_length=100, verbose_name='Артикул категории во внешней системе')),
('external_category_name', models.CharField(blank=True, help_text='Для справки, не обязательно', max_length=200, verbose_name='Название категории во внешней системе')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integration_mappings', to='products.productcategory', verbose_name='Категория')),
],
options={
'verbose_name': 'Маппинг категории',
'verbose_name_plural': 'Маппинги категорий',
'indexes': [models.Index(fields=['integration_type', 'external_category_sku'], name='integration_integra_450473_idx')],
'unique_together': {('category', 'integration_type')},
},
),
]

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-08 15:58
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.db.models.deletion
import phonenumber_field.modelfields

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-08 15:58
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.db.models.deletion
from django.db import migrations, models

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-08 15:58
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.db.models.deletion
import phonenumber_field.modelfields

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-08 15:58
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.db.models.deletion
from django.db import migrations, models

View File

@@ -1,40 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-10 21:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('discounts', '0001_initial'),
('orders', '0002_initial'),
]
operations = [
migrations.AddField(
model_name='order',
name='applied_discount',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='discounts.discount', verbose_name='Примененная скидка'),
),
migrations.AddField(
model_name='order',
name='applied_promo_code',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Использованный промокод'),
),
migrations.AddField(
model_name='order',
name='discount_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Сумма скидки'),
),
migrations.AddField(
model_name='orderitem',
name='applied_discount',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items', to='discounts.discount', verbose_name='Скидка на позицию'),
),
migrations.AddField(
model_name='orderitem',
name='discount_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Сумма скидки'),
),
]

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-11 10:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('orders', '0003_order_applied_discount_order_applied_promo_code_and_more'),
]
operations = [
migrations.RemoveField(
model_name='order',
name='applied_discount',
),
migrations.RemoveField(
model_name='order',
name='applied_promo_code',
),
migrations.RemoveField(
model_name='order',
name='discount_amount',
),
migrations.RemoveField(
model_name='orderitem',
name='applied_discount',
),
migrations.RemoveField(
model_name='orderitem',
name='discount_amount',
),
]

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-08 15:56
# Generated by Django 5.0.10 on 2026-01-14 07:03
import django.utils.timezone
from django.db import migrations, models

View File

@@ -1,10 +1,9 @@
# Generated by Django 5.0.10 on 2026-01-08 15:58
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.core.validators
import django.db.models.deletion
import products.models.photos
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
@@ -13,9 +12,9 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0001_initial'),
('inventory', '0001_initial'),
('orders', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
@@ -112,10 +111,13 @@ class Migration(migrations.Migration):
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')),
('is_new', models.BooleanField(default=False, help_text='Отображать как новый товар', verbose_name='Новинка')),
('is_popular', models.BooleanField(default=False, help_text='Отображать как популярный товар', verbose_name='Популярный')),
('is_special', models.BooleanField(default=False, help_text='Отображать как спецпредложение (акция)', verbose_name='Спецпредложение')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')),
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')),
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to='accounts.customuser', verbose_name='Архивировано пользователем')),
],
options={
'verbose_name': 'Вариативный товар',
@@ -196,6 +198,9 @@ class Migration(migrations.Migration):
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')),
('is_new', models.BooleanField(default=False, help_text='Отображать как новый товар', verbose_name='Новинка')),
('is_popular', models.BooleanField(default=False, help_text='Отображать как популярный товар', verbose_name='Популярный')),
('is_special', models.BooleanField(default=False, help_text='Отображать как спецпредложение (акция)', verbose_name='Спецпредложение')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')),
@@ -206,7 +211,7 @@ class Migration(migrations.Migration):
('sale_price', models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, товар продается по этой цене (дешевле основной)', max_digits=10, null=True, verbose_name='Цена со скидкой')),
('in_stock', models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии')),
('search_keywords', models.TextField(blank=True, help_text='Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную.', verbose_name='Ключевые слова для поиска')),
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')),
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to='accounts.customuser', verbose_name='Архивировано пользователем')),
],
options={
'verbose_name': 'Товар',
@@ -290,7 +295,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удалена')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Время удаления')),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_categories', to=settings.AUTH_USER_MODEL, verbose_name='Удалена пользователем')),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_categories', to='accounts.customuser', verbose_name='Удалена пользователем')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='products.productcategory', verbose_name='Родительская категория')),
],
options={
@@ -303,6 +308,16 @@ class Migration(migrations.Migration):
name='categories',
field=models.ManyToManyField(blank=True, related_name='products', to='products.productcategory', verbose_name='Категории'),
),
migrations.AddField(
model_name='product',
name='external_category',
field=models.ForeignKey(blank=True, help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='external_%(class)ss', to='products.productcategory', verbose_name='Внешняя категория'),
),
migrations.AddField(
model_name='configurableproduct',
name='external_category',
field=models.ForeignKey(blank=True, help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='external_%(class)ss', to='products.productcategory', verbose_name='Внешняя категория'),
),
migrations.CreateModel(
name='ProductCategoryPhoto',
fields=[
@@ -341,7 +356,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Завершено')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_import_jobs', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_import_jobs', to='accounts.customuser', verbose_name='Пользователь')),
],
options={
'verbose_name': 'Задача импорта товаров',
@@ -359,6 +374,9 @@ class Migration(migrations.Migration):
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')),
('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')),
('is_new', models.BooleanField(default=False, help_text='Отображать как новый товар', verbose_name='Новинка')),
('is_popular', models.BooleanField(default=False, help_text='Отображать как популярный товар', verbose_name='Популярный')),
('is_special', models.BooleanField(default=False, help_text='Отображать как спецпредложение (акция)', verbose_name='Спецпредложение')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')),
@@ -368,8 +386,9 @@ class Migration(migrations.Migration):
('price_adjustment_type', models.CharField(choices=[('none', 'Без изменения'), ('increase_percent', 'Увеличить на %'), ('increase_amount', 'Увеличить на сумму'), ('decrease_percent', 'Уменьшить на %'), ('decrease_amount', 'Уменьшить на сумму')], default='none', max_length=20, verbose_name='Тип корректировки цены')),
('price_adjustment_value', models.DecimalField(decimal_places=2, default=0, help_text='Процент (%) или сумма (руб) в зависимости от типа корректировки', max_digits=10, verbose_name='Значение корректировки')),
('is_temporary', models.BooleanField(default=False, help_text='Временные комплекты не показываются в каталоге и создаются для конкретного заказа', verbose_name='Временный комплект')),
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')),
('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to='accounts.customuser', verbose_name='Архивировано пользователем')),
('categories', models.ManyToManyField(blank=True, related_name='kits', to='products.productcategory', verbose_name='Категории')),
('external_category', models.ForeignKey(blank=True, help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='external_%(class)ss', to='products.productcategory', verbose_name='Внешняя категория')),
('order', models.ForeignKey(blank=True, help_text='Заказ, для которого создан временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='orders.order', verbose_name='Заказ')),
('showcase', models.ForeignKey(blank=True, help_text='Витрина, на которой выложен временный комплект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='temporary_kits', to='inventory.showcase', verbose_name='Витрина')),
],

View File

@@ -1,68 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-09 20:42
import django.db.models.deletion
from django.db import migrations, models
def clear_invalid_user_references(apps, schema_editor):
"""
Очистить ссылки на PlatformAdmin в полях archived_by/deleted_by/user,
т.к. они теперь ссылаются на CustomUser.
Устанавливаем NULL для всех существующих ссылок на пользователей,
чтобы избежать ошибок referential integrity при изменении типа ForeignKey.
"""
ProductCategory = apps.get_model('products', 'ProductCategory')
Product = apps.get_model('products', 'Product')
ProductKit = apps.get_model('products', 'ProductKit')
ConfigurableProduct = apps.get_model('products', 'ConfigurableProduct')
ProductImportJob = apps.get_model('products', 'ProductImportJob')
# Очищаем все существующие ссылки (ставим NULL)
ProductCategory.objects.all().update(deleted_by=None)
Product.objects.all().update(archived_by=None)
ProductKit.objects.all().update(archived_by=None)
ConfigurableProduct.objects.all().update(archived_by=None)
# ProductImportJob.user не может быть NULL (CASCADE), поэтому удаляем записи
# если они были созданы PlatformAdmin (что маловероятно)
ProductImportJob.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
('products', '0001_initial'),
]
operations = [
# Сначала очищаем некорректные ссылки
migrations.RunPython(clear_invalid_user_references, reverse_code=migrations.RunPython.noop),
# Затем изменяем типы полей
migrations.AlterField(
model_name='configurableproduct',
name='archived_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to='accounts.customuser', verbose_name='Архивировано пользователем'),
),
migrations.AlterField(
model_name='product',
name='archived_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to='accounts.customuser', verbose_name='Архивировано пользователем'),
),
migrations.AlterField(
model_name='productcategory',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_categories', to='accounts.customuser', verbose_name='Удалена пользователем'),
),
migrations.AlterField(
model_name='productimportjob',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_import_jobs', to='accounts.customuser', verbose_name='Пользователь'),
),
migrations.AlterField(
model_name='productkit',
name='archived_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to='accounts.customuser', verbose_name='Архивировано пользователем'),
),
]

View File

@@ -1,58 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-12 21:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_alter_configurableproduct_archived_by_and_more'),
]
operations = [
migrations.AddField(
model_name='configurableproduct',
name='is_new',
field=models.BooleanField(default=False, help_text='Отображать как новый товар', verbose_name='Новинка'),
),
migrations.AddField(
model_name='configurableproduct',
name='is_popular',
field=models.BooleanField(default=False, help_text='Отображать как популярный товар', verbose_name='Популярный'),
),
migrations.AddField(
model_name='configurableproduct',
name='is_special',
field=models.BooleanField(default=False, help_text='Отображать как спецпредложение (акция)', verbose_name='Спецпредложение'),
),
migrations.AddField(
model_name='product',
name='is_new',
field=models.BooleanField(default=False, help_text='Отображать как новый товар', verbose_name='Новинка'),
),
migrations.AddField(
model_name='product',
name='is_popular',
field=models.BooleanField(default=False, help_text='Отображать как популярный товар', verbose_name='Популярный'),
),
migrations.AddField(
model_name='product',
name='is_special',
field=models.BooleanField(default=False, help_text='Отображать как спецпредложение (акция)', verbose_name='Спецпредложение'),
),
migrations.AddField(
model_name='productkit',
name='is_new',
field=models.BooleanField(default=False, help_text='Отображать как новый товар', verbose_name='Новинка'),
),
migrations.AddField(
model_name='productkit',
name='is_popular',
field=models.BooleanField(default=False, help_text='Отображать как популярный товар', verbose_name='Популярный'),
),
migrations.AddField(
model_name='productkit',
name='is_special',
field=models.BooleanField(default=False, help_text='Отображать как спецпредложение (акция)', verbose_name='Спецпредложение'),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-13 21:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0003_add_marketing_flags'),
]
operations = [
migrations.AddField(
model_name='configurableproduct',
name='primary_category',
field=models.ForeignKey(blank=True, help_text='Используется для интеграций с внешними площадками', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_%(class)ss', to='products.productcategory', verbose_name='Основная категория'),
),
migrations.AddField(
model_name='product',
name='primary_category',
field=models.ForeignKey(blank=True, help_text='Используется для интеграций с внешними площадками', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_%(class)ss', to='products.productcategory', verbose_name='Основная категория'),
),
migrations.AddField(
model_name='productkit',
name='primary_category',
field=models.ForeignKey(blank=True, help_text='Используется для интеграций с внешними площадками', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_%(class)ss', to='products.productcategory', verbose_name='Основная категория'),
),
]

View File

@@ -1,72 +0,0 @@
# Generated migration to rename primary_category to external_category
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0004_configurableproduct_primary_category_and_more'),
]
operations = [
# Rename primary_category to external_category for Product
migrations.RenameField(
model_name='product',
old_name='primary_category',
new_name='external_category',
),
# Update related_name for Product
migrations.AlterField(
model_name='product',
name='external_category',
field=models.ForeignKey(
blank=True,
help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)',
null=True,
on_delete=models.SET_NULL,
related_name='external_products',
to='products.productcategory',
verbose_name='Внешняя категория'
),
),
# Rename primary_category to external_category for ProductKit
migrations.RenameField(
model_name='productkit',
old_name='primary_category',
new_name='external_category',
),
# Update related_name for ProductKit
migrations.AlterField(
model_name='productkit',
name='external_category',
field=models.ForeignKey(
blank=True,
help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)',
null=True,
on_delete=models.SET_NULL,
related_name='external_productkits',
to='products.productcategory',
verbose_name='Внешняя категория'
),
),
# Rename primary_category to external_category for ConfigurableProduct
migrations.RenameField(
model_name='configurableproduct',
old_name='primary_category',
new_name='external_category',
),
# Update related_name for ConfigurableProduct
migrations.AlterField(
model_name='configurableproduct',
name='external_category',
field=models.ForeignKey(
blank=True,
help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)',
null=True,
on_delete=models.SET_NULL,
related_name='external_configurableproducts',
to='products.productcategory',
verbose_name='Внешняя категория'
),
),
]

View File

@@ -237,11 +237,13 @@
</a>
</li>
<li><hr class="dropdown-divider"></li>
{% if recommerce_integration_enabled %}
<li>
<a class="dropdown-item" href="#" id="bulk-recommerce-sync">
<i class="bi bi-arrow-repeat"></i> Синхронизация с Recommerce
</a>
</li>
{% endif %}
</ul>
</div>
</div>
@@ -595,4 +597,19 @@
<script src="{% static 'products/js/batch-selection.js' %}?v=1.5"></script>
<script src="{% static 'products/js/bulk-category-modal.js' %}?v=1.6"></script>
<script src="{% static 'products/js/recommerce-sync.js' %}?v=1.2"></script>
<script>
// Проверка состояния интеграции Recommerce
document.addEventListener('DOMContentLoaded', function() {
const syncBtn = document.getElementById('bulk-recommerce-sync');
if (syncBtn) {
syncBtn.addEventListener('click', function(e) {
{% if not recommerce_integration_enabled %}
e.preventDefault();
alert('Интеграция с Recommerce отключена. Включите её в настройках.');
return false;
{% endif %}
});
}
});
</script>
{% endblock %}

View File

@@ -175,7 +175,7 @@ class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailVie
product_photos.sort(key=lambda x: (x.order, x.created_at))
context['product_photos'] = product_photos
context['photos_count'] = len(product_photos)
# Кешируем cost_price_details, чтобы не делать множественные запросы к БД
from ..services.cost_calculator import ProductCostCalculator
context['cost_price_details'] = ProductCostCalculator.get_cost_details(self.object)
@@ -277,7 +277,7 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
def get_queryset(self):
# Получаем фильтр по типу
type_filter = self.request.GET.get('type', 'all')
# Получаем товары и комплекты (только постоянные комплекты)
# Аннотируем товары данными об остатках из агрегированной таблицы Stock
total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField())
@@ -331,13 +331,13 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
elif is_active_filter == '0':
products = products.filter(status__in=['archived', 'discontinued'])
kits = kits.filter(status__in=['archived', 'discontinued'])
# Фильтрация по наличию (только для товаров)
if in_stock_filter == '1':
products = products.filter(in_stock=True)
elif in_stock_filter == '0':
products = products.filter(in_stock=False)
# Фильтрация по тегам
if tags:
products = products.filter(tags__id__in=tags).distinct()
@@ -382,12 +382,12 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
# Применяем фильтр по типу
products_list = []
kits_list = []
if type_filter in ['all', 'products']:
products_list = list(products.order_by('-created_at'))
for p in products_list:
p.item_type = 'product'
if type_filter in ['all', 'kits']:
kits_list = list(kits.order_by('-created_at'))
for k in kits_list:
@@ -411,6 +411,11 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
from ..models.base import BaseProductEntity
item_statuses = BaseProductEntity.STATUS_CHOICES
# Получаем состояние интеграции Recommerce
from integrations.models import RecommerceIntegration
recommerce_integration = RecommerceIntegration.objects.first()
context['recommerce_integration_enabled'] = recommerce_integration.is_active if recommerce_integration else False
# Данные для фильтров
context['filters'] = {
'categories': ProductCategory.objects.filter(is_active=True),
@@ -431,7 +436,7 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
'has_discount': self.request.GET.get('has_discount', ''),
}
}
context['item_statuses'] = item_statuses
# Кнопки действий

View File

@@ -1,30 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-11 19:36
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='IntegrationConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('integration_id', models.CharField(choices=[('woocommerce', 'WooCommerce')], max_length=50, unique=True, verbose_name='Интеграция')),
('is_enabled', models.BooleanField(default=False, help_text='Глобальное включение интеграции для тенанта', verbose_name='Включена')),
('last_sync_at', models.DateTimeField(blank=True, null=True, verbose_name='Последняя синхронизация')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Настройка интеграции',
'verbose_name_plural': 'Настройки интеграций',
'ordering': ['integration_id'],
},
),
]

View File

@@ -1,16 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-11 21:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('system_settings', '0001_initial'),
]
operations = [
migrations.DeleteModel(
name='IntegrationConfig',
),
]

View File

@@ -1,4 +1,5 @@
<!-- navbar.html - Компонент навигационной панели -->
<!-- navbar.html - Компонент навигационной панели (только для tenant схем) -->
{% if request.tenant %}
<style>
.navbar .dropdown:hover > .dropdown-menu {
display: block;
@@ -80,10 +81,16 @@
<!-- ⚙️ Настройки (только для owner/superuser) -->
{% if request.user.is_owner or request.user.is_superuser %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.namespace == 'system_settings' or 'user_roles' in request.resolver_match.app_names %}active{% endif %}"
href="{% url 'system_settings:settings' %}">
⚙️ Настройки
</a>
{% if request.tenant %}
<a class="nav-link {% if request.resolver_match.namespace == 'system_settings' or 'user_roles' in request.resolver_match.app_names %}active{% endif %}"
href="{% url 'system_settings:settings' %}">
⚙️ Настройки
</a>
{% else %}
<a class="nav-link" href="/platform/dashboard">
⚙️ Настройки
</a>
{% endif %}
</li>
{% endif %}
@@ -125,3 +132,4 @@
</div>
</div>
</nav>
{% endif %}

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-08 15:56
# Generated by Django 5.0.10 on 2026-01-14 07:03
import django.core.validators
import django.db.models.deletion

View File

@@ -49,6 +49,13 @@ class RoleBasedPermissionBackend:
4. Никакие данные из public schema не используются!
"""
def authenticate(self, request, username=None, password=None, **kwargs):
"""
Этот backend НЕ выполняет аутентификацию.
Возвращает None, чтобы Django перешёл к следующему backend.
"""
return None
# Маппинг ролей на наборы разрешений
# Формат: 'app_label': ['action1', 'action2', ...]
# где action - это префикс permission: add_product -> add, change_order -> change

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.0.10 on 2026-01-08 15:58
# Generated by Django 5.0.10 on 2026-01-14 07:04
import django.db.models.deletion
from django.db import migrations, models
@@ -17,7 +17,7 @@ class Migration(migrations.Migration):
name='Role',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(choices=[('owner', 'Владелец'), ('manager', 'Менеджер'), ('florist', 'Флорист'), ('courier', 'Курьер')], max_length=20, unique=True, verbose_name='Код роли')),
('code', models.CharField(choices=[('platform_support', 'Техподдержка платформы'), ('owner', 'Владелец'), ('manager', 'Менеджер'), ('florist', 'Флорист'), ('courier', 'Курьер')], max_length=20, unique=True, verbose_name='Код роли')),
('name', models.CharField(max_length=100, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('is_system', models.BooleanField(default=True, help_text='Системные роли нельзя удалить', verbose_name='Системная роль')),

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.10 on 2026-01-11 09:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user_roles', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='role',
name='code',
field=models.CharField(choices=[('platform_support', 'Техподдержка платформы'), ('owner', 'Владелец'), ('manager', 'Менеджер'), ('florist', 'Флорист'), ('courier', 'Курьер')], max_length=20, unique=True, verbose_name='Код роли'),
),
]