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

Реализован полностью новый функционал экспорта клиентов с возможностью
выбора полей, формата файла (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

@@ -90,4 +90,52 @@ class ContactChannelForm(forms.ModelForm):
type_display = dict(ContactChannel.CHANNEL_TYPES).get(channel_type, channel_type)
raise ValidationError(f'Такой {type_display} уже существует у другого клиента')
return value
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

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

View File

@@ -0,0 +1,105 @@
<!-- Modal for Customer Export Configuration -->
<div class="modal fade" id="exportModal" tabindex="-1" aria-labelledby="exportModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form method="post" action="{% url 'customers:customer-export' %}?{{ request.GET.urlencode }}" id="exportForm">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="exportModalLabel">
<i class="bi bi-download"></i> Настройка экспорта клиентов
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<!-- Export info -->
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
Будет экспортировано клиентов: <strong>{{ total_customers }}</strong>
{% if query or filter.form.has_notes.value or filter.form.no_phone.value or filter.form.no_email.value or filter.form.has_contact_channel.value %}
<br><small>С учётом текущих фильтров и поиска</small>
{% endif %}
</div>
<!-- Field Selection -->
<div class="mb-4">
<h6>Выберите поля для экспорта:</h6>
<div class="row">
{% for field in export_form %}
{% if field.name != 'export_format' %}
<div class="col-md-6 mb-2">
<div class="form-check">
{{ field }}
<label class="form-check-label" for="{{ field.id_for_label }}">
{{ field.label }}
{% if 'wallet_balance' in field.name %}
<span class="badge bg-warning text-dark">Только для владельца</span>
{% endif %}
</label>
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
<!-- Format Selection -->
<div class="mb-3">
<h6>Формат файла:</h6>
{% for choice in export_form.export_format %}
<div class="form-check form-check-inline">
{{ choice.tag }}
<label class="form-check-label" for="{{ choice.id_for_label }}">
{{ choice.choice_label }}
</label>
</div>
{% endfor %}
</div>
<div class="text-muted small">
<i class="bi bi-lightbulb"></i>
Ваш выбор будет сохранён для следующего экспорта
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Отмена
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-download"></i> Экспортировать
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Pre-select saved preferences from session
var preferencesJson = '{{ export_preferences|escapejs }}';
if (preferencesJson && preferencesJson !== '{}') {
try {
var preferences = JSON.parse(preferencesJson.replace(/'/g, '"'));
// Restore selected fields
if (preferences.selected_fields) {
preferences.selected_fields.forEach(function(field) {
var checkbox = document.getElementById('id_field_' + field);
if (checkbox) checkbox.checked = true;
});
}
// Restore format selection
if (preferences.format) {
var radio = document.querySelector('input[name="export_format"][value="' + preferences.format + '"]');
if (radio) radio.checked = true;
}
} catch (e) {
console.log('Could not parse export preferences:', e);
}
}
});
</script>

View File

@@ -19,9 +19,11 @@
<a href="{% url 'customers:customer-import' %}" class="btn btn-outline-success">
<i class="bi bi-upload"></i> Импорт
</a>
<a href="{% url 'customers:customer-export' %}" class="btn btn-outline-info">
<i class="bi bi-download"></i> Экспорт
</a>
{% if user.is_owner or user.is_superuser %}
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#exportModal">
<i class="bi bi-download"></i> Экспорт
</button>
{% endif %}
<a href="{% url 'customers:customer-create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Добавить клиента
</a>
@@ -207,4 +209,7 @@
</div>
</div>
</div>
{% include 'customers/customer_export_modal.html' %}
{% endblock %}

View File

@@ -7,7 +7,7 @@ from django.db.models.functions import Greatest, Coalesce
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from user_roles.decorators import manager_or_owner_required
from user_roles.decorators import manager_or_owner_required, owner_required
import phonenumbers
import json
from decimal import Decimal
@@ -80,11 +80,18 @@ def customer_list(request):
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
# Подготовка формы экспорта и настроек из сессии
from .forms import CustomerExportForm
export_form = CustomerExportForm(user=request.user)
export_preferences = request.session.get('customer_export_preferences', {})
context = {
'page_obj': page_obj,
'query': query,
'total_customers': paginator.count, # Используем count из paginator, чтобы избежать дублирования SQL запроса
'filter': customer_filter, # Добавляем фильтр в контекст
'export_form': export_form, # Форма экспорта для модального окна
'export_preferences': export_preferences, # Сохранённые настройки экспорта
}
return render(request, 'customers/customer_list.html', context)
@@ -886,12 +893,65 @@ def customer_import_download_errors(request):
@login_required
@manager_or_owner_required
@owner_required
def customer_export(request):
"""
Экспорт клиентов в CSV файл.
Экспорт клиентов в CSV/XLSX файл.
GET: Перенаправление на список клиентов
POST: Обработка экспорта с выбранными полями и форматом
Поддерживает фильтрацию - экспортирует только клиентов, соответствующих текущим фильтрам.
Доступен только владельцу (OWNER) и superuser.
"""
from .services.import_export import CustomerExporter
exporter = CustomerExporter()
return exporter.export_to_csv()
from .forms import CustomerExportForm
from .filters import CustomerFilter
# Базовый queryset (исключаем системного клиента)
queryset = Customer.objects.filter(is_system_customer=False)
# Применяем фильтры (та же логика что в customer_list)
customer_filter = CustomerFilter(request.GET, queryset=queryset)
filtered_queryset = customer_filter.qs
# GET запрос: перенаправление на список клиентов
if request.method != 'POST':
messages.info(
request,
'Используйте кнопку "Экспорт" в списке клиентов для настройки экспорта.'
)
return redirect('customers:customer-list')
# POST запрос: обработка экспорта
form = CustomerExportForm(request.POST, user=request.user)
if not form.is_valid():
messages.error(request, 'Ошибка в настройках экспорта. Выберите хотя бы одно поле.')
return redirect('customers:customer-list')
# Получение конфигурации экспорта
selected_fields = form.cleaned_data['selected_fields']
export_format = form.cleaned_data['export_format']
# Сохранение настроек в сессии
request.session['customer_export_preferences'] = {
'selected_fields': selected_fields,
'format': export_format,
}
# Оптимизация запроса (prefetch contact channels)
filtered_queryset = filtered_queryset.prefetch_related('contact_channels').order_by('-created_at')
# Создание экспортера с отфильтрованным queryset
exporter = CustomerExporter(
queryset=filtered_queryset,
selected_fields=selected_fields,
user=request.user
)
# Генерация и возврат файла экспорта
if export_format == 'xlsx':
return exporter.export_to_xlsx()
else:
return exporter.export_to_csv()