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:
@@ -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': 'Пользователь магазина',
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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='Исключенные товары')),
|
||||
|
||||
@@ -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='Режим объединения'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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='Сумма скидки'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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='Витрина')),
|
||||
],
|
||||
|
||||
@@ -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='Архивировано пользователем'),
|
||||
),
|
||||
]
|
||||
@@ -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='Спецпредложение'),
|
||||
),
|
||||
]
|
||||
@@ -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='Основная категория'),
|
||||
),
|
||||
]
|
||||
@@ -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='Внешняя категория'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
# Кнопки действий
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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='Системная роль')),
|
||||
|
||||
@@ -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='Код роли'),
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user