Добавлен настраиваемый экспорт клиентов с выбором полей и форматов
Реализован полностью новый функционал экспорта клиентов с возможностью
выбора полей, формата файла (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:
@@ -91,3 +91,51 @@ class ContactChannelForm(forms.ModelForm):
|
||||
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
|
||||
@@ -28,56 +28,203 @@ 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',
|
||||
'Телефон',
|
||||
'Дата создания',
|
||||
])
|
||||
ws.append(self._get_headers())
|
||||
|
||||
# Данные (исключаем системного клиента)
|
||||
customers = Customer.objects.filter(is_system_customer=False).order_by('-created_at')
|
||||
# Данные
|
||||
for customer in self.queryset:
|
||||
row = [
|
||||
self._get_field_value(customer, field)
|
||||
for field in self.selected_fields
|
||||
]
|
||||
ws.append(row)
|
||||
|
||||
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'),
|
||||
])
|
||||
# Автоподстройка ширины столбцов
|
||||
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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
{% 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> Экспорт
|
||||
</a>
|
||||
</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 %}
|
||||
|
||||
@@ -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
|
||||
from .forms import CustomerExportForm
|
||||
from .filters import CustomerFilter
|
||||
|
||||
exporter = CustomerExporter()
|
||||
# Базовый 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()
|
||||
|
||||
Reference in New Issue
Block a user