Обновили шапку и вывод всехтоваров. Добавили фильтры

This commit is contained in:
2025-10-24 23:11:29 +03:00
parent 2fb6253d06
commit 9ad9f604e9
35 changed files with 2498 additions and 1270 deletions

View File

@@ -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.contrib.auth.validators
import django.utils.timezone import django.utils.timezone

View File

@@ -0,0 +1 @@

View 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',)
}),
)

View 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'})

View 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'),
),
]

View File

@@ -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'),
),
]

View 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='Телефон'),
),
]

View File

@@ -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',
),
]

View 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='Телефон'),
),
]

View 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

View File

@@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View 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'),
]

View 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)

View File

@@ -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 import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@@ -42,6 +42,7 @@ INSTALLED_APPS = [
'products', 'products',
'inventory', 'inventory',
'orders', 'orders',
'customers',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -185,5 +186,20 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEFAULT_FROM_EMAIL = 'noreply@example.com' DEFAULT_FROM_EMAIL = 'noreply@example.com'
# Настройки телефонных номеров
PHONENUMBER_DEFAULT_REGION = 'BY' # Регион по умолчанию для номеров без кода страны
# Указываем нашу кастомную модель пользователя # Указываем нашу кастомную модель пользователя
AUTH_USER_MODEL = 'accounts.CustomUser' 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)

View File

@@ -26,6 +26,7 @@ urlpatterns = [
path('', views.index, name='index'), # Main page path('', views.index, name='index'), # Main page
path('accounts/', include('accounts.urls')), path('accounts/', include('accounts.urls')),
path('products/', include('products.urls')), path('products/', include('products.urls')),
path('customers/', include('customers.urls')),
] ]
# Serve media files during development # Serve media files during development

View 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)

View 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')

View File

