This commit is contained in:
@@ -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 (含详细信息)"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user