Добавлен настраиваемый экспорт клиентов с выбором полей и форматов

Реализован полностью новый функционал экспорта клиентов с возможностью
выбора полей, формата файла (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:
2026-01-03 21:12:08 +03:00
parent 0f09702094
commit 95036ed285
5 changed files with 416 additions and 51 deletions

View File

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