diff --git a/myproject/customers/forms.py b/myproject/customers/forms.py index 5807f82..307ce9e 100644 --- a/myproject/customers/forms.py +++ b/myproject/customers/forms.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/myproject/customers/services/import_export.py b/myproject/customers/services/import_export.py index 2145a02..1c7f4e0 100644 --- a/myproject/customers/services/import_export.py +++ b/myproject/customers/services/import_export.py @@ -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 diff --git a/myproject/customers/templates/customers/customer_export_modal.html b/myproject/customers/templates/customers/customer_export_modal.html new file mode 100644 index 0000000..4f9caa8 --- /dev/null +++ b/myproject/customers/templates/customers/customer_export_modal.html @@ -0,0 +1,105 @@ + +
+ + diff --git a/myproject/customers/templates/customers/customer_list.html b/myproject/customers/templates/customers/customer_list.html index b670e4a..5822c64 100644 --- a/myproject/customers/templates/customers/customer_list.html +++ b/myproject/customers/templates/customers/customer_list.html @@ -19,9 +19,11 @@ Импорт - - Экспорт - + {% if user.is_owner or user.is_superuser %} + + {% endif %} Добавить клиента @@ -207,4 +209,7 @@ + +{% include 'customers/customer_export_modal.html' %} + {% endblock %} diff --git a/myproject/customers/views.py b/myproject/customers/views.py index 23ab481..c9c2ad0 100644 --- a/myproject/customers/views.py +++ b/myproject/customers/views.py @@ -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()