Реализован полностью новый функционал экспорта клиентов с возможностью
выбора полей, формата файла (CSV/XLSX) и сохранением предпочтений.
Ключевые изменения:
1. CustomerExporter (import_export.py):
- Полностью переписан класс с поддержкой динамического выбора полей
- Добавлена конфигурация AVAILABLE_FIELDS с метаданными полей
- Реализован метод get_available_fields() для фильтрации по ролям
- Новый метод export_to_xlsx() с автоподстройкой ширины столбцов
- Форматирование ContactChannel с переводами строк
- Поддержка фильтрации queryset
2. CustomerExportForm (forms.py):
- Динамическое создание checkbox полей на основе роли пользователя
- Выбор формата файла (CSV/XLSX) через radio buttons
- Валидация выбора хотя бы одного поля
3. View customer_export (views.py):
- КРИТИЧНО: Изменён декоратор с @manager_or_owner_required на @owner_required
- Обработка GET (редирект) и POST запросов
- Применение фильтров CustomerFilter из списка клиентов
- Оптимизация с prefetch_related('contact_channels')
- Сохранение настроек экспорта в session
4. UI изменения:
- Создан шаблон customer_export_modal.html с модальным окном
- Обновлён customer_list.html: кнопка экспорта с проверкой роли
- JavaScript для восстановления сохранённых настроек из session
- Отображение количества экспортируемых клиентов
- Бейдж "Только для владельца" на поле баланса кошелька
Безопасность:
- Экспорт доступен ТОЛЬКО владельцу тенанта (OWNER) и superuser
- Поле "Баланс кошелька" скрыто от менеджеров на уровне формы
- Двойная проверка роли при экспорте баланса
- Кнопка экспорта скрыта в UI для всех кроме owner/superuser
Функциональность:
- Выбор полей: ID, имя, email, телефон, заметки, каналы связи, баланс, дата создания
- Форматы: CSV (с BOM для Excel) и XLSX
- Учёт текущих фильтров и поиска из списка клиентов
- Сохранение предпочтений между экспортами в session
- Исключение системного клиента из экспорта
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
141 lines
5.5 KiB
Python
141 lines
5.5 KiB
Python
from django import forms
|
||
from django.core.exceptions import ValidationError
|
||
from phonenumber_field.formfields import PhoneNumberField
|
||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||
from .models import Customer, ContactChannel
|
||
|
||
class CustomerForm(forms.ModelForm):
|
||
phone = PhoneNumberField(
|
||
region='BY',
|
||
required=False,
|
||
help_text='Формат: +375XXXXXXXXX или 80XXXXXXXXX',
|
||
widget=forms.TextInput(attrs={'placeholder': '+375XXXXXXXXX'})
|
||
)
|
||
|
||
class Meta:
|
||
model = Customer
|
||
fields = ['name', 'email', 'phone', 'notes']
|
||
exclude = ['is_system_customer']
|
||
widgets = {
|
||
'notes': forms.Textarea(attrs={'rows': 3}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
# Ensure phone displays in E.164 format
|
||
if self.instance and self.instance.phone:
|
||
self.initial['phone'] = str(self.instance.phone)
|
||
|
||
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'})
|
||
else:
|
||
# Regular input fields get form-control class
|
||
field.widget.attrs.update({'class': 'form-control'})
|
||
|
||
def clean_email(self):
|
||
"""Нормализует пустые значения email в None"""
|
||
email = self.cleaned_data.get('email')
|
||
if not email:
|
||
return None
|
||
return email
|
||
|
||
def clean_phone(self):
|
||
"""Нормализует пустые значения телефона в None"""
|
||
phone = self.cleaned_data.get('phone')
|
||
if not phone:
|
||
return None
|
||
return phone
|
||
|
||
def clean(self):
|
||
"""Дополнительная валидация формы"""
|
||
cleaned_data = super().clean()
|
||
|
||
# Защита от редактирования системного клиента
|
||
if self.instance and self.instance.pk and self.instance.is_system_customer:
|
||
raise ValidationError(
|
||
'Системный клиент не может быть изменен. '
|
||
'Он необходим для корректной работы системы и создается автоматически.'
|
||
)
|
||
|
||
return cleaned_data
|
||
|
||
|
||
class ContactChannelForm(forms.ModelForm):
|
||
"""Форма для добавления/редактирования канала связи"""
|
||
|
||
class Meta:
|
||
model = ContactChannel
|
||
fields = ['channel_type', 'value', 'is_primary', 'notes']
|
||
widgets = {
|
||
'channel_type': forms.Select(attrs={'class': 'form-select'}),
|
||
'value': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '@username, номер и т.д.'}),
|
||
'notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Личный аккаунт, рабочий...'}),
|
||
'is_primary': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||
}
|
||
|
||
def clean_value(self):
|
||
value = self.cleaned_data.get('value', '').strip()
|
||
channel_type = self.cleaned_data.get('channel_type')
|
||
|
||
if not value:
|
||
raise ValidationError('Значение не может быть пустым')
|
||
|
||
# Проверка уникальности комбинации channel_type + value
|
||
qs = ContactChannel.objects.filter(channel_type=channel_type, value=value)
|
||
if self.instance.pk:
|
||
qs = qs.exclude(pk=self.instance.pk)
|
||
if qs.exists():
|
||
type_display = dict(ContactChannel.CHANNEL_TYPES).get(channel_type, channel_type)
|
||
raise ValidationError(f'Такой {type_display} уже существует у другого клиента')
|
||
|
||
return value
|
||
|
||
|
||
class CustomerExportForm(forms.Form):
|
||
"""Форма настройки экспорта клиентов"""
|
||
|
||
FORMAT_CHOICES = [
|
||
('csv', 'CSV'),
|
||
('xlsx', 'Excel (XLSX)'),
|
||
]
|
||
|
||
export_format = forms.ChoiceField(
|
||
choices=FORMAT_CHOICES,
|
||
widget=forms.RadioSelect(attrs={'class': 'form-check-input'}),
|
||
initial='csv',
|
||
label='Формат файла'
|
||
)
|
||
|
||
def __init__(self, *args, user=None, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Получаем доступные поля на основе роли пользователя
|
||
from .services.import_export import CustomerExporter
|
||
available_fields = CustomerExporter.get_available_fields(user)
|
||
|
||
# Динамически создаём checkbox поля
|
||
for field_key, field_info in available_fields.items():
|
||
self.fields[f'field_{field_key}'] = forms.BooleanField(
|
||
required=False,
|
||
label=field_info['label'],
|
||
initial=field_key in CustomerExporter.DEFAULT_FIELDS,
|
||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||
)
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
|
||
# Собираем выбранные поля
|
||
selected_fields = [
|
||
key.replace('field_', '')
|
||
for key, value in cleaned_data.items()
|
||
if key.startswith('field_') and value
|
||
]
|
||
|
||
if not selected_fields:
|
||
raise ValidationError('Выберите хотя бы одно поле для экспорта')
|
||
|
||
cleaned_data['selected_fields'] = selected_fields
|
||
return cleaned_data |