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 django.utils.timezone
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
@@ -11,7 +10,6 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
] ]
operations = [ operations = [
@@ -21,21 +19,16 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('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)), ('email', models.EmailField(max_length=254, unique=True)),
('name', models.CharField(max_length=100)), ('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)), ('is_email_confirmed', models.BooleanField(default=False)),
('email_confirmation_token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('email_confirmation_token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('email_confirmed_at', models.DateTimeField(blank=True, null=True)), ('email_confirmed_at', models.DateTimeField(blank=True, null=True)),
('password_reset_token', models.UUIDField(blank=True, editable=False, null=True, unique=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={ options={
'verbose_name': 'Пользователь магазина', '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 %} <form method="post">
<div class="container"> {% csrf_token %}
<div class="form-container"> <div class="form-group">
<h2 class="text-center mb-4">Установка пароля</h2> <label for="id_password1">Пароль</label>
<input type="password" name="password1" id="id_password1" required>
<!-- Приветственное сообщение -->
<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>
</div> </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>
</div> </body>
{% endblock %} </html>

View File

@@ -61,7 +61,7 @@ def login_view(request):
def logout_view(request): def logout_view(request):
logout(request) logout(request)
return redirect('index') return redirect('/')
@login_required @login_required
@@ -142,7 +142,7 @@ def password_reset_confirm(request, token):
user = CustomUser.objects.get(password_reset_token=token) user = CustomUser.objects.get(password_reset_token=token)
except CustomUser.DoesNotExist: except CustomUser.DoesNotExist:
messages.error(request, 'Ссылка для восстановления пароля недействительна.') messages.error(request, 'Ссылка для восстановления пароля недействительна.')
return redirect('index') return redirect('/')
if request.method == 'POST': if request.method == 'POST':
password1 = request.POST.get('password1') password1 = request.POST.get('password1')
@@ -178,7 +178,7 @@ def password_setup_confirm(request, token):
) )
except TenantRegistration.DoesNotExist: except TenantRegistration.DoesNotExist:
messages.error(request, 'Ссылка для настройки пароля недействительна.') messages.error(request, 'Ссылка для настройки пароля недействительна.')
return redirect('index') return redirect('/')
# Проверить истечение токена (7 дней) # Проверить истечение токена (7 дней)
if registration.password_setup_token_created_at: if registration.password_setup_token_created_at:
@@ -188,24 +188,30 @@ def password_setup_confirm(request, token):
request, request,
'Ссылка для настройки пароля истекла. Пожалуйста, свяжитесь с поддержкой.' 'Ссылка для настройки пароля истекла. Пожалуйста, свяжитесь с поддержкой.'
) )
return redirect('index') return redirect('/')
# Получить тенант и пользователя-владельца # Получить тенант и пользователя-владельца
from django.db import connection from django.db import connection
tenant = registration.tenant tenant = registration.tenant
if not tenant: if not tenant:
messages.error(request, 'Тенант не найден.') messages.error(request, 'Тенант не найден.')
return redirect('index') return redirect('/')
# Переключиться на схему тенанта чтобы найти владельца # Переключиться на схему тенанта чтобы найти владельца
connection.set_tenant(tenant) connection.set_tenant(tenant)
User = get_user_model() from accounts.models import CustomUser
try:
owner = User.objects.get(email=registration.owner_email) # Создаём пользователя если он не существует (для случаев когда активация прошла без создания пользователя)
except User.DoesNotExist: owner, created = CustomUser.objects.get_or_create(
connection.set_schema_to_public() email=registration.owner_email,
messages.error(request, 'Пользователь не найден.') defaults={
return redirect('index') 'name': registration.owner_name,
'is_active': False,
}
)
if created:
owner.is_email_confirmed = True
owner.save()
# Обработать POST - установить пароль # Обработать POST - установить пароль
if request.method == '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 django.db.models.deletion
import phonenumber_field.modelfields 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 import django.db.models.deletion
from django.db import migrations, models 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 import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@@ -9,10 +9,10 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('accounts', '0002_remove_customuser_first_name_and_more'), ('accounts', '0001_initial'),
('customers', '0002_initial'), ('customers', '0002_initial'),
('orders', '0002_initial'), ('orders', '0002_initial'),
('products', '0002_alter_configurableproduct_archived_by_and_more'), ('products', '0001_initial'),
] ]
operations = [ operations = [
@@ -34,6 +34,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('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='Мин. сумма заказа')), ('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='Автоматическая')), ('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='Категории')), ('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='Создал')), ('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='Исключенные товары')), ('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 import integrations.fields
from django.db import migrations, models from django.db import migrations, models
@@ -9,6 +10,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('products', '0001_initial'),
] ]
operations = [ operations = [
@@ -54,4 +56,22 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'WooCommerce', '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 django.db.models.deletion
import phonenumber_field.modelfields 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 import django.db.models.deletion
from django.db import migrations, models 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 django.db.models.deletion
import phonenumber_field.modelfields 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 import django.db.models.deletion
from django.db import migrations, models 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 import django.utils.timezone
from django.db import migrations, models 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.core.validators
import django.db.models.deletion import django.db.models.deletion
import products.models.photos import products.models.photos
from decimal import Decimal from decimal import Decimal
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -13,9 +12,9 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('accounts', '0001_initial'),
('inventory', '0001_initial'), ('inventory', '0001_initial'),
('orders', '0001_initial'), ('orders', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@@ -112,10 +111,13 @@ class Migration(migrations.Migration):
('description', models.TextField(blank=True, null=True, verbose_name='Описание')), ('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', 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='Статус')), ('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='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=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={ options={
'verbose_name': 'Вариативный товар', 'verbose_name': 'Вариативный товар',
@@ -196,6 +198,9 @@ class Migration(migrations.Migration):
('description', models.TextField(blank=True, null=True, verbose_name='Описание')), ('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', 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='Статус')), ('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='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=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='Цена со скидкой')), ('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='В наличии')), ('in_stock', models.BooleanField(db_index=True, default=False, help_text='Автоматически обновляется при изменении остатков на складе', verbose_name='В наличии')),
('search_keywords', models.TextField(blank=True, 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={ options={
'verbose_name': 'Товар', 'verbose_name': 'Товар',
@@ -290,7 +295,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления')),
('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удалена')), ('is_deleted', models.BooleanField(db_index=True, default=False, verbose_name='Удалена')),
('deleted_at', models.DateTimeField(blank=True, null=True, 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='Родительская категория')), ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='products.productcategory', verbose_name='Родительская категория')),
], ],
options={ options={
@@ -303,6 +308,16 @@ class Migration(migrations.Migration):
name='categories', name='categories',
field=models.ManyToManyField(blank=True, related_name='products', to='products.productcategory', verbose_name='Категории'), 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( migrations.CreateModel(
name='ProductCategoryPhoto', name='ProductCategoryPhoto',
fields=[ fields=[
@@ -341,7 +356,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('completed_at', models.DateTimeField(blank=True, null=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={ options={
'verbose_name': 'Задача импорта товаров', 'verbose_name': 'Задача импорта товаров',
@@ -359,6 +374,9 @@ class Migration(migrations.Migration):
('description', models.TextField(blank=True, null=True, verbose_name='Описание')), ('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', 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='Статус')), ('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='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('archived_at', models.DateTimeField(blank=True, null=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_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='Значение корректировки')), ('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='Временный комплект')), ('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='Категории')), ('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='Заказ')), ('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='Витрина')), ('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> </a>
</li> </li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{% if recommerce_integration_enabled %}
<li> <li>
<a class="dropdown-item" href="#" id="bulk-recommerce-sync"> <a class="dropdown-item" href="#" id="bulk-recommerce-sync">
<i class="bi bi-arrow-repeat"></i> Синхронизация с Recommerce <i class="bi bi-arrow-repeat"></i> Синхронизация с Recommerce
</a> </a>
</li> </li>
{% endif %}
</ul> </ul>
</div> </div>
</div> </div>
@@ -595,4 +597,19 @@
<script src="{% static 'products/js/batch-selection.js' %}?v=1.5"></script> <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/bulk-category-modal.js' %}?v=1.6"></script>
<script src="{% static 'products/js/recommerce-sync.js' %}?v=1.2"></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 %} {% endblock %}

View File

@@ -175,7 +175,7 @@ class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailVie
product_photos.sort(key=lambda x: (x.order, x.created_at)) product_photos.sort(key=lambda x: (x.order, x.created_at))
context['product_photos'] = product_photos context['product_photos'] = product_photos
context['photos_count'] = len(product_photos) context['photos_count'] = len(product_photos)
# Кешируем cost_price_details, чтобы не делать множественные запросы к БД # Кешируем cost_price_details, чтобы не делать множественные запросы к БД
from ..services.cost_calculator import ProductCostCalculator from ..services.cost_calculator import ProductCostCalculator
context['cost_price_details'] = ProductCostCalculator.get_cost_details(self.object) context['cost_price_details'] = ProductCostCalculator.get_cost_details(self.object)
@@ -277,7 +277,7 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
def get_queryset(self): def get_queryset(self):
# Получаем фильтр по типу # Получаем фильтр по типу
type_filter = self.request.GET.get('type', 'all') type_filter = self.request.GET.get('type', 'all')
# Получаем товары и комплекты (только постоянные комплекты) # Получаем товары и комплекты (только постоянные комплекты)
# Аннотируем товары данными об остатках из агрегированной таблицы Stock # Аннотируем товары данными об остатках из агрегированной таблицы Stock
total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField()) 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': elif is_active_filter == '0':
products = products.filter(status__in=['archived', 'discontinued']) products = products.filter(status__in=['archived', 'discontinued'])
kits = kits.filter(status__in=['archived', 'discontinued']) kits = kits.filter(status__in=['archived', 'discontinued'])
# Фильтрация по наличию (только для товаров) # Фильтрация по наличию (только для товаров)
if in_stock_filter == '1': if in_stock_filter == '1':
products = products.filter(in_stock=True) products = products.filter(in_stock=True)
elif in_stock_filter == '0': elif in_stock_filter == '0':
products = products.filter(in_stock=False) products = products.filter(in_stock=False)
# Фильтрация по тегам # Фильтрация по тегам
if tags: if tags:
products = products.filter(tags__id__in=tags).distinct() products = products.filter(tags__id__in=tags).distinct()
@@ -382,12 +382,12 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
# Применяем фильтр по типу # Применяем фильтр по типу
products_list = [] products_list = []
kits_list = [] kits_list = []
if type_filter in ['all', 'products']: if type_filter in ['all', 'products']:
products_list = list(products.order_by('-created_at')) products_list = list(products.order_by('-created_at'))
for p in products_list: for p in products_list:
p.item_type = 'product' p.item_type = 'product'
if type_filter in ['all', 'kits']: if type_filter in ['all', 'kits']:
kits_list = list(kits.order_by('-created_at')) kits_list = list(kits.order_by('-created_at'))
for k in kits_list: for k in kits_list:
@@ -411,6 +411,11 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
from ..models.base import BaseProductEntity from ..models.base import BaseProductEntity
item_statuses = BaseProductEntity.STATUS_CHOICES 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'] = { context['filters'] = {
'categories': ProductCategory.objects.filter(is_active=True), 'categories': ProductCategory.objects.filter(is_active=True),
@@ -431,7 +436,7 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
'has_discount': self.request.GET.get('has_discount', ''), 'has_discount': self.request.GET.get('has_discount', ''),
} }
} }
context['item_statuses'] = item_statuses 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> <style>
.navbar .dropdown:hover > .dropdown-menu { .navbar .dropdown:hover > .dropdown-menu {
display: block; display: block;
@@ -80,10 +81,16 @@
<!-- ⚙️ Настройки (только для owner/superuser) --> <!-- ⚙️ Настройки (только для owner/superuser) -->
{% if request.user.is_owner or request.user.is_superuser %} {% if request.user.is_owner or request.user.is_superuser %}
<li class="nav-item"> <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 %}" {% if request.tenant %}
href="{% url 'system_settings:settings' %}"> <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> ⚙️ Настройки
</a>
{% else %}
<a class="nav-link" href="/platform/dashboard">
⚙️ Настройки
</a>
{% endif %}
</li> </li>
{% endif %} {% endif %}
@@ -125,3 +132,4 @@
</div> </div>
</div> </div>
</nav> </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.core.validators
import django.db.models.deletion import django.db.models.deletion

View File

@@ -49,6 +49,13 @@ class RoleBasedPermissionBackend:
4. Никакие данные из public schema не используются! 4. Никакие данные из public schema не используются!
""" """
def authenticate(self, request, username=None, password=None, **kwargs):
"""
Этот backend НЕ выполняет аутентификацию.
Возвращает None, чтобы Django перешёл к следующему backend.
"""
return None
# Маппинг ролей на наборы разрешений # Маппинг ролей на наборы разрешений
# Формат: 'app_label': ['action1', 'action2', ...] # Формат: 'app_label': ['action1', 'action2', ...]
# где action - это префикс permission: add_product -> add, change_order -> change # где 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 import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@@ -17,7 +17,7 @@ class Migration(migrations.Migration):
name='Role', name='Role',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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='Название')), ('name', models.CharField(max_length=100, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')), ('description', models.TextField(blank=True, verbose_name='Описание')),
('is_system', models.BooleanField(default=True, help_text='Системные роли нельзя удалить', 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='Код роли'),
),
]