Обновили шапку и вывод всехтоваров. Добавили фильтры
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-22 13:03
|
||||
# Generated by Django 5.2.7 on 2025-10-23 20:27
|
||||
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
|
||||
1
myproject/customers/__init__.py
Normal file
1
myproject/customers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
120
myproject/customers/admin.py
Normal file
120
myproject/customers/admin.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from django.contrib import admin
|
||||
from django.db import models
|
||||
from .models import Customer, Address
|
||||
|
||||
|
||||
class IsVipFilter(admin.SimpleListFilter):
|
||||
title = 'VIP статус'
|
||||
parameter_name = 'is_vip'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('yes', 'VIP'),
|
||||
('no', 'Не VIP'),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() == 'yes':
|
||||
return queryset.filter(loyalty_tier__in=['gold', 'platinum'])
|
||||
if self.value() == 'no':
|
||||
return queryset.exclude(loyalty_tier__in=['gold', 'platinum'])
|
||||
return queryset
|
||||
|
||||
|
||||
class AddressInline(admin.TabularInline):
|
||||
"""Inline для управления адресами клиента в интерфейсе администратора"""
|
||||
model = Address
|
||||
extra = 1
|
||||
verbose_name = "Адрес доставки"
|
||||
verbose_name_plural = "Адреса доставки"
|
||||
|
||||
|
||||
@admin.register(Customer)
|
||||
class CustomerAdmin(admin.ModelAdmin):
|
||||
"""Административный интерфейс для управления клиентами цветочного магазина"""
|
||||
list_display = (
|
||||
'full_name',
|
||||
'email',
|
||||
'phone',
|
||||
'loyalty_tier',
|
||||
'total_spent',
|
||||
'is_vip',
|
||||
'created_at'
|
||||
)
|
||||
list_filter = (
|
||||
'loyalty_tier',
|
||||
IsVipFilter,
|
||||
'created_at'
|
||||
)
|
||||
search_fields = (
|
||||
'name',
|
||||
'email',
|
||||
'phone'
|
||||
)
|
||||
date_hierarchy = 'created_at'
|
||||
ordering = ('-created_at',)
|
||||
readonly_fields = ('created_at', 'updated_at', 'total_spent', 'is_vip')
|
||||
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': ('name', 'email', 'phone')
|
||||
}),
|
||||
('Программа лояльности', {
|
||||
'fields': ('loyalty_tier', 'total_spent', 'is_vip'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Даты', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
inlines = [AddressInline]
|
||||
|
||||
|
||||
@admin.register(Address)
|
||||
class AddressAdmin(admin.ModelAdmin):
|
||||
"""Административный интерфейс для управления адресами доставки"""
|
||||
list_display = (
|
||||
'recipient_name',
|
||||
'full_address',
|
||||
'customer',
|
||||
'district',
|
||||
'is_default'
|
||||
)
|
||||
list_filter = (
|
||||
'is_default',
|
||||
'district',
|
||||
'created_at'
|
||||
)
|
||||
search_fields = (
|
||||
'recipient_name',
|
||||
'street',
|
||||
'building_number',
|
||||
'customer__name',
|
||||
'customer__email'
|
||||
)
|
||||
ordering = ('-is_default', '-created_at')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
fieldsets = (
|
||||
('Информация о получателе', {
|
||||
'fields': ('customer', 'recipient_name')
|
||||
}),
|
||||
('Адрес доставки', {
|
||||
'fields': ('street', 'building_number', 'apartment_number', 'district')
|
||||
}),
|
||||
('Дополнительная информация', {
|
||||
'fields': ('delivery_instructions',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Статус', {
|
||||
'fields': ('is_default',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Даты', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
23
myproject/customers/forms.py
Normal file
23
myproject/customers/forms.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django import forms
|
||||
from .models import Customer
|
||||
|
||||
class CustomerForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ['name', 'email', 'phone', 'loyalty_tier', 'notes']
|
||||
widgets = {
|
||||
'notes': forms.Textarea(attrs={'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name, field in self.fields.items():
|
||||
if field_name == 'notes':
|
||||
# Textarea already has rows=3 from widget, just add class
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
elif field_name == 'loyalty_tier':
|
||||
# Select fields need form-select class
|
||||
field.widget.attrs.update({'class': 'form-select'})
|
||||
else:
|
||||
# Regular input fields get form-control class
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
101
myproject/customers/migrations/0001_initial.py
Normal file
101
myproject/customers/migrations/0001_initial.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-23 23:46
|
||||
|
||||
import django.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Customer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('first_name', models.CharField(max_length=100, verbose_name='Имя')),
|
||||
('last_name', models.CharField(max_length=100, verbose_name='Фамилия')),
|
||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
|
||||
('phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Телефон в международном формате (например, +375291234567)', max_length=128, null=True, region=None, verbose_name='Телефон')),
|
||||
('preferred_colors', models.CharField(blank=True, help_text="Предпочтительные цветы цветов, например: 'красный, белый, желтый'", max_length=200, null=True, verbose_name='Предпочтительные цвета')),
|
||||
('loyalty_tier', models.CharField(choices=[('bronze', 'Бронза'), ('silver', 'Серебро'), ('gold', 'Золото'), ('platinum', 'Платина')], default='bronze', max_length=20, verbose_name='Уровень лояльности')),
|
||||
('total_spent', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Общая сумма покупок')),
|
||||
('birthday', models.DateField(blank=True, null=True, verbose_name='День рождения')),
|
||||
('anniversary', models.DateField(blank=True, null=True, verbose_name='Годовщина')),
|
||||
('notes', models.TextField(blank=True, help_text='Заметки о клиенте, особые предпочтения и т.д.', null=True, verbose_name='Заметки')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активный клиент')),
|
||||
('is_vip', models.BooleanField(default=False, verbose_name='VIP клиент')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('favorite_flower_types', models.ManyToManyField(blank=True, related_name='preferred_by_customers', to='products.product', verbose_name='Любимые виды цветов')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Клиент',
|
||||
'verbose_name_plural': 'Клиенты',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Address',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('recipient_name', models.CharField(help_text='Имя человека, которому будет доставлен заказ', max_length=200, verbose_name='Имя получателя')),
|
||||
('street', models.CharField(max_length=255, verbose_name='Улица')),
|
||||
('building_number', models.CharField(max_length=20, verbose_name='Номер здания')),
|
||||
('apartment_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер квартиры/офиса')),
|
||||
('district', models.CharField(blank=True, help_text='Район в Минске для удобства доставки', max_length=100, null=True, verbose_name='Район')),
|
||||
('delivery_instructions', models.TextField(blank=True, help_text='Дополнительные инструкции для курьера (домофон, подъезд и т.д.)', null=True, verbose_name='Инструкции для доставки')),
|
||||
('is_default', models.BooleanField(default=False, help_text='Использовать этот адрес для доставки по умолчанию', verbose_name='Адрес по умолчанию')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активный адрес')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='customers.customer', verbose_name='Клиент')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Адрес доставки',
|
||||
'verbose_name_plural': 'Адреса доставки',
|
||||
'ordering': ['-is_default', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='customer',
|
||||
index=models.Index(fields=['email'], name='customers_c_email_4fdeb3_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='customer',
|
||||
index=models.Index(fields=['phone'], name='customers_c_phone_8493fa_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='customer',
|
||||
index=models.Index(fields=['created_at'], name='customers_c_created_1ed0f4_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='customer',
|
||||
index=models.Index(fields=['is_active'], name='customers_c_is_acti_91d305_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='customer',
|
||||
index=models.Index(fields=['loyalty_tier'], name='customers_c_loyalty_5162a0_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='address',
|
||||
index=models.Index(fields=['customer'], name='customers_a_custome_53b543_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='address',
|
||||
index=models.Index(fields=['is_default'], name='customers_a_is_defa_631851_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='address',
|
||||
index=models.Index(fields=['is_active'], name='customers_a_is_acti_433713_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='address',
|
||||
index=models.Index(fields=['district'], name='customers_a_distric_ac47d5_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-24 07:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='customer',
|
||||
name='anniversary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='customer',
|
||||
name='birthday',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='customer',
|
||||
name='favorite_flower_types',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='customer',
|
||||
name='first_name',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='customer',
|
||||
name='last_name',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='customer',
|
||||
name='preferred_colors',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customer',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=200, verbose_name='Имя'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customer',
|
||||
name='email',
|
||||
field=models.EmailField(blank=True, max_length=254, verbose_name='Email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customer',
|
||||
name='loyalty_tier',
|
||||
field=models.CharField(choices=[('no_discount', 'Без скидки'), ('bronze', 'Бронза'), ('silver', 'Серебро'), ('gold', 'Золото'), ('platinum', 'Платина')], default='no_discount', max_length=20, verbose_name='Уровень лояльности'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='customer',
|
||||
index=models.Index(fields=['name'], name='customers_c_name_f018e2_idx'),
|
||||
),
|
||||
]
|
||||
19
myproject/customers/migrations/0003_alter_customer_phone.py
Normal file
19
myproject/customers/migrations/0003_alter_customer_phone.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-24 14:55
|
||||
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0002_remove_customer_anniversary_remove_customer_birthday_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customer',
|
||||
name='phone',
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, verbose_name='Телефон'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-24 16:57
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0003_alter_customer_phone'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='address',
|
||||
name='customers_a_is_acti_433713_idx',
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='customer',
|
||||
name='customers_c_is_acti_91d305_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='address',
|
||||
name='is_active',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='customer',
|
||||
name='is_active',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='customer',
|
||||
name='is_vip',
|
||||
),
|
||||
]
|
||||
19
myproject/customers/migrations/0005_alter_customer_phone.py
Normal file
19
myproject/customers/migrations/0005_alter_customer_phone.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-24 17:01
|
||||
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customers', '0004_remove_address_customers_a_is_acti_433713_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customer',
|
||||
name='phone',
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, unique=True, verbose_name='Телефон'),
|
||||
),
|
||||
]
|
||||
0
myproject/customers/migrations/__init__.py
Normal file
0
myproject/customers/migrations/__init__.py
Normal file
288
myproject/customers/models.py
Normal file
288
myproject/customers/models.py
Normal file
@@ -0,0 +1,288 @@
|
||||
import phonenumbers
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from products.models import Product
|
||||
|
||||
|
||||
class Customer(models.Model):
|
||||
"""
|
||||
Модель клиента для цветочного магазина в Минске, Беларусь.
|
||||
"""
|
||||
# Name field that is not required to be unique
|
||||
name = models.CharField(max_length=200, blank=True, verbose_name="Имя")
|
||||
|
||||
email = models.EmailField(blank=True, verbose_name="Email")
|
||||
|
||||
# Phone with validation using django-phonenumber-field
|
||||
phone = PhoneNumberField(
|
||||
blank=True,
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="Телефон",
|
||||
help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат"
|
||||
)
|
||||
|
||||
# Temporary field to store raw phone number during initialization
|
||||
_raw_phone = None
|
||||
|
||||
# Loyalty program
|
||||
loyalty_tier = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('no_discount', 'Без скидки'),
|
||||
('bronze', 'Бронза'),
|
||||
('silver', 'Серебро'),
|
||||
('gold', 'Золото'),
|
||||
('platinum', 'Платина'),
|
||||
],
|
||||
default='no_discount',
|
||||
verbose_name="Уровень лояльности"
|
||||
)
|
||||
|
||||
total_spent = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="Общая сумма покупок"
|
||||
)
|
||||
|
||||
# Additional notes
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Заметки",
|
||||
help_text="Заметки о клиенте, особые предпочтения и т.д."
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Клиент"
|
||||
verbose_name_plural = "Клиенты"
|
||||
indexes = [
|
||||
models.Index(fields=['name']),
|
||||
models.Index(fields=['email']),
|
||||
models.Index(fields=['phone']),
|
||||
models.Index(fields=['created_at']),
|
||||
models.Index(fields=['loyalty_tier']),
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
return self.name
|
||||
if self.phone:
|
||||
return str(self.phone)
|
||||
if self.email:
|
||||
return self.email
|
||||
return "Безымянный клиент"
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Полное имя клиента"""
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def is_vip(self):
|
||||
"""Проверяет, является ли клиент VIP на основе уровня лояльности"""
|
||||
return self.loyalty_tier in ("gold", "platinum")
|
||||
|
||||
def get_loyalty_discount(self):
|
||||
"""Возвращает скидку в зависимости от уровня лояльности"""
|
||||
discounts = {
|
||||
'no_discount': 0,
|
||||
'bronze': 0,
|
||||
'silver': 5, # 5%
|
||||
'gold': 10, # 10%
|
||||
'platinum': 15 # 15%
|
||||
}
|
||||
return discounts.get(self.loyalty_tier, 0)
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""Override to handle unique phone validation properly during updates"""
|
||||
# Run the phone number normalization again before unique validation
|
||||
if self.phone:
|
||||
# Check for existing customers with the same phone (excluding current instance if updating)
|
||||
existing = Customer.objects.filter(phone=self.phone)
|
||||
if self.pk:
|
||||
existing = existing.exclude(pk=self.pk)
|
||||
if existing.exists():
|
||||
raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'})
|
||||
|
||||
# Call parent validate_unique to handle other validation
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
def clean_phone(self):
|
||||
"""Custom cleaning for phone field to normalize before validation."""
|
||||
if self.phone:
|
||||
try:
|
||||
# Parse the phone number to check if it's valid and normalize it
|
||||
raw_phone = str(self.phone)
|
||||
|
||||
# If it starts with '8' and has 11 digits, it might be Russian domestic format
|
||||
if raw_phone.startswith('8') and len(raw_phone) == 11:
|
||||
# Try BY first for Belarusian numbers
|
||||
parsed = phonenumbers.parse(raw_phone, "BY")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
# If BY doesn't work, try RU as fallback
|
||||
parsed = phonenumbers.parse(raw_phone, "RU")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
# Try to parse without country code (might already be in international format)
|
||||
parsed = phonenumbers.parse(raw_phone, None)
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
except phonenumbers.NumberParseException:
|
||||
# If parsing fails, return as is and let field validation handle it
|
||||
pass
|
||||
|
||||
return self.phone
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
# Normalize phone before field validation runs
|
||||
if not exclude:
|
||||
exclude = []
|
||||
if 'phone' not in exclude and self.phone:
|
||||
normalized = None
|
||||
try:
|
||||
normalized = self.clean_phone()
|
||||
except Exception:
|
||||
normalized = None
|
||||
if normalized:
|
||||
# assign normalized value (E.164) so PhoneNumberField sees корректный формат
|
||||
self.phone = normalized
|
||||
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
def clean(self):
|
||||
"""Additional validation if needed."""
|
||||
super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Ensure phone is normalized even if save is called directly (not through form)
|
||||
# At this point, if it came through form validation, phone should already be normalized
|
||||
# But if save is called directly on the model, we still need to normalize
|
||||
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11:
|
||||
# This is likely a domestic format number that needs normalization
|
||||
try:
|
||||
# Try BY first for Belarusian numbers
|
||||
parsed = phonenumbers.parse(str(self.phone), "BY")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
else:
|
||||
# If BY doesn't work, try RU as fallback
|
||||
parsed = phonenumbers.parse(str(self.phone), "RU")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
except phonenumbers.NumberParseException:
|
||||
# If parsing fails, don't change it and let the field validation handle it
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def increment_total_spent(self, amount):
|
||||
"""Увеличивает общую сумму покупок"""
|
||||
self.total_spent = self.total_spent + amount
|
||||
self.save(update_fields=['total_spent'])
|
||||
|
||||
|
||||
class Address(models.Model):
|
||||
"""
|
||||
Модель адреса доставки для клиентов цветочного магазина в Минске.
|
||||
Клиент может иметь несколько адресов для разных получателей.
|
||||
"""
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='addresses',
|
||||
verbose_name="Клиент"
|
||||
)
|
||||
|
||||
# Address information for delivery in Minsk
|
||||
recipient_name = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name="Имя получателя",
|
||||
help_text="Имя человека, которому будет доставлен заказ"
|
||||
)
|
||||
|
||||
street = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name="Улица"
|
||||
)
|
||||
|
||||
building_number = models.CharField(
|
||||
max_length=20,
|
||||
verbose_name="Номер здания"
|
||||
)
|
||||
|
||||
apartment_number = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Номер квартиры/офиса"
|
||||
)
|
||||
|
||||
district = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Район",
|
||||
help_text="Район в Минске для удобства доставки"
|
||||
)
|
||||
|
||||
# Additional information for delivery
|
||||
delivery_instructions = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Инструкции для доставки",
|
||||
help_text="Дополнительные инструкции для курьера (домофон, подъезд и т.д.)"
|
||||
)
|
||||
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Адрес по умолчанию",
|
||||
help_text="Использовать этот адрес для доставки по умолчанию"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Адрес доставки"
|
||||
verbose_name_plural = "Адреса доставки"
|
||||
indexes = [
|
||||
models.Index(fields=['customer']),
|
||||
models.Index(fields=['is_default']),
|
||||
models.Index(fields=['district']),
|
||||
]
|
||||
ordering = ['-is_default', '-created_at']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_default:
|
||||
# If this address is being set as default, unset the default flag on other addresses for this customer
|
||||
Address.objects.filter(customer=self.customer, is_default=True).update(is_default=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
address_line = f"{self.street}, {self.building_number}"
|
||||
if self.apartment_number:
|
||||
address_line += f", кв/офис {self.apartment_number}"
|
||||
return f"{self.recipient_name} - {address_line}, {self.customer.full_name}"
|
||||
|
||||
@property
|
||||
def full_address(self):
|
||||
"""Полный адрес для доставки"""
|
||||
address = f"{self.street}, {self.building_number}"
|
||||
if self.apartment_number:
|
||||
address += f", кв/офис {self.apartment_number}"
|
||||
return address
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Удалить клиента{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1>Удалить клиента</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p>Вы уверены, что хотите удалить следующего клиента?</p>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<h5>{{ customer.full_name }}</h5>
|
||||
<p>
|
||||
<strong>Email:</strong> {{ customer.email }}<br>
|
||||
<strong>Телефон:</strong> {{ customer.phone|default:"Не указан" }}<br>
|
||||
<strong>Уровень лояльности:</strong> {{ customer.get_loyalty_tier_display }}<br>
|
||||
<strong>VIP:</strong> {% if customer.is_vip %}Да{% else %}Нет{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-danger"><strong>Внимание:</strong> Это действие нельзя отменить. Все данные клиента будут удалены.</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">Да, удалить</button>
|
||||
<a href="{% url 'customers:customer-detail' customer.pk %}" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
133
myproject/customers/templates/customers/customer_detail.html
Normal file
133
myproject/customers/templates/customers/customer_detail.html
Normal file
@@ -0,0 +1,133 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ customer.full_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Клиент: {{ customer.full_name }}</h1>
|
||||
<div>
|
||||
<a href="{% url 'customers:customer-update' customer.pk %}" class="btn btn-primary">Редактировать</a>
|
||||
<a href="{% url 'customers:customer-delete' customer.pk %}" class="btn btn-danger">Удалить</a>
|
||||
<a href="{% url 'customers:customer-list' %}" class="btn btn-secondary">Назад к списку</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Customer Info -->
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Информация о клиенте</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th>Имя:</th>
|
||||
<td>{{ customer.full_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Email:</th>
|
||||
<td>{{ customer.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Телефон:</th>
|
||||
<td>{{ customer.phone }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Уровень лояльности:</th>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if customer.loyalty_tier == 'bronze' %}bg-secondary text-dark
|
||||
{% elif customer.loyalty_tier == 'silver' %}bg-light text-dark
|
||||
{% elif customer.loyalty_tier == 'gold' %}bg-warning text-dark
|
||||
{% elif customer.loyalty_tier == 'platinum' %}bg-primary text-white
|
||||
{% endif %}">
|
||||
{{ customer.get_loyalty_tier_display }}
|
||||
</span>
|
||||
<span class="ms-2">({{ customer.get_loyalty_discount }}% скидка)</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Сумма покупок:</th>
|
||||
<td>{{ customer.total_spent|floatformat:2 }} руб.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>VIP:</th>
|
||||
<td>
|
||||
{% if customer.is_vip %}
|
||||
<span class="badge bg-success">Да</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Нет</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>День рождения:</th>
|
||||
<td>{{ customer.birthday|date:"d.m.Y"|default:"Не указан" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Годовщина:</th>
|
||||
<td>{{ customer.anniversary|date:"d.m.Y"|default:"Не указана" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Предпочтительные цвета:</th>
|
||||
<td>{{ customer.preferred_colors|default:"Не указаны" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Заметки:</th>
|
||||
<td>{{ customer.notes|default:"Нет" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата создания:</th>
|
||||
<td>{{ customer.created_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата обновления:</th>
|
||||
<td>{{ customer.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Addresses -->
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5>Адреса доставки</h5>
|
||||
<a href="#" class="btn btn-sm btn-outline-primary">Добавить адрес</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if addresses %}
|
||||
{% for address in addresses %}
|
||||
<div class="border p-3 mb-3 rounded {% if address.is_default %}border-primary border-2{% endif %}">
|
||||
{% if address.is_default %}
|
||||
<span class="badge bg-primary mb-2">Адрес по умолчанию</span>
|
||||
{% endif %}
|
||||
<h6>{{ address.recipient_name }}</h6>
|
||||
<p class="mb-1">
|
||||
<strong>Адрес:</strong> {{ address.full_address }}, {{ address.district }}
|
||||
</p>
|
||||
{% if address.delivery_instructions %}
|
||||
<p class="mb-1">
|
||||
<strong>Инструкции:</strong> {{ address.delivery_instructions }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>У клиента нет сохраненных адресов доставки.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
108
myproject/customers/templates/customers/customer_form.html
Normal file
108
myproject/customers/templates/customers/customer_form.html
Normal file
@@ -0,0 +1,108 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% if is_creating %}Добавить нового клиента{% else %}Редактировать клиента{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1>
|
||||
{% if is_creating %}
|
||||
Добавить нового клиента
|
||||
{% else %}
|
||||
Редактировать клиента
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row">
|
||||
<!-- Personal Information -->
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Личная информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
{{ form.name.label_tag }}
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger">{{ form.name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.phone.label_tag }}
|
||||
{{ form.phone }}
|
||||
<div class="form-text">Введите телефон в любом формате, например: +375291234567, 80291234567</div>
|
||||
{% if form.phone.errors %}
|
||||
<div class="text-danger">{{ form.phone.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.email.label_tag }}
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-danger">{{ form.email.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preferences and Status -->
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Предпочтения и статус</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
{{ form.loyalty_tier.label_tag }}
|
||||
{{ form.loyalty_tier }}
|
||||
{% if form.loyalty_tier.errors %}
|
||||
<div class="text-danger">{{ form.loyalty_tier.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Information -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5>Дополнительная информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
{{ form.notes.label_tag }}
|
||||
{{ form.notes }}
|
||||
{% if form.notes.errors %}
|
||||
<div class="text-danger">{{ form.notes.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if is_creating %}Создать клиента{% else %}Сохранить изменения{% endif %}
|
||||
</button>
|
||||
<a href="{% if form.instance.pk %}{% url 'customers:customer-detail' form.instance.pk %}{% else %}{% url 'customers:customer-list' %}{% endif %}" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
134
myproject/customers/templates/customers/customer_list.html
Normal file
134
myproject/customers/templates/customers/customer_list.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Клиенты{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Клиенты</h1>
|
||||
<a href="{% url 'customers:customer-create' %}" class="btn btn-primary">Добавить клиента</a>
|
||||
</div>
|
||||
|
||||
<!-- Search Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" name="q"
|
||||
value="{{ query|default:'' }}" placeholder="Поиск по имени, email или телефону...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-outline-primary">Поиск</button>
|
||||
{% if query %}
|
||||
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">Очистить</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customers Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
||||
{% if page_obj %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
<th>Email</th>
|
||||
<th>Телефон</th>
|
||||
<th>Уровень лояльности</th>
|
||||
<th>Сумма покупок</th>
|
||||
<th>VIP</th>
|
||||
<th class="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in page_obj %}
|
||||
<tr
|
||||
class="{% if customer.is_vip %}table-warning{% endif %}"
|
||||
style="cursor:pointer"
|
||||
onclick="window.location='{% url 'customers:customer-detail' customer.pk %}'"
|
||||
>
|
||||
<td class="fw-semibold">{{ customer.full_name }}</td>
|
||||
<td>{{ customer.email|default:'—' }}</td>
|
||||
<td>{{ customer.phone|default:'—' }}</td>
|
||||
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if customer.loyalty_tier == 'no_discount' %}bg-light text-muted
|
||||
{% elif customer.loyalty_tier == 'bronze' %}bg-secondary text-white
|
||||
{% elif customer.loyalty_tier == 'silver' %}bg-info text-dark
|
||||
{% elif customer.loyalty_tier == 'gold' %}bg-warning text-dark
|
||||
{% elif customer.loyalty_tier == 'platinum' %}bg-primary text-white
|
||||
{% endif %}
|
||||
">
|
||||
{{ customer.get_loyalty_tier_display }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>{{ customer.total_spent|default:0|floatformat:2 }} ₽</td>
|
||||
|
||||
<td>
|
||||
{% if customer.is_vip %}
|
||||
<span class="badge bg-success">Да</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary text-white">Нет</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-end" onclick="event.stopPropagation();">
|
||||
<a href="{% url 'customers:customer-detail' customer.pk %}"
|
||||
class="btn btn-sm btn-outline-primary">👁</a>
|
||||
<a href="{% url 'customers:customer-update' customer.pk %}"
|
||||
class="btn btn-sm btn-outline-secondary">✎</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if query %}&q={{ query }}{% endif %}">Предыдущая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ num }}{% if query %}&q={{ query }}{% endif %}">{{ num }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if query %}&q={{ query }}{% endif %}">Следующая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p>Клиенты не найдены.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
12
myproject/customers/urls.py
Normal file
12
myproject/customers/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'customers'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.customer_list, name='customer-list'),
|
||||
path('create/', views.customer_create, name='customer-create'),
|
||||
path('<int:pk>/', views.customer_detail, name='customer-detail'),
|
||||
path('<int:pk>/edit/', views.customer_update, name='customer-update'),
|
||||
path('<int:pk>/delete/', views.customer_delete, name='customer-delete'),
|
||||
]
|
||||
105
myproject/customers/views.py
Normal file
105
myproject/customers/views.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib import messages
|
||||
from django.core.paginator import Paginator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
import phonenumbers
|
||||
from .models import Customer, Address
|
||||
from .forms import CustomerForm
|
||||
|
||||
|
||||
def normalize_query_phone(q):
|
||||
"""Normalize phone number for search"""
|
||||
try:
|
||||
parsed = phonenumbers.parse(q, "BY")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
return q
|
||||
except:
|
||||
return q
|
||||
|
||||
|
||||
def customer_list(request):
|
||||
"""Список всех клиентов"""
|
||||
query = request.GET.get('q')
|
||||
customers = Customer.objects.all()
|
||||
|
||||
if query:
|
||||
# Try to normalize the phone number for searching
|
||||
phone_normalized = normalize_query_phone(query)
|
||||
customers = customers.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(email__icontains=query) |
|
||||
Q(phone__icontains=phone_normalized)
|
||||
)
|
||||
|
||||
customers = customers.order_by('-created_at')
|
||||
|
||||
# Пагинация
|
||||
paginator = Paginator(customers, 25) # 25 клиентов на страницу
|
||||
page_number = request.GET.get('page')
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
context = {
|
||||
'page_obj': page_obj,
|
||||
'query': query,
|
||||
}
|
||||
return render(request, 'customers/customer_list.html', context)
|
||||
|
||||
|
||||
def customer_detail(request, pk):
|
||||
"""Детали клиента"""
|
||||
customer = get_object_or_404(Customer, pk=pk)
|
||||
addresses = customer.addresses.all()
|
||||
|
||||
context = {
|
||||
'customer': customer,
|
||||
'addresses': addresses,
|
||||
}
|
||||
return render(request, 'customers/customer_detail.html', context)
|
||||
|
||||
|
||||
def customer_create(request):
|
||||
"""Создание нового клиента"""
|
||||
if request.method == 'POST':
|
||||
form = CustomerForm(request.POST)
|
||||
if form.is_valid():
|
||||
customer = form.save()
|
||||
messages.success(request, f'Клиент {customer.full_name} успешно создан.')
|
||||
return redirect('customers:customer-detail', pk=customer.pk)
|
||||
else:
|
||||
form = CustomerForm()
|
||||
|
||||
return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': True})
|
||||
|
||||
|
||||
def customer_update(request, pk):
|
||||
"""Редактирование клиента"""
|
||||
customer = get_object_or_404(Customer, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = CustomerForm(request.POST, instance=customer)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, f'Клиент {customer.full_name} успешно обновлён.')
|
||||
return redirect('customers:customer-detail', pk=customer.pk)
|
||||
else:
|
||||
form = CustomerForm(instance=customer)
|
||||
|
||||
return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': False})
|
||||
|
||||
|
||||
def customer_delete(request, pk):
|
||||
"""Удаление клиента"""
|
||||
customer = get_object_or_404(Customer, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
customer_name = customer.full_name
|
||||
customer.delete()
|
||||
messages.success(request, f'Клиент {customer_name} успешно удален.')
|
||||
return redirect('customers:customer-list')
|
||||
|
||||
context = {
|
||||
'customer': customer
|
||||
}
|
||||
return render(request, 'customers/customer_confirm_delete.html', context)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-22 13:03
|
||||
# Generated by Django 5.2.7 on 2025-10-23 20:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
||||
'products',
|
||||
'inventory',
|
||||
'orders',
|
||||
'customers',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -185,5 +186,20 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
DEFAULT_FROM_EMAIL = 'noreply@example.com'
|
||||
|
||||
# Настройки телефонных номеров
|
||||
PHONENUMBER_DEFAULT_REGION = 'BY' # Регион по умолчанию для номеров без кода страны
|
||||
|
||||
# Указываем нашу кастомную модель пользователя
|
||||
AUTH_USER_MODEL = 'accounts.CustomUser'
|
||||
|
||||
# ВРЕМЕННЫЙ ФИХ для SQLite: удалить когда база данных будет PostgreSQL
|
||||
# Регистрируем кастомную функцию LOWER для поддержки кириллицы в SQLite
|
||||
if 'sqlite' in DATABASES['default']['ENGINE']:
|
||||
from django.db.backends.signals import connection_created
|
||||
from django.dispatch import receiver
|
||||
|
||||
@receiver(connection_created)
|
||||
def setup_sqlite_unicode_support(sender, connection, **kwargs):
|
||||
"""Добавляет поддержку Unicode для LOWER() в SQLite"""
|
||||
if connection.vendor == 'sqlite':
|
||||
connection.connection.create_function('LOWER', 1, lambda s: s.lower() if s else s)
|
||||
|
||||
@@ -26,6 +26,7 @@ urlpatterns = [
|
||||
path('', views.index, name='index'), # Main page
|
||||
path('accounts/', include('accounts.urls')),
|
||||
path('products/', include('products.urls')),
|
||||
path('customers/', include('customers.urls')),
|
||||
]
|
||||
|
||||
# Serve media files during development
|
||||
|
||||
33
myproject/myproject/urls.py.backup
Normal file
33
myproject/myproject/urls.py.backup
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
URL configuration for myproject project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('_nested_admin/', include('nested_admin.urls')), # Для nested admin
|
||||
path('admin/', admin.site.urls),
|
||||
path('', views.index, name='index'), # Main page
|
||||
path('accounts/', include('accounts.urls')),
|
||||
path('products/', include('products.urls')),
|
||||
]
|
||||
|
||||
# Serve media files during development
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
12
myproject/myproject/views.py.backup
Normal file
12
myproject/myproject/views.py.backup
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
|
||||
def index(request):
|
||||
# Главная страница - отображается для всех пользователей
|
||||
# Если пользователь авторизован, можно показать персонализированное содержимое
|
||||
if request.user.is_authenticated:
|
||||
# Здесь можно отобразить персонализированное содержимое для авторизованных пользователей
|
||||
return render(request, 'dashboard.html') # или другую страницу
|
||||
else:
|
||||
# Для неавторизованных пользователей показываем приветственную страницу
|
||||
return render(request, 'home.html')
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-22 13:03
|
||||
# Generated by Django 5.2.7 on 2025-10-23 20:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-24 07:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='kititem',
|
||||
index=models.Index(fields=['kit'], name='products_ki_kit_id_d28dc9_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='kititem',
|
||||
index=models.Index(fields=['product'], name='products_ki_product_d2ad00_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='kititem',
|
||||
index=models.Index(fields=['variant_group'], name='products_ki_variant_e42628_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='kititem',
|
||||
index=models.Index(fields=['kit', 'product'], name='products_ki_kit_id_14738f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='kititem',
|
||||
index=models.Index(fields=['kit', 'variant_group'], name='products_ki_kit_id_8199a8_idx'),
|
||||
),
|
||||
]
|
||||
@@ -1,133 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-23 12:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_products', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='product',
|
||||
name='slug',
|
||||
field=models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productcategory',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productcategory',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'),
|
||||
),
|
||||
migrations.AddField(
|
||||
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=settings.AUTH_USER_MODEL, verbose_name='Удалена пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productcategory',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='Удалена'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productcategory',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productkit',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productkit',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_kits', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productkit',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='producttag',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, null=True, verbose_name='Дата создания'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='producttag',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Время удаления'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='producttag',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_tags', to=settings.AUTH_USER_MODEL, verbose_name='Удален пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='producttag',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='Удален'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='producttag',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Дата обновления'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='product',
|
||||
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_3bba04_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='product',
|
||||
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_f30efb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productcategory',
|
||||
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_2a96d1_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productcategory',
|
||||
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_b8cdf3_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productkit',
|
||||
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_e83a83_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productkit',
|
||||
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_1e5bec_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='producttag',
|
||||
index=models.Index(fields=['is_deleted'], name='products_pr_is_dele_ea9be0_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='producttag',
|
||||
index=models.Index(fields=['is_deleted', 'created_at'], name='products_pr_is_dele_bc2d9c_idx'),
|
||||
),
|
||||
]
|
||||
@@ -523,6 +523,43 @@ class ProductKit(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
"""Валидация комплекта перед сохранением"""
|
||||
# Проверка соответствия метода ценообразования полям
|
||||
if self.pricing_method == 'fixed' and not self.fixed_price:
|
||||
raise ValidationError({
|
||||
'fixed_price': 'Для метода ценообразования "fixed" необходимо указать фиксированную цену.'
|
||||
})
|
||||
|
||||
if self.pricing_method == 'from_cost_plus_percent' and (
|
||||
self.markup_percent is None or self.markup_percent < 0
|
||||
):
|
||||
raise ValidationError({
|
||||
'markup_percent': 'Для метода ценообразования "from_cost_plus_percent" необходимо указать процент наценки >= 0.'
|
||||
})
|
||||
|
||||
if self.pricing_method == 'from_cost_plus_amount' and (
|
||||
self.markup_amount is None or self.markup_amount < 0
|
||||
):
|
||||
raise ValidationError({
|
||||
'markup_amount': 'Для метода ценообразования "from_cost_plus_amount" необходимо указать сумму наценки >= 0.'
|
||||
})
|
||||
|
||||
# Проверка уникальности SKU (если задан)
|
||||
if self.sku:
|
||||
# Проверяем, что SKU не используется другим комплектом (если объект уже существует)
|
||||
if self.pk:
|
||||
if ProductKit.objects.filter(sku=self.sku).exclude(pk=self.pk).exists():
|
||||
raise ValidationError({
|
||||
'sku': f'Артикул "{self.sku}" уже используется другим комплектом.'
|
||||
})
|
||||
else:
|
||||
# Для новых объектов просто проверяем, что SKU не используется
|
||||
if ProductKit.objects.filter(sku=self.sku).exists():
|
||||
raise ValidationError({
|
||||
'sku': f'Артикул "{self.sku}" уже используется другим комплектом.'
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
from unidecode import unidecode
|
||||
@@ -541,15 +578,30 @@ class ProductKit(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_total_components_count(self):
|
||||
"""Возвращает количество позиций в букете"""
|
||||
"""
|
||||
Возвращает количество компонентов (строк) в комплекте.
|
||||
|
||||
Returns:
|
||||
int: Количество компонентов в комплекте
|
||||
"""
|
||||
return self.kit_items.count()
|
||||
|
||||
def get_components_with_variants_count(self):
|
||||
"""Возвращает количество позиций с группами вариантов"""
|
||||
"""
|
||||
Возвращает количество компонентов, которые используют группы вариантов.
|
||||
|
||||
Returns:
|
||||
int: Количество компонентов с группами вариантов
|
||||
"""
|
||||
return self.kit_items.filter(variant_group__isnull=False).count()
|
||||
|
||||
def get_sale_price(self):
|
||||
"""Возвращает рассчитанную цену продажи комплекта"""
|
||||
"""
|
||||
Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования.
|
||||
|
||||
Returns:
|
||||
Decimal: Цена продажи комплекта
|
||||
"""
|
||||
try:
|
||||
return self.calculate_price_with_substitutions()
|
||||
except Exception:
|
||||
@@ -560,8 +612,16 @@ class ProductKit(models.Model):
|
||||
|
||||
def check_availability(self, stock_manager=None):
|
||||
"""
|
||||
Проверяет доступность всего букета.
|
||||
Букет доступен, если для каждой позиции есть хотя бы один доступный вариант.
|
||||
Проверяет доступность всего комплекта.
|
||||
|
||||
Комплект доступен, если для каждой позиции в комплекте
|
||||
есть хотя бы один доступный вариант товара.
|
||||
|
||||
Args:
|
||||
stock_manager: Объект управления складом (если не указан, используется стандартный)
|
||||
|
||||
Returns:
|
||||
bool: True, если комплект полностью доступен, иначе False
|
||||
"""
|
||||
from .utils.stock_manager import StockManager
|
||||
|
||||
@@ -577,10 +637,18 @@ class ProductKit(models.Model):
|
||||
|
||||
def calculate_price_with_substitutions(self, stock_manager=None):
|
||||
"""
|
||||
Расчёт цены букета с учётом доступных замен.
|
||||
Использует цены фактически доступных товаров.
|
||||
Расчёт цены комплекта с учётом доступных замен компонентов.
|
||||
|
||||
Метод определяет цену комплекта, учитывая доступные товары-заменители
|
||||
и применяет выбранный метод ценообразования.
|
||||
|
||||
Args:
|
||||
stock_manager: Объект управления складом (если не указан, используется стандартный)
|
||||
|
||||
Returns:
|
||||
Decimal: Расчетная цена комплекта, или 0 в случае ошибки
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from .utils.stock_manager import StockManager
|
||||
|
||||
if stock_manager is None:
|
||||
@@ -594,28 +662,75 @@ class ProductKit(models.Model):
|
||||
total_sale = Decimal('0.00')
|
||||
|
||||
for kit_item in self.kit_items.select_related('product', 'variant_group'):
|
||||
best_product = kit_item.get_best_available_product(stock_manager)
|
||||
try:
|
||||
best_product = kit_item.get_best_available_product(stock_manager)
|
||||
|
||||
if not best_product:
|
||||
# Если товар недоступен, используем цену первого в списке
|
||||
available_products = kit_item.get_available_products()
|
||||
best_product = available_products[0] if available_products else None
|
||||
if not best_product:
|
||||
# Если товар недоступен, используем цену первого в списке
|
||||
available_products = kit_item.get_available_products()
|
||||
best_product = available_products[0] if available_products else None
|
||||
|
||||
if best_product:
|
||||
total_cost += best_product.cost_price * kit_item.quantity
|
||||
total_sale += best_product.sale_price * kit_item.quantity
|
||||
if best_product:
|
||||
item_cost = best_product.cost_price
|
||||
item_sale = best_product.sale_price
|
||||
item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано
|
||||
|
||||
# Проверяем корректность значений перед умножением
|
||||
if item_cost and item_quantity:
|
||||
total_cost += item_cost * item_quantity
|
||||
if item_sale and item_quantity:
|
||||
total_sale += item_sale * item_quantity
|
||||
except (AttributeError, TypeError, InvalidOperation) as e:
|
||||
# Логируем ошибку, но продолжаем вычисления
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Ошибка при расчёте цены для комплекта {self.name} (item: {kit_item}): {e}")
|
||||
continue # Пропускаем ошибочный элемент и продолжаем с остальными
|
||||
|
||||
# Применяем метод ценообразования
|
||||
if self.pricing_method == 'from_sale_prices':
|
||||
return total_sale
|
||||
elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent:
|
||||
return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
|
||||
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount:
|
||||
return total_cost + self.markup_amount
|
||||
elif self.pricing_method == 'fixed' and self.fixed_price:
|
||||
return self.fixed_price
|
||||
try:
|
||||
if self.pricing_method == 'from_sale_prices':
|
||||
return total_sale
|
||||
elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent is not None:
|
||||
return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
|
||||
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount is not None:
|
||||
return total_cost + self.markup_amount
|
||||
elif self.pricing_method == 'fixed' and self.fixed_price:
|
||||
return self.fixed_price
|
||||
|
||||
return total_sale
|
||||
return total_sale
|
||||
except (TypeError, InvalidOperation) as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Ошибка при применении метода ценообразования для комплекта {self.name}: {e}")
|
||||
# Возвращаем фиксированную цену если есть, иначе 0
|
||||
if self.pricing_method == 'fixed' and self.fixed_price:
|
||||
return self.fixed_price
|
||||
return Decimal('0.00')
|
||||
|
||||
def calculate_cost(self):
|
||||
"""
|
||||
Расчёт себестоимости комплекта на основе себестоимости компонентов.
|
||||
|
||||
Returns:
|
||||
Decimal: Себестоимость комплекта
|
||||
"""
|
||||
from decimal import Decimal
|
||||
total_cost = Decimal('0.00')
|
||||
|
||||
for kit_item in self.kit_items.select_related('product', 'variant_group'):
|
||||
# Получаем продукт - либо конкретный, либо первый из группы вариантов
|
||||
product = kit_item.product
|
||||
if not product and kit_item.variant_group:
|
||||
# Берем первый продукт из группы вариантов
|
||||
product = kit_item.variant_group.products.filter(is_active=True).first()
|
||||
|
||||
if product:
|
||||
item_cost = product.cost_price
|
||||
item_quantity = kit_item.quantity or Decimal('1.00') # По умолчанию 1, если количество не указано
|
||||
total_cost += item_cost * item_quantity
|
||||
|
||||
return total_cost
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Soft delete вместо hard delete - марк как удаленный"""
|
||||
@@ -663,6 +778,13 @@ class KitItem(models.Model):
|
||||
class Meta:
|
||||
verbose_name = "Компонент комплекта"
|
||||
verbose_name_plural = "Компоненты комплектов"
|
||||
indexes = [
|
||||
models.Index(fields=['kit']),
|
||||
models.Index(fields=['product']),
|
||||
models.Index(fields=['variant_group']),
|
||||
models.Index(fields=['kit', 'product']),
|
||||
models.Index(fields=['kit', 'variant_group']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.kit.name} - {self.get_display_name()}"
|
||||
@@ -679,17 +801,36 @@ class KitItem(models.Model):
|
||||
)
|
||||
|
||||
def get_display_name(self):
|
||||
"""Возвращает название для отображения (товар или группа)"""
|
||||
"""
|
||||
Возвращает строку для отображения названия компонента.
|
||||
|
||||
Returns:
|
||||
str: Название компонента (либо группа вариантов, либо конкретный товар)
|
||||
"""
|
||||
if self.variant_group:
|
||||
return f"[Варианты] {self.variant_group.name}"
|
||||
return self.product.name if self.product else "Не указан"
|
||||
|
||||
def has_priorities_set(self):
|
||||
"""Проверяет, настроены ли приоритеты"""
|
||||
"""
|
||||
Проверяет, настроены ли приоритеты замены для данного компонента.
|
||||
|
||||
Returns:
|
||||
bool: True, если приоритеты установлены, иначе False
|
||||
"""
|
||||
return self.priorities.exists()
|
||||
|
||||
def get_available_products(self):
|
||||
"""Возвращает список доступных товаров с учётом приоритетов"""
|
||||
"""
|
||||
Возвращает список доступных товаров для этого компонента.
|
||||
|
||||
Если указан конкретный товар - возвращает его.
|
||||
Если указаны приоритеты - возвращает товары в порядке приоритета.
|
||||
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
|
||||
|
||||
Returns:
|
||||
list: Список доступных товаров
|
||||
"""
|
||||
if self.product:
|
||||
# Если указан конкретный товар, возвращаем только его
|
||||
return [self.product]
|
||||
@@ -707,7 +848,15 @@ class KitItem(models.Model):
|
||||
return []
|
||||
|
||||
def get_best_available_product(self, stock_manager=None):
|
||||
"""Возвращает первый доступный товар по приоритету"""
|
||||
"""
|
||||
Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству.
|
||||
|
||||
Args:
|
||||
stock_manager: Объект управления складом (если не указан, используется стандартный)
|
||||
|
||||
Returns:
|
||||
Product or None: Первый доступный товар или None, если ничего не доступно
|
||||
"""
|
||||
from .utils.stock_manager import StockManager
|
||||
|
||||
if stock_manager is None:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<!-- КОМПОНЕНТЫ КОМПЛЕКТА - Shared include для создания и редактирования -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body p-3">
|
||||
<h6 class="mb-3 text-muted"><i class="bi bi-boxes me-1"></i>Состав комплекта</h6>
|
||||
|
||||
{{ kititem_formset.management_form }}
|
||||
|
||||
<div id="kititem-forms">
|
||||
{% for kititem_form in kititem_formset %}
|
||||
<div class="card mb-2 kititem-form border" data-form-index="{{ forloop.counter0 }}">
|
||||
{{ kititem_form.id }}
|
||||
<div class="card-body p-2">
|
||||
{% if kititem_form.non_field_errors %}
|
||||
<div class="alert alert-danger alert-sm mb-2">
|
||||
{% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small text-muted mb-1">Товар</label>
|
||||
{{ kititem_form.product }}
|
||||
{% if kititem_form.product.errors %}
|
||||
<div class="text-danger small">{{ kititem_form.product.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-muted mb-1">Группа вариантов</label>
|
||||
{{ kititem_form.variant_group }}
|
||||
{% if kititem_form.variant_group.errors %}
|
||||
<div class="text-danger small">{{ kititem_form.variant_group.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted mb-1">Кол-во</label>
|
||||
{{ kititem_form.quantity }}
|
||||
{% if kititem_form.quantity.errors %}
|
||||
<div class="text-danger small">{{ kititem_form.quantity.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-1 text-end">
|
||||
{% if kititem_form.DELETE %}
|
||||
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.previousElementSibling.checked = true; this.closest('.kititem-form').style.display='none';" title="Удалить">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
{{ kititem_form.DELETE }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if kititem_form.notes %}
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12">
|
||||
<label class="form-label small text-muted mb-1">Примечание</label>
|
||||
{{ kititem_form.notes }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавления внизу списка -->
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-success w-100" id="addKitItemBtn">
|
||||
<i class="bi bi-plus-circle"></i> Добавить товар
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,76 +46,7 @@
|
||||
</div>
|
||||
|
||||
<!-- КОМПОНЕНТЫ КОМПЛЕКТА -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body p-3">
|
||||
<h6 class="mb-3 text-muted"><i class="bi bi-boxes me-1"></i>Состав комплекта</h6>
|
||||
|
||||
{{ kititem_formset.management_form }}
|
||||
|
||||
<div id="kititem-forms">
|
||||
{% for kititem_form in kititem_formset %}
|
||||
<div class="card mb-2 kititem-form border" data-form-index="{{ forloop.counter0 }}">
|
||||
{{ kititem_form.id }}
|
||||
<div class="card-body p-2">
|
||||
{% if kititem_form.non_field_errors %}
|
||||
<div class="alert alert-danger alert-sm mb-2">
|
||||
{% for error in kititem_form.non_field_errors %}{{ error }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small text-muted mb-1">Товар</label>
|
||||
{{ kititem_form.product }}
|
||||
{% if kititem_form.product.errors %}
|
||||
<div class="text-danger small">{{ kititem_form.product.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-muted mb-1">Группа вариантов</label>
|
||||
{{ kititem_form.variant_group }}
|
||||
{% if kititem_form.variant_group.errors %}
|
||||
<div class="text-danger small">{{ kititem_form.variant_group.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted mb-1">Кол-во</label>
|
||||
{{ kititem_form.quantity }}
|
||||
{% if kititem_form.quantity.errors %}
|
||||
<div class="text-danger small">{{ kititem_form.quantity.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-1 text-end">
|
||||
{% if kititem_form.DELETE %}
|
||||
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.previousElementSibling.checked = true; this.closest('.kititem-form').style.display='none';" title="Удалить">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
{{ kititem_form.DELETE }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if kititem_form.notes %}
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12">
|
||||
<label class="form-label small text-muted mb-1">Примечание</label>
|
||||
{{ kititem_form.notes }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавления внизу списка -->
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-success w-100" id="addKitItemBtn">
|
||||
<i class="bi bi-plus-circle"></i> Добавить товар
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'products/includes/kititem_formset.html' %}
|
||||
|
||||
<!-- ФОТОГРАФИИ -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
<strong class="text-success fs-5">{{ kit.get_sale_price|floatformat:2 }} ₽</strong>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Себестоимость:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<strong class="text-danger fs-5">{{ kit.calculate_cost|floatformat:2 }} ₽</strong>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Ценообразование:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-info text-dark">{{ kit.get_pricing_method_display }}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,79 +3,164 @@ API представления для приложения products.
|
||||
"""
|
||||
from django.http import JsonResponse
|
||||
from django.db import models
|
||||
from django.core.cache import cache
|
||||
|
||||
from ..models import Product, ProductVariantGroup
|
||||
|
||||
|
||||
def search_products_and_variants(request):
|
||||
"""
|
||||
API endpoint для поиска товаров и групп вариантов.
|
||||
API endpoint для поиска товаров и групп вариантов (совместимость с Select2).
|
||||
Используется для автокомплита при добавлении компонентов в комплект.
|
||||
|
||||
Параметры GET:
|
||||
- q: строка поиска
|
||||
- type: 'product' или 'variant' (опционально, если не указано - поиск по обоим)
|
||||
- q: строка поиска (term в Select2)
|
||||
- type: 'product' или 'variant' (опционально)
|
||||
- page: номер страницы для пагинации (по умолчанию 1)
|
||||
|
||||
Возвращает JSON список:
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Роза красная Freedom 50см",
|
||||
"sku": "PROD-000001",
|
||||
"type": "product",
|
||||
"price": "150.00"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Роза красная Freedom",
|
||||
"type": "variant",
|
||||
"count": 3
|
||||
Возвращает JSON в формате Select2:
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Роза красная Freedom 50см (PROD-000001)",
|
||||
"sku": "PROD-000001",
|
||||
"price": "150.00"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"more": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
query = request.GET.get('q', '').strip()
|
||||
search_type = request.GET.get('type', 'all')
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return JsonResponse({'results': []})
|
||||
page = int(request.GET.get('page', 1))
|
||||
page_size = 30
|
||||
|
||||
results = []
|
||||
|
||||
# Поиск товаров
|
||||
# Если поиска нет - показываем популярные товары
|
||||
if not query or len(query) < 2:
|
||||
# Кэшируем популярные товары на 1 час
|
||||
cache_key = f'popular_products_{search_type}'
|
||||
cached_results = cache.get(cache_key)
|
||||
|
||||
if cached_results:
|
||||
return JsonResponse(cached_results)
|
||||
|
||||
if search_type in ['all', 'product']:
|
||||
# Показываем последние добавленные активные товары
|
||||
products = Product.objects.filter(is_active=True)\
|
||||
.order_by('-created_at')[:page_size]\
|
||||
.values('id', 'name', 'sku', 'sale_price')
|
||||
|
||||
for product in products:
|
||||
text = product['name']
|
||||
if product['sku']:
|
||||
text += f" ({product['sku']})"
|
||||
|
||||
results.append({
|
||||
'id': product['id'],
|
||||
'text': text,
|
||||
'sku': product['sku'],
|
||||
'price': str(product['sale_price']) if product['sale_price'] else None
|
||||
})
|
||||
|
||||
response_data = {
|
||||
'results': results,
|
||||
'pagination': {'more': False}
|
||||
}
|
||||
cache.set(cache_key, response_data, 3600)
|
||||
return JsonResponse(response_data)
|
||||
|
||||
# Поиск товаров (регистронезависимый поиск с приоритетом точных совпадений)
|
||||
if search_type in ['all', 'product']:
|
||||
products = Product.objects.filter(
|
||||
models.Q(name__icontains=query) |
|
||||
models.Q(sku__icontains=query) |
|
||||
models.Q(description__icontains=query) |
|
||||
models.Q(search_keywords__icontains=query),
|
||||
is_active=True
|
||||
).values('id', 'name', 'sku', 'sale_price')[:10]
|
||||
# Нормализуем запрос - убираем лишние пробелы
|
||||
query_normalized = ' '.join(query.split())
|
||||
|
||||
from django.db.models import Case, When, IntegerField
|
||||
from django.conf import settings
|
||||
|
||||
# ВРЕМЕННЫЙ ФИХ для SQLite: удалить когда база данных будет PostgreSQL
|
||||
# SQLite не поддерживает регистронезависимый поиск для кириллицы в LIKE
|
||||
if 'sqlite' in settings.DATABASES['default']['ENGINE']:
|
||||
from django.db.models.functions import Lower
|
||||
query_lower = query_normalized.lower()
|
||||
|
||||
products_query = Product.objects.annotate(
|
||||
name_lower=Lower('name'),
|
||||
sku_lower=Lower('sku'),
|
||||
description_lower=Lower('description')
|
||||
).filter(
|
||||
models.Q(name_lower__contains=query_lower) |
|
||||
models.Q(sku_lower__contains=query_lower) |
|
||||
models.Q(description_lower__contains=query_lower),
|
||||
is_active=True
|
||||
).annotate(
|
||||
relevance=Case(
|
||||
When(name_lower=query_lower, then=3),
|
||||
When(name_lower__startswith=query_lower, then=2),
|
||||
default=1,
|
||||
output_field=IntegerField()
|
||||
)
|
||||
).order_by('-relevance', 'name')
|
||||
else:
|
||||
# Основное решение для PostgreSQL (работает корректно с кириллицей)
|
||||
products_query = Product.objects.filter(
|
||||
models.Q(name__icontains=query_normalized) |
|
||||
models.Q(sku__icontains=query_normalized) |
|
||||
models.Q(description__icontains=query_normalized),
|
||||
is_active=True
|
||||
).annotate(
|
||||
relevance=Case(
|
||||
When(name__iexact=query_normalized, then=3),
|
||||
When(name__istartswith=query_normalized, then=2),
|
||||
default=1,
|
||||
output_field=IntegerField()
|
||||
)
|
||||
).order_by('-relevance', 'name')
|
||||
|
||||
total_products = products_query.count()
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
|
||||
products = products_query[start:end].values('id', 'name', 'sku', 'sale_price')
|
||||
|
||||
for product in products:
|
||||
text = product['name']
|
||||
if product['sku']:
|
||||
text += f" ({product['sku']})"
|
||||
|
||||
results.append({
|
||||
'id': product['id'],
|
||||
'name': f"{product['name']} ({product['sku']})",
|
||||
'text': text,
|
||||
'sku': product['sku'],
|
||||
'type': 'product',
|
||||
'price': str(product['sale_price']),
|
||||
'display_name': product['name'],
|
||||
'display_price': f"{product['sale_price']:.2f} ₽"
|
||||
'price': str(product['sale_price']) if product['sale_price'] else None,
|
||||
'type': 'product'
|
||||
})
|
||||
|
||||
has_more = total_products > end
|
||||
else:
|
||||
has_more = False
|
||||
|
||||
# Поиск групп вариантов
|
||||
if search_type in ['all', 'variant']:
|
||||
variants = ProductVariantGroup.objects.filter(
|
||||
models.Q(name__icontains=query) |
|
||||
models.Q(description__icontains=query)
|
||||
).prefetch_related('products')[:10]
|
||||
).prefetch_related('products')[:page_size]
|
||||
|
||||
for variant in variants:
|
||||
count = variant.products.filter(is_active=True).count()
|
||||
results.append({
|
||||
'id': variant.id,
|
||||
'name': f"{variant.name} ({count} вариантов)",
|
||||
'text': f"{variant.name} ({count} вариантов)",
|
||||
'type': 'variant',
|
||||
'count': count
|
||||
})
|
||||
|
||||
return JsonResponse({'results': results})
|
||||
return JsonResponse({
|
||||
'results': results,
|
||||
'pagination': {'more': has_more if search_type == 'product' else False}
|
||||
})
|
||||
|
||||
9
myproject/requirements.txt
Normal file
9
myproject/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
asgiref==3.10.0
|
||||
Django==5.2.7
|
||||
django-nested-admin==4.1.5
|
||||
django-phonenumber-field==8.3.0
|
||||
pillow==12.0.0
|
||||
python-monkey-business==1.1.0
|
||||
sqlparse==0.5.3
|
||||
tzdata==2025.2
|
||||
Unidecode==1.4.0
|
||||
@@ -6,6 +6,11 @@
|
||||
<title>{% block title %}Мой Django Проект{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
|
||||
<!-- Select2 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
@@ -34,7 +39,14 @@
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- jQuery (required for Select2) -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Select2 JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/i18n/ru.js"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -19,7 +19,7 @@
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Заказы</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Клиенты</a>
|
||||
<a class="nav-link" href="{% url 'customers:customer-list' %}">Клиенты</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a>
|
||||
|
||||
119
myproject/test_manager_fix.py
Normal file
119
myproject/test_manager_fix.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Test script to verify that the manager fix works correctly.
|
||||
Deleted products should be hidden from Product.objects (default manager).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
sys.path.insert(0, '/c/Users/team_/Desktop/test_qwen/myproject')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from products.models import Product, ProductKit, ProductCategory
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
print("=" * 70)
|
||||
print("TESTING MANAGER FIX - DELETED ITEMS VISIBILITY")
|
||||
print("=" * 70)
|
||||
|
||||
# Get counts before
|
||||
print("\n1. BEFORE SOFT DELETE:")
|
||||
print("-" * 70)
|
||||
products_before = Product.objects.count()
|
||||
kits_before = ProductKit.objects.count()
|
||||
categories_before = ProductCategory.objects.count()
|
||||
|
||||
print(f" Products (objects): {products_before}")
|
||||
print(f" Kits (objects): {kits_before}")
|
||||
print(f" Categories (objects): {categories_before}")
|
||||
|
||||
print(f"\n Products (all_objects): {Product.all_objects.count()}")
|
||||
print(f" Kits (all_objects): {ProductKit.all_objects.count()}")
|
||||
print(f" Categories (all_objects): {ProductCategory.all_objects.count()}")
|
||||
|
||||
# Get a product to soft delete
|
||||
try:
|
||||
product_to_delete = Product.objects.first()
|
||||
if not product_to_delete:
|
||||
print("\n❌ No products found to test with!")
|
||||
else:
|
||||
product_id = product_to_delete.pk
|
||||
product_name = product_to_delete.name
|
||||
|
||||
print(f"\n2. SOFT DELETING PRODUCT:")
|
||||
print("-" * 70)
|
||||
print(f" Product ID: {product_id}")
|
||||
print(f" Product Name: {product_name}")
|
||||
|
||||
# Soft delete the product
|
||||
product_to_delete.is_deleted = True
|
||||
product_to_delete.deleted_at = timezone.now()
|
||||
try:
|
||||
user = User.objects.first()
|
||||
if user:
|
||||
product_to_delete.deleted_by = user
|
||||
except:
|
||||
pass
|
||||
product_to_delete.save()
|
||||
print(f" ✓ Marked as is_deleted=True")
|
||||
|
||||
print(f"\n3. AFTER SOFT DELETE:")
|
||||
print("-" * 70)
|
||||
|
||||
# Check default manager
|
||||
products_after = Product.objects.count()
|
||||
try:
|
||||
product_in_default = Product.objects.get(pk=product_id)
|
||||
print(f" ❌ ERROR: Product found in Product.objects (should be hidden!)")
|
||||
print(f" Product.objects count: {products_after} (was {products_before})")
|
||||
except Product.DoesNotExist:
|
||||
print(f" ✓ Product correctly HIDDEN from Product.objects")
|
||||
print(f" Product.objects count: {products_after} (was {products_before})")
|
||||
if products_after == products_before - 1:
|
||||
print(f" ✓ Count decreased by 1 ✓")
|
||||
else:
|
||||
print(f" ⚠ Count change unexpected: {products_before} → {products_after}")
|
||||
|
||||
# Check all_objects manager
|
||||
try:
|
||||
product_in_all = Product.all_objects.get(pk=product_id, is_deleted=True)
|
||||
print(f"\n ✓ Product found in Product.all_objects.get(..., is_deleted=True)")
|
||||
print(f" Product.all_objects count: {Product.all_objects.count()}")
|
||||
except Product.DoesNotExist:
|
||||
print(f"\n ❌ ERROR: Product NOT found in all_objects (should be there!)")
|
||||
print(f" Product.all_objects count: {Product.all_objects.count()}")
|
||||
|
||||
print(f"\n4. TESTING RESTORE:")
|
||||
print("-" * 70)
|
||||
|
||||
# Restore the product
|
||||
product_to_delete.is_deleted = False
|
||||
product_to_delete.deleted_at = None
|
||||
product_to_delete.deleted_by = None
|
||||
product_to_delete.save()
|
||||
print(f" ✓ Marked as is_deleted=False")
|
||||
|
||||
# Check if it reappears
|
||||
try:
|
||||
product_restored = Product.objects.get(pk=product_id)
|
||||
print(f" ✓ Product correctly reappears in Product.objects")
|
||||
print(f" Product.objects count: {Product.objects.count()}")
|
||||
except Product.DoesNotExist:
|
||||
print(f" ❌ ERROR: Product not found after restore!")
|
||||
print(f" Product.objects count: {Product.objects.count()}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error during test: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("TEST COMPLETE")
|
||||
print("=" * 70)
|
||||
print("\nSUMMARY:")
|
||||
print("✓ Product.objects now filters out deleted items (correct)")
|
||||
print("✓ Product.all_objects still provides access to all items")
|
||||
print("✓ Soft delete/restore functionality working as expected")
|
||||
Reference in New Issue
Block a user