@@ -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 import django.db.models.deletion
from django.conf import settings from django.conf import settings

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -523,6 +523,43 @@ class ProductKit(models.Model):
def __str__(self): def __str__(self):
return self.name 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): def save(self, *args, **kwargs):
if not self.slug: if not self.slug:
from unidecode import unidecode from unidecode import unidecode
@@ -541,15 +578,30 @@ class ProductKit(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_total_components_count(self): def get_total_components_count(self):
"""Возвращает количество позиций в букете""" """
Возвращает количество компонентов (строк) в комплекте.
Returns:
int: Количество компонентов в комплекте
"""
return self.kit_items.count() return self.kit_items.count()
def get_components_with_variants_count(self): def get_components_with_variants_count(self):
"""Возвращает количество позиций с группами вариантов""" """
Возвращает количество компонентов, которые используют группы вариантов.
Returns:
int: Количество компонентов с группами вариантов
"""
return self.kit_items.filter(variant_group__isnull=False).count() return self.kit_items.filter(variant_group__isnull=False).count()
def get_sale_price(self): def get_sale_price(self):
"""Возвращает рассчитанную цену продажи комплекта""" """
Возвращает рассчитанную цену продажи комплекта в соответствии с выбранным методом ценообразования.
Returns:
Decimal: Цена продажи комплекта
"""
try: try:
return self.calculate_price_with_substitutions() return self.calculate_price_with_substitutions()
except Exception: except Exception:
@@ -560,8 +612,16 @@ class ProductKit(models.Model):
def check_availability(self, stock_manager=None): def check_availability(self, stock_manager=None):
""" """
Проверяет доступность всего букета. Проверяет доступность всего комплекта.
Букет доступен, если для каждой позиции есть хотя бы один доступный вариант.
Комплект доступен, если для каждой позиции в комплекте
есть хотя бы один доступный вариант товара.
Args:
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
bool: True, если комплект полностью доступен, иначе False
""" """
from .utils.stock_manager import StockManager from .utils.stock_manager import StockManager
@@ -577,10 +637,18 @@ class ProductKit(models.Model):
def calculate_price_with_substitutions(self, stock_manager=None): 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 from .utils.stock_manager import StockManager
if stock_manager is None: if stock_manager is None:
@@ -594,6 +662,7 @@ class ProductKit(models.Model):
total_sale = Decimal('0.00') total_sale = Decimal('0.00')
for kit_item in self.kit_items.select_related('product', 'variant_group'): for kit_item in self.kit_items.select_related('product', 'variant_group'):
try:
best_product = kit_item.get_best_available_product(stock_manager) best_product = kit_item.get_best_available_product(stock_manager)
if not best_product: if not best_product:
@@ -602,20 +671,66 @@ class ProductKit(models.Model):
best_product = available_products[0] if available_products else None best_product = available_products[0] if available_products else None
if best_product: if best_product:
total_cost += best_product.cost_price * kit_item.quantity item_cost = best_product.cost_price
total_sale += best_product.sale_price * kit_item.quantity 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 # Пропускаем ошибочный элемент и продолжаем с остальными
# Применяем метод ценообразования # Применяем метод ценообразования
try:
if self.pricing_method == 'from_sale_prices': if self.pricing_method == 'from_sale_prices':
return total_sale return total_sale
elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent: 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')) return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount: elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount is not None:
return total_cost + self.markup_amount return total_cost + self.markup_amount
elif self.pricing_method == 'fixed' and self.fixed_price: elif self.pricing_method == 'fixed' and self.fixed_price:
return 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): def delete(self, *args, **kwargs):
"""Soft delete вместо hard delete - марк как удаленный""" """Soft delete вместо hard delete - марк как удаленный"""
@@ -663,6 +778,13 @@ class KitItem(models.Model):
class Meta: class Meta:
verbose_name = "Компонент комплекта" verbose_name = "Компонент комплекта"
verbose_name_plural = "Компоненты комплектов" 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): def __str__(self):
return f"{self.kit.name} - {self.get_display_name()}" return f"{self.kit.name} - {self.get_display_name()}"
@@ -679,17 +801,36 @@ class KitItem(models.Model):
) )
def get_display_name(self): def get_display_name(self):
"""Возвращает название для отображения (товар или группа)""" """
Возвращает строку для отображения названия компонента.
Returns:
str: Название компонента (либо группа вариантов, либо конкретный товар)
"""
if self.variant_group: if self.variant_group:
return f"[Варианты] {self.variant_group.name}" return f"[Варианты] {self.variant_group.name}"
return self.product.name if self.product else "Не указан" return self.product.name if self.product else "Не указан"
def has_priorities_set(self): def has_priorities_set(self):
"""Проверяет, настроены ли приоритеты""" """
Проверяет, настроены ли приоритеты замены для данного компонента.
Returns:
bool: True, если приоритеты установлены, иначе False
"""
return self.priorities.exists() return self.priorities.exists()
def get_available_products(self): def get_available_products(self):
"""Возвращает список доступных товаров с учётом приоритетов""" """
Возвращает список доступных товаров для этого компонента.
Если указан конкретный товар - возвращает его.
Если указаны приоритеты - возвращает товары в порядке приоритета.
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
Returns:
list: Список доступных товаров
"""
if self.product: if self.product:
# Если указан конкретный товар, возвращаем только его # Если указан конкретный товар, возвращаем только его
return [self.product] return [self.product]
@@ -707,7 +848,15 @@ class KitItem(models.Model):
return [] return []
def get_best_available_product(self, stock_manager=None): def get_best_available_product(self, stock_manager=None):
"""Возвращает первый доступный товар по приоритету""" """
Возвращает первый доступный товар по приоритету, соответствующий требованиям по количеству.
Args:
stock_manager: Объект управления складом (если не указан, используется стандартный)
Returns:
Product or None: Первый доступный товар или None, если ничего не доступно
"""
from .utils.stock_manager import StockManager from .utils.stock_manager import StockManager
if stock_manager is None: if stock_manager is None:

View File

@@ -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>

View File

@@ -46,76 +46,7 @@
</div> </div>
<!-- КОМПОНЕНТЫ КОМПЛЕКТА --> <!-- КОМПОНЕНТЫ КОМПЛЕКТА -->
<div class="card border-0 shadow-sm mb-3"> {% include 'products/includes/kititem_formset.html' %}
<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>
<!-- ФОТОГРАФИИ --> <!-- ФОТОГРАФИИ -->
<div class="card border-0 shadow-sm mb-3"> <div class="card border-0 shadow-sm mb-3">

View File

@@ -52,6 +52,11 @@
<strong class="text-success fs-5">{{ kit.get_sale_price|floatformat:2 }} ₽</strong> <strong class="text-success fs-5">{{ kit.get_sale_price|floatformat:2 }} ₽</strong>
</dd> </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> <dt class="col-sm-4">Ценообразование:</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
<span class="badge bg-info text-dark">{{ kit.get_pricing_method_display }}</span> <span class="badge bg-info text-dark">{{ kit.get_pricing_method_display }}</span>

File diff suppressed because it is too large Load Diff

