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

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

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