Добавлен настраиваемый экспорт клиентов с выбором полей и форматов
Реализован полностью новый функционал экспорта клиентов с возможностью
выбора полей, формата файла (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>
This commit is contained in:
@@ -28,57 +28,204 @@ import re
|
||||
|
||||
class CustomerExporter:
|
||||
"""
|
||||
Класс для экспорта клиентов в различные форматы.
|
||||
Класс для экспорта клиентов в различные форматы (CSV/XLSX).
|
||||
Поддерживает выбор полей и фильтрацию по ролям.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def export_to_csv():
|
||||
|
||||
# Конфигурация доступных полей с метаданными
|
||||
AVAILABLE_FIELDS = {
|
||||
'id': {'label': 'ID', 'owner_only': False},
|
||||
'name': {'label': 'Имя', 'owner_only': False},
|
||||
'email': {'label': 'Email', 'owner_only': False},
|
||||
'phone': {'label': 'Телефон', 'owner_only': False},
|
||||
'notes': {'label': 'Заметки', 'owner_only': False},
|
||||
'contact_channels': {'label': 'Каналы связи', 'owner_only': False},
|
||||
'wallet_balance': {'label': 'Баланс кошелька', 'owner_only': True},
|
||||
'created_at': {'label': 'Дата создания', 'owner_only': False},
|
||||
}
|
||||
|
||||
DEFAULT_FIELDS = ['id', 'name', 'email', 'phone']
|
||||
|
||||
@classmethod
|
||||
def get_available_fields(cls, user):
|
||||
"""
|
||||
Экспортирует всех клиентов (кроме системного) в CSV файл.
|
||||
|
||||
Поля экспорта:
|
||||
- ID
|
||||
- Имя
|
||||
- Email
|
||||
- Телефон
|
||||
- Дата создания
|
||||
|
||||
Примечание: Баланс кошелька НЕ экспортируется (требование безопасности).
|
||||
|
||||
Получить поля доступные для пользователя на основе роли.
|
||||
|
||||
Args:
|
||||
user: Объект пользователя
|
||||
|
||||
Returns:
|
||||
HttpResponse: HTTP ответ с CSV файлом для скачивания
|
||||
dict: Словарь доступных полей с метаданными
|
||||
"""
|
||||
fields = {}
|
||||
is_owner = user.is_superuser or user.is_owner
|
||||
for field_key, field_info in cls.AVAILABLE_FIELDS.items():
|
||||
if field_info['owner_only'] and not is_owner:
|
||||
continue
|
||||
fields[field_key] = field_info
|
||||
return fields
|
||||
|
||||
def __init__(self, queryset, selected_fields, user):
|
||||
"""
|
||||
Инициализация экспортера.
|
||||
|
||||
Args:
|
||||
queryset: QuerySet клиентов (уже отфильтрованный)
|
||||
selected_fields: Список ключей полей для экспорта
|
||||
user: Текущий пользователь (для проверки прав)
|
||||
"""
|
||||
self.queryset = queryset
|
||||
self.selected_fields = selected_fields
|
||||
self.user = user
|
||||
|
||||
def _get_headers(self):
|
||||
"""Генерация заголовков на основе выбранных полей"""
|
||||
return [
|
||||
self.AVAILABLE_FIELDS[field]['label']
|
||||
for field in self.selected_fields
|
||||
]
|
||||
|
||||
def _get_field_value(self, customer, field_key):
|
||||
"""
|
||||
Получить отформатированное значение для конкретного поля.
|
||||
|
||||
Args:
|
||||
customer: Объект Customer
|
||||
field_key: Ключ поля
|
||||
|
||||
Returns:
|
||||
str: Форматированное значение
|
||||
"""
|
||||
if field_key == 'id':
|
||||
return customer.id
|
||||
elif field_key == 'name':
|
||||
return customer.name or ''
|
||||
elif field_key == 'email':
|
||||
return customer.email or ''
|
||||
elif field_key == 'phone':
|
||||
return str(customer.phone) if customer.phone else ''
|
||||
elif field_key == 'notes':
|
||||
return customer.notes or ''
|
||||
elif field_key == 'contact_channels':
|
||||
return self._get_contact_channels_display(customer)
|
||||
elif field_key == 'wallet_balance':
|
||||
# Двойная защита: проверка роли
|
||||
if not (self.user.is_superuser or self.user.is_owner):
|
||||
return 'N/A'
|
||||
return str(customer.wallet_balance)
|
||||
elif field_key == 'created_at':
|
||||
return customer.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
return ''
|
||||
|
||||
def _get_contact_channels_display(self, customer):
|
||||
"""
|
||||
Форматирование каналов связи для экспорта.
|
||||
Объединяет все каналы в одну строку с переводами строк.
|
||||
|
||||
Args:
|
||||
customer: Объект Customer
|
||||
|
||||
Returns:
|
||||
str: Форматированная строка каналов связи
|
||||
"""
|
||||
channels = customer.contact_channels.all()
|
||||
if not channels:
|
||||
return ''
|
||||
|
||||
from ..models import ContactChannel
|
||||
lines = []
|
||||
for channel in channels:
|
||||
channel_name = dict(ContactChannel.CHANNEL_TYPES).get(
|
||||
channel.channel_type,
|
||||
channel.channel_type
|
||||
)
|
||||
lines.append(f"{channel_name}: {channel.value}")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def export_to_csv(self):
|
||||
"""
|
||||
Экспорт в CSV с выбранными полями.
|
||||
|
||||
Returns:
|
||||
HttpResponse: CSV файл для скачивания
|
||||
"""
|
||||
# Создаём HTTP ответ с CSV файлом
|
||||
response = HttpResponse(content_type='text/csv; charset=utf-8')
|
||||
timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
|
||||
response['Content-Disposition'] = f'attachment; filename="customers_export_{timestamp}.csv"'
|
||||
|
||||
# Добавляем BOM для корректного открытия в Excel
|
||||
|
||||
# BOM для корректного открытия в Excel
|
||||
response.write('\ufeff')
|
||||
|
||||
|
||||
writer = csv.writer(response)
|
||||
|
||||
|
||||
# Динамические заголовки
|
||||
writer.writerow(self._get_headers())
|
||||
|
||||
# Данные
|
||||
for customer in self.queryset:
|
||||
row = [
|
||||
self._get_field_value(customer, field)
|
||||
for field in self.selected_fields
|
||||
]
|
||||
writer.writerow(row)
|
||||
|
||||
return response
|
||||
|
||||
def export_to_xlsx(self):
|
||||
"""
|
||||
Экспорт в XLSX используя openpyxl.
|
||||
|
||||
Returns:
|
||||
HttpResponse: XLSX файл для скачивания
|
||||
"""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError:
|
||||
# Fallback to CSV если openpyxl не установлен
|
||||
return self.export_to_csv()
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Клиенты"
|
||||
|
||||
# Заголовки
|
||||
writer.writerow([
|
||||
'ID',
|
||||
'Имя',
|
||||
'Email',
|
||||
'Телефон',
|
||||
'Дата создания',
|
||||
])
|
||||
|
||||
# Данные (исключаем системного клиента)
|
||||
customers = Customer.objects.filter(is_system_customer=False).order_by('-created_at')
|
||||
|
||||
for customer in customers:
|
||||
writer.writerow([
|
||||
customer.id,
|
||||
customer.name or '',
|
||||
customer.email or '',
|
||||
str(customer.phone) if customer.phone else '',
|
||||
customer.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
])
|
||||
|
||||
ws.append(self._get_headers())
|
||||
|
||||
# Данные
|
||||
for customer in self.queryset:
|
||||
row = [
|
||||
self._get_field_value(customer, field)
|
||||
for field in self.selected_fields
|
||||
]
|
||||
ws.append(row)
|
||||
|
||||
# Автоподстройка ширины столбцов
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(cell.value)
|
||||
except:
|
||||
pass
|
||||
adjusted_width = min(max_length + 2, 50) # Максимум 50
|
||||
ws.column_dimensions[column_letter].width = adjusted_width
|
||||
|
||||
# Сохранение в BytesIO
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
# Создание response
|
||||
response = HttpResponse(
|
||||
output.read(),
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
|
||||
response['Content-Disposition'] = f'attachment; filename="customers_export_{timestamp}.xlsx"'
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user