View File

@@ -3,79 +3,164 @@ API представления для приложения products.
""" """
from django.http import JsonResponse from django.http import JsonResponse
from django.db import models from django.db import models
from django.core.cache import cache
from ..models import Product, ProductVariantGroup from ..models import Product, ProductVariantGroup
def search_products_and_variants(request): def search_products_and_variants(request):
""" """
API endpoint для поиска товаров и групп вариантов. API endpoint для поиска товаров и групп вариантов (совместимость с Select2).
Используется для автокомплита при добавлении компонентов в комплект. Используется для автокомплита при добавлении компонентов в комплект.
Параметры GET: Параметры GET:
- q: строка поиска - q: строка поиска (term в Select2)
- type: 'product' или 'variant' (опционально, если не указано - поиск по обоим) - type: 'product' или 'variant' (опционально)
- page: номер страницы для пагинации (по умолчанию 1)
Возвращает JSON список: Возвращает JSON в формате Select2:
[ {
"results": [
{ {
"id": 1, "id": 1,
"name": "Роза красная Freedom 50см", "text": "Роза красная Freedom 50см (PROD-000001)",
"sku": "PROD-000001", "sku": "PROD-000001",
"type": "product",
"price": "150.00" "price": "150.00"
},
{
"id": 1,
"name": "Роза красная Freedom",
"type": "variant",
"count": 3
} }
] ],
"pagination": {
"more": true
}
}
""" """
query = request.GET.get('q', '').strip() query = request.GET.get('q', '').strip()
search_type = request.GET.get('type', 'all') search_type = request.GET.get('type', 'all')
page = int(request.GET.get('page', 1))
if not query or len(query) < 2: page_size = 30
return JsonResponse({'results': []})
results = [] 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']: if search_type in ['all', 'product']:
products = Product.objects.filter( # Показываем последние добавленные активные товары
models.Q(name__icontains=query) | products = Product.objects.filter(is_active=True)\
models.Q(sku__icontains=query) | .order_by('-created_at')[:page_size]\
models.Q(description__icontains=query) | .values('id', 'name', 'sku', 'sale_price')
models.Q(search_keywords__icontains=query),
is_active=True
).values('id', 'name', 'sku', 'sale_price')[:10]
for product in products: for product in products:
text = product['name']
if product['sku']:
text += f" ({product['sku']})"
results.append({ results.append({
'id': product['id'], 'id': product['id'],
'name': f"{product['name']} ({product['sku']})", 'text': text,
'sku': product['sku'], 'sku': product['sku'],
'type': 'product', 'price': str(product['sale_price']) if product['sale_price'] else None
'price': str(product['sale_price']),
'display_name': product['name'],
'display_price': f"{product['sale_price']:.2f}"
}) })
response_data = {
'results': results,
'pagination': {'more': False}
}
cache.set(cache_key, response_data, 3600)
return JsonResponse(response_data)
# Поиск товаров (регистронезависимый поиск с приоритетом точных совпадений)
if search_type in ['all', 'product']:
# Нормализуем запрос - убираем лишние пробелы
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'],
'text': text,
'sku': product['sku'],
'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']: if search_type in ['all', 'variant']:
variants = ProductVariantGroup.objects.filter( variants = ProductVariantGroup.objects.filter(
models.Q(name__icontains=query) | models.Q(name__icontains=query) |
models.Q(description__icontains=query) models.Q(description__icontains=query)
).prefetch_related('products')[:10] ).prefetch_related('products')[:page_size]
for variant in variants: for variant in variants:
count = variant.products.filter(is_active=True).count() count = variant.products.filter(is_active=True).count()
results.append({ results.append({
'id': variant.id, 'id': variant.id,
'name': f"{variant.name} ({count} вариантов)", 'text': f"{variant.name} ({count} вариантов)",
'type': 'variant', 'type': 'variant',
'count': count 'count': count
}) })
return JsonResponse({'results': results}) return JsonResponse({
'results': results,
'pagination': {'more': has_more if search_type == 'product' else False}
})

View 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

View File

@@ -6,6 +6,11 @@
<title>{% block title %}Мой Django Проект{% endblock %}</title> <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 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"> <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> <style>
body { body {
background-color: #f8f9fa; background-color: #f8f9fa;
@@ -34,7 +39,14 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </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> <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 %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -19,7 +19,7 @@
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Заказы</a> <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Заказы</a>
</li> </li>
<li class="nav-item"> <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>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a> <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a>

View 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")