diff --git a/backend/community/admin_actions.py b/backend/community/admin_actions.py index 4aecd23..72146c8 100644 --- a/backend/community/admin_actions.py +++ b/backend/community/admin_actions.py @@ -37,58 +37,6 @@ def get_signup_info_keys(queryset): keys.update(flat_info.keys()) return sorted(list(keys)) -def get_row_data(obj): - """ - Build a dictionary of all exportable data for a single ActivitySignup object - """ - data = {} - - # 1. 基础信息 - data['ID'] = str(obj.id) - data['活动标题'] = obj.activity.title - data['报名时间'] = obj.signup_time - data['状态'] = obj.get_status_display() - - # 2. 用户信息 - if obj.user: - data['用户昵称'] = obj.user.nickname - data['用户ID'] = str(obj.user.id) - data['用户OpenID'] = obj.user.openid - data['用户绑定手机'] = obj.user.phone_number or '' - data['用户地区'] = f"{obj.user.country} {obj.user.province} {obj.user.city}".strip() - data['用户注册时间'] = obj.user.created_at - else: - data['用户昵称'] = 'Unknown' - data['用户ID'] = '' - data['用户OpenID'] = '' - data['用户绑定手机'] = '' - data['用户地区'] = '' - data['用户注册时间'] = '' - - # 3. 订单/发货信息 - if obj.order: - data['关联订单ID'] = str(obj.order.id) - data['订单状态'] = obj.order.get_status_display() - data['收货人姓名'] = obj.order.customer_name - data['收货电话'] = obj.order.phone_number - data['收货地址'] = obj.order.shipping_address - data['快递公司'] = obj.order.courier_name or '' - data['快递单号'] = obj.order.tracking_number or '' - data['订单总价'] = str(obj.order.total_price) - data['商户订单号'] = obj.order.out_trade_no or '' - else: - data['关联订单ID'] = '' - data['订单状态'] = '' - data['收货人姓名'] = '' - data['收货电话'] = '' - data['收货地址'] = '' - data['快递公司'] = '' - data['快递单号'] = '' - data['订单总价'] = '' - data['商户订单号'] = '' - - return data - def export_signups_csv(modeladmin, request, queryset): """ Export selected signups to CSV, including flattened JSON fields @@ -101,30 +49,26 @@ def export_signups_csv(modeladmin, request, queryset): writer = csv.writer(response) - # Fixed headers - fixed_headers = [ - 'ID', '活动标题', '状态', '报名时间', - '用户ID', '用户昵称', '用户OpenID', '用户绑定手机', '用户地区', '用户注册时间', - '关联订单ID', '订单状态', '收货人姓名', '收货电话', '收货地址', '快递公司', '快递单号', '订单总价', '商户订单号' - ] + # Base fields to export + base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID'] # Get dynamic JSON keys json_keys = get_signup_info_keys(queryset) # Write header - writer.writerow(fixed_headers + json_keys) + writer.writerow(base_headers + json_keys) # Write data for obj in queryset: - row_data = get_row_data(obj) - - # Build the row based on fixed_headers order - row = [] - for header in fixed_headers: - val = row_data.get(header, '') - if isinstance(val, (datetime.datetime, datetime.date)): - val = val.strftime('%Y-%m-%d %H:%M:%S') - row.append(str(val)) + row = [ + str(obj.id), + obj.activity.title, + obj.user.nickname if obj.user else 'Unknown', + str(obj.user.id) if obj.user else '', + obj.signup_time.strftime('%Y-%m-%d %H:%M:%S'), + obj.get_status_display(), + str(obj.order.id) if obj.order else '' + ] # Add JSON data flat_info = {} @@ -141,7 +85,7 @@ def export_signups_csv(modeladmin, request, queryset): return response -export_signups_csv.short_description = "导出选中报名记录为 CSV (含发货/详细信息)" +export_signups_csv.short_description = "导出选中报名记录为 CSV (含详细信息)" def export_signups_excel(modeladmin, request, queryset): """ @@ -165,38 +109,27 @@ def export_signups_excel(modeladmin, request, queryset): ws = wb.active ws.title = str(opts.verbose_name)[:31] # Sheet name limit is 31 chars - # Fixed headers - fixed_headers = [ - 'ID', '活动标题', '状态', '报名时间', - '用户ID', '用户昵称', '用户OpenID', '用户绑定手机', '用户地区', '用户注册时间', - '关联订单ID', '订单状态', '收货人姓名', '收货电话', '收货地址', '快递公司', '快递单号', '订单总价', '商户订单号' - ] + # Base fields to export + base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID'] # Get dynamic JSON keys json_keys = get_signup_info_keys(queryset) # Write header - ws.append(fixed_headers + json_keys) + ws.append(base_headers + json_keys) # Write data for obj in queryset: - row_data = get_row_data(obj) + row = [ + obj.id, + obj.activity.title, + obj.user.nickname if obj.user else 'Unknown', + obj.user.id if obj.user else '', + obj.signup_time.replace(tzinfo=None) if obj.signup_time else '', # Remove tz for Excel + obj.get_status_display(), + obj.order.id if obj.order else '' + ] - row = [] - for header in fixed_headers: - val = row_data.get(header, '') - # Excel handles datetime natively, but we need to remove timezone info if present to avoid Excel errors or warnings depending on version - # Here we convert to naive datetime for simplicity or keep as is if library handles it. - # openpyxl supports datetime, but usually it's safer to remove tzinfo for compatibility. - if isinstance(val, (datetime.datetime, datetime.date)): - if hasattr(val, 'replace'): - val = val.replace(tzinfo=None) - - # Ensure None becomes empty string - if val is None: - val = "" - row.append(val) - # Add JSON data flat_info = {} if obj.signup_info and isinstance(obj.signup_info, dict): @@ -206,11 +139,11 @@ def export_signups_excel(modeladmin, request, queryset): val = flat_info.get(key, '') if val is None: val = '' - row.append(str(val)) # JSON values as strings + row.append(str(val)) # Ensure string for simplicity, or handle types ws.append(row) wb.save(response) return response -export_signups_excel.short_description = "导出选中报名记录为 Excel (含发货/详细信息)" +export_signups_excel.short_description = "导出选中报名记录为 Excel (含详细信息)" diff --git a/backend/shop/admin.py b/backend/shop/admin.py index 15e9aff..3b649b9 100644 --- a/backend/shop/admin.py +++ b/backend/shop/admin.py @@ -7,7 +7,7 @@ from django.shortcuts import redirect from unfold.admin import ModelAdmin, TabularInline from unfold.decorators import display from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment, AdminPhoneNumber -from .admin_actions import export_orders_excel +from .admin_actions import export_to_csv, export_to_excel import qrcode from io import BytesIO import base64 @@ -403,7 +403,7 @@ class OrderAdmin(ModelAdmin): list_filter = ('status', 'salesperson', 'distributor', 'created_at') search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no') readonly_fields = ('total_price', 'created_at', 'wechat_trade_no') - actions = [export_orders_excel] + actions = [export_to_csv, export_to_excel] def get_item_name(self, obj): if obj.config: @@ -472,6 +472,7 @@ class WeChatUserAdmin(OrderableAdminMixin, ModelAdmin): search_fields = ('nickname', 'openid', 'phone_number') list_filter = ('is_star', GenderFilter, UserSourceFilter, 'province', 'city', 'created_at') readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at') + actions = [export_to_csv, export_to_excel] def avatar_display(self, obj): if obj.avatar_url: diff --git a/backend/shop/admin_actions.py b/backend/shop/admin_actions.py index f6bffd0..59bff16 100644 --- a/backend/shop/admin_actions.py +++ b/backend/shop/admin_actions.py @@ -1,124 +1,71 @@ import csv -import json import datetime from django.http import HttpResponse from django.utils.encoding import escape_uri_path -def flatten_json(y): +def export_to_csv(modeladmin, request, queryset): """ - Flatten a nested json object + 通用导出 CSV 的 Admin Action + 支持中文编码(UTF-8 BOM),可直接用 Excel 打开 """ - out = {} - - def flatten(x, name=''): - if type(x) is dict: - for a in x: - flatten(x[a], name + a + '_') - elif type(x) is list: - i = 0 - for a in x: - flatten(a, name + str(i) + '_') - i += 1 - else: - out[name[:-1]] = x - - flatten(y) - return out - -def get_order_signup_keys(queryset): - """ - Collect all unique keys from the signup_info JSON of ActivitySignups related to the Order queryset - """ - keys = set() - for order in queryset: - # Check if order has related activity signups - # Assuming reverse relation name is 'activity_signups' based on ActivitySignup model - signups = order.activity_signups.all() - for signup in signups: - if signup.signup_info and isinstance(signup.signup_info, dict): - flat_info = flatten_json(signup.signup_info) - keys.update(flat_info.keys()) - return sorted(list(keys)) - -def get_order_row_data(order): - """ - Build a dictionary of all exportable data for a single Order object - """ - data = {} + opts = modeladmin.model._meta + # 设置文件名,使用模型的 verbose_name + filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" - # 1. 订单基础信息 - data['订单ID'] = str(order.id) - data['商户订单号'] = order.out_trade_no or '' - data['微信支付单号'] = order.wechat_trade_no or '' - data['订单状态'] = order.get_status_display() - data['订单总价'] = str(order.total_price) - data['购买数量'] = str(order.quantity) - data['创建时间'] = order.created_at.strftime('%Y-%m-%d %H:%M:%S') + response = HttpResponse(content_type='text/csv; charset=utf-8-sig') + response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}' - # 商品信息 - if order.config: - data['商品类型'] = '硬件' - data['商品名称'] = order.config.name - elif order.course: - data['商品类型'] = '课程' - data['商品名称'] = order.course.title - elif order.activity: - data['商品类型'] = '活动' - data['商品名称'] = order.activity.title - else: - data['商品类型'] = '未知' - data['商品名称'] = '未知' + writer = csv.writer(response) + + # 获取所有非多对多字段和非反向关联字段 + fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many] + + # 写入表头 (使用字段的 verbose_name) + writer.writerow([field.verbose_name for field in fields]) + + # 写入数据 + for obj in queryset: + data_row = [] + for field in fields: + value = getattr(obj, field.name) + + # 处理 Choice 字段,显示可读的标签 + if hasattr(obj, f'get_{field.name}_display'): + value = getattr(obj, f'get_{field.name}_display')() + + # 处理关联对象(ForeignKey) + if field.is_relation and value: + value = str(value) - # 2. 发货/收货信息 - data['收货人姓名'] = order.customer_name - data['收货电话'] = order.phone_number - data['收货地址'] = order.shipping_address - data['快递公司'] = order.courier_name or '' - data['快递单号'] = order.tracking_number or '' + # 处理日期时间 + if isinstance(value, datetime.datetime): + value = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, datetime.date): + value = value.strftime('%Y-%m-%d') + + # 处理 None + if value is None: + value = "" + + data_row.append(str(value)) + writer.writerow(data_row) + + return response - # 3. 下单用户信息 - if order.wechat_user: - data['用户昵称'] = order.wechat_user.nickname - data['用户ID'] = str(order.wechat_user.id) - data['用户OpenID'] = order.wechat_user.openid - data['用户绑定手机'] = order.wechat_user.phone_number or '' - else: - data['用户昵称'] = 'Unknown' - data['用户ID'] = '' - data['用户OpenID'] = '' - data['用户绑定手机'] = '' +export_to_csv.short_description = "导出选中项为 CSV" - # 4. 销售/分销信息 - data['销售员'] = order.salesperson.name if order.salesperson else '' - data['分销员'] = order.distributor.user.nickname if order.distributor else '' - - return data - -def get_order_signup_data(order): +def export_to_excel(modeladmin, request, queryset): """ - Get flattened signup info for the order. - If multiple signups exist, we only take the first one for simplicity in flat export, - or we could try to merge them but keys might conflict. - Given the context, usually 1 order -> 1 signup for activity. - """ - flat_info = {} - signup = order.activity_signups.first() - if signup and signup.signup_info and isinstance(signup.signup_info, dict): - flat_info = flatten_json(signup.signup_info) - return flat_info - -def export_orders_excel(modeladmin, request, queryset): - """ - Export selected orders to Excel, including related Activity Signup JSON info + 导出为 Excel (需要安装 openpyxl) """ try: from openpyxl import Workbook except ImportError: - modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能", level='error') + modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能: pip install openpyxl", level='error') return opts = modeladmin.model._meta - filename = f"Orders_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" response = HttpResponse( content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', @@ -127,44 +74,37 @@ def export_orders_excel(modeladmin, request, queryset): wb = Workbook() ws = wb.active - ws.title = "订单导出" + # Sheet name limit is 31 chars + ws.title = str(opts.verbose_name)[:31] - # Fixed headers - fixed_headers = [ - '订单ID', '商户订单号', '微信支付单号', '订单状态', '订单总价', '购买数量', '创建时间', - '商品类型', '商品名称', - '收货人姓名', '收货电话', '收货地址', '快递公司', '快递单号', - '用户ID', '用户昵称', '用户OpenID', '用户绑定手机', - '销售员', '分销员' - ] + fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many] - # Dynamic headers from ActivitySignup (only if orders contain activity orders) - json_keys = get_order_signup_keys(queryset) + # 写入表头 + ws.append([str(field.verbose_name) for field in fields]) - # Write header - # Add a prefix to json keys to distinguish them? Or keep as is. - # Let's keep as is but maybe add a note in header if needed. - ws.append(fixed_headers + json_keys) - - # Write data - for order in queryset: - row_data = get_order_row_data(order) - signup_data = get_order_signup_data(order) - + # 写入数据 + for obj in queryset: row = [] - for header in fixed_headers: - val = row_data.get(header, '') - row.append(str(val)) # Convert everything to string for safety + for field in fields: + value = getattr(obj, field.name) - for key in json_keys: - val = signup_data.get(key, '') - if val is None: - val = '' - row.append(str(val)) + if hasattr(obj, f'get_{field.name}_display'): + value = getattr(obj, f'get_{field.name}_display')() + # 处理关联对象(ForeignKey) + if field.is_relation and value: + value = str(value) + + if isinstance(value, (datetime.datetime, datetime.date)): + # openpyxl 可以直接处理 datetime 格式,Excel 会自动识别 + # 但为了避免时区问题,通常转为无时区时间或字符串 + if isinstance(value, datetime.datetime): + value = value.replace(tzinfo=None) + + row.append(value) ws.append(row) wb.save(response) return response -export_orders_excel.short_description = "导出选中订单为 Excel (含报名信息)" +export_to_excel.short_description = "导出选中项为 Excel"