创赢未来评分系统 - 初始化提交(移除大文件)
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
This commit is contained in:
0
backend/community/__init__.py
Normal file
0
backend/community/__init__.py
Normal file
404
backend/community/admin.py
Normal file
404
backend/community/admin.py
Normal file
@@ -0,0 +1,404 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import redirect
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
from unfold.decorators import display
|
||||
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
|
||||
from .admin_actions import export_signups_csv, export_signups_excel
|
||||
|
||||
class ActivitySignupInline(TabularInline):
|
||||
model = ActivitySignup
|
||||
extra = 0
|
||||
readonly_fields = ('signup_time',)
|
||||
fields = ('user', 'status', 'signup_time')
|
||||
autocomplete_fields = ['user']
|
||||
can_delete = True
|
||||
show_change_link = True
|
||||
|
||||
class ReplyInline(TabularInline):
|
||||
model = Reply
|
||||
extra = 0
|
||||
readonly_fields = ('created_at',)
|
||||
fields = ('content', 'author', 'created_at')
|
||||
can_delete = True
|
||||
show_change_link = True
|
||||
|
||||
class TopicMediaInline(TabularInline):
|
||||
model = TopicMedia
|
||||
extra = 0
|
||||
fields = ('file', 'file_url', 'media_type', 'created_at')
|
||||
readonly_fields = ('created_at',)
|
||||
can_delete = True
|
||||
|
||||
class OrderableAdminMixin:
|
||||
"""
|
||||
为 Admin 添加排序功能的 Mixin
|
||||
提供上移、下移按钮,直接交换 order 值
|
||||
"""
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<path:object_id>/move-up/', self.admin_site.admin_view(self.move_up_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_up'),
|
||||
path('<path:object_id>/move-down/', self.admin_site.admin_view(self.move_down_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_down'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def move_up_view(self, request, object_id):
|
||||
obj = self.get_object(request, object_id)
|
||||
if obj:
|
||||
qs = self.model.objects.all()
|
||||
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
|
||||
if hasattr(obj, 'is_pinned'):
|
||||
qs = qs.filter(is_pinned=obj.is_pinned)
|
||||
|
||||
# 找到排在它前面的一个 (order 小于它的最大值)
|
||||
prev_obj = qs.filter(order__lt=obj.order).order_by('-order').first()
|
||||
if prev_obj:
|
||||
# 交换
|
||||
obj.order, prev_obj.order = prev_obj.order, obj.order
|
||||
obj.save()
|
||||
prev_obj.save()
|
||||
self.message_user(request, f"成功将 {obj} 上移")
|
||||
else:
|
||||
# 已经是第一个,或者前面没有更小的 order
|
||||
pass
|
||||
return redirect(request.META.get('HTTP_REFERER', '..'))
|
||||
|
||||
def move_down_view(self, request, object_id):
|
||||
obj = self.get_object(request, object_id)
|
||||
if obj:
|
||||
qs = self.model.objects.all()
|
||||
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
|
||||
if hasattr(obj, 'is_pinned'):
|
||||
qs = qs.filter(is_pinned=obj.is_pinned)
|
||||
|
||||
# 找到排在它后面的一个 (order 大于它的最小值)
|
||||
next_obj = qs.filter(order__gt=obj.order).order_by('order').first()
|
||||
if next_obj:
|
||||
# 交换
|
||||
obj.order, next_obj.order = next_obj.order, obj.order
|
||||
obj.save()
|
||||
next_obj.save()
|
||||
self.message_user(request, f"成功将 {obj} 下移")
|
||||
return redirect(request.META.get('HTTP_REFERER', '..'))
|
||||
|
||||
def order_actions(self, obj):
|
||||
# 只有专家用户才显示排序按钮
|
||||
if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型,WeChatUser有is_star字段
|
||||
return "默认排序"
|
||||
|
||||
# 使用 inline style 实现基本样式
|
||||
btn_style = (
|
||||
"display: inline-flex; align-items: center; justify-content: center; "
|
||||
"width: 26px; height: 26px; border-radius: 6px; "
|
||||
"background-color: #f3f4f6; color: #4b5563; text-decoration: none; "
|
||||
"border: 1px solid #e5e7eb; transition: all 0.2s;"
|
||||
)
|
||||
# onmouseover js
|
||||
hover_js = "this.style.backgroundColor='#dbeafe'; this.style.color='#2563eb'; this.style.borderColor='#bfdbfe';"
|
||||
out_js = "this.style.backgroundColor='#f3f4f6'; this.style.color='#4b5563'; this.style.borderColor='#e5e7eb';"
|
||||
|
||||
return format_html(
|
||||
'<div style="display: flex; align-items: center; gap: 6px;">'
|
||||
'<a href="{}" title="上移" style="{}" onmouseover="{}" onmouseout="{}">'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>'
|
||||
'</a>'
|
||||
'<span style="font-weight: 700; font-family: system-ui, -apple-system, sans-serif; min-width: 20px; text-align: center; color: #374151; font-size: 13px;">{}</span>'
|
||||
'<a href="{}" title="下移" style="{}" onmouseover="{}" onmouseout="{}">'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
|
||||
'</a>'
|
||||
'</div>',
|
||||
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_up', args=[obj.pk]),
|
||||
btn_style, hover_js, out_js,
|
||||
obj.order,
|
||||
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_down', args=[obj.pk]),
|
||||
btn_style, hover_js, out_js,
|
||||
)
|
||||
order_actions.short_description = "排序调节"
|
||||
order_actions.allow_tags = True
|
||||
|
||||
@admin.register(Activity)
|
||||
class ActivityAdmin(ModelAdmin):
|
||||
list_display = ('title', 'author_info_display', 'banner_display', 'start_time', 'location', 'signup_count', 'is_visible', 'is_active', 'auto_confirm', 'created_at')
|
||||
list_filter = ('is_visible', 'is_active', 'auto_confirm', 'start_time')
|
||||
search_fields = ('title', 'location', 'author__phone_number')
|
||||
# autocomplete_fields = ['author'] # 暂时注释,避免环境不一致导致报错
|
||||
raw_id_fields = ('author',)
|
||||
inlines = [ActivitySignupInline]
|
||||
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('title', 'author', 'description', 'banner', 'banner_url', 'is_visible', 'is_active', 'auto_confirm')
|
||||
}),
|
||||
('费用与时间', {
|
||||
'fields': ('is_paid', 'price', 'start_time', 'end_time', 'location'),
|
||||
'classes': ('tab',)
|
||||
}),
|
||||
('报名设置', {
|
||||
'fields': ('max_participants', 'ask_name', 'ask_phone', 'ask_wechat', 'ask_company', 'signup_form_config'),
|
||||
'description': '勾选需要收集的信息,或者在下方“自定义报名配置”中填写高级JSON配置'
|
||||
}),
|
||||
)
|
||||
|
||||
@display(description="发布者 (手机号/昵称)")
|
||||
def author_info_display(self, obj):
|
||||
if not obj.author:
|
||||
return "-"
|
||||
phone = obj.author.phone_number or "无手机号"
|
||||
nickname = obj.author.nickname or "无昵称"
|
||||
return f"{phone} ({nickname})"
|
||||
|
||||
@display(description="Banner")
|
||||
def banner_display(self, obj):
|
||||
if obj.banner:
|
||||
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.banner.url)
|
||||
elif obj.banner_url:
|
||||
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.banner_url)
|
||||
return "暂无"
|
||||
|
||||
@display(description="报名人数")
|
||||
def signup_count(self, obj):
|
||||
return obj.signups.count()
|
||||
|
||||
@admin.register(ActivitySignup)
|
||||
class ActivitySignupAdmin(ModelAdmin):
|
||||
list_display = ('activity', 'user_info_display', 'signup_time', 'status_label', 'order_link')
|
||||
list_filter = ('status', 'signup_time', 'activity')
|
||||
search_fields = ('user__nickname', 'user__phone_number', 'activity__title')
|
||||
autocomplete_fields = ['activity', 'user']
|
||||
actions = [export_signups_csv, export_signups_excel]
|
||||
|
||||
fieldsets = (
|
||||
('报名详情', {
|
||||
'fields': ('activity', 'user', 'status', 'order', 'signup_info_display')
|
||||
}),
|
||||
('时间信息', {
|
||||
'fields': ('signup_time',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
readonly_fields = ('signup_time', 'signup_info_display')
|
||||
|
||||
@display(description="报名用户 (手机号/昵称)")
|
||||
def user_info_display(self, obj):
|
||||
phone = obj.user.phone_number or "无手机号"
|
||||
nickname = obj.user.nickname or "无昵称"
|
||||
return f"{phone} ({nickname})"
|
||||
|
||||
@display(description="报名信息")
|
||||
def signup_info_display(self, obj):
|
||||
import json
|
||||
if not obj.signup_info:
|
||||
return "无"
|
||||
|
||||
try:
|
||||
# Format JSON nicely
|
||||
formatted_json = json.dumps(obj.signup_info, indent=2, ensure_ascii=False)
|
||||
return format_html('<pre style="white-space: pre-wrap; word-break: break-all;">{}</pre>', formatted_json)
|
||||
except:
|
||||
return str(obj.signup_info)
|
||||
|
||||
@display(
|
||||
description="状态",
|
||||
label={
|
||||
"pending": "warning",
|
||||
"confirmed": "success",
|
||||
"cancelled": "danger",
|
||||
"unpaid": "secondary",
|
||||
}
|
||||
)
|
||||
def status_label(self, obj):
|
||||
# Auto sync with order status on display
|
||||
if obj.check_payment_status():
|
||||
# If status changed, return new status
|
||||
return obj.status
|
||||
return obj.status
|
||||
|
||||
@display(description="关联订单")
|
||||
def order_link(self, obj):
|
||||
if obj.order:
|
||||
return format_html('<a href="/admin/shop/order/{}/change/">Order #{}</a>', obj.order.id, obj.order.id)
|
||||
return "-"
|
||||
|
||||
@admin.register(Topic)
|
||||
class TopicAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
list_display = ('title', 'status', 'category', 'author_info_display', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at', 'order_actions')
|
||||
list_filter = ('status', 'category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
|
||||
search_fields = ('title', 'content', 'author__nickname', 'author__phone_number')
|
||||
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
|
||||
filter_horizontal = ('likes',)
|
||||
inlines = [TopicMediaInline, ReplyInline]
|
||||
actions = ['reset_ordering', 'approve_topics', 'reject_topics']
|
||||
list_editable = ('status', 'is_pinned', 'view_count')
|
||||
|
||||
@display(description="作者 (手机号/昵称)")
|
||||
def author_info_display(self, obj):
|
||||
if not obj.author:
|
||||
return "-"
|
||||
phone = obj.author.phone_number or "无手机号"
|
||||
nickname = obj.author.nickname or "无昵称"
|
||||
return f"{phone} ({nickname})"
|
||||
|
||||
@admin.action(description="批量通过审核")
|
||||
def approve_topics(self, request, queryset):
|
||||
rows_updated = queryset.update(status='published')
|
||||
self.message_user(request, f"{rows_updated} 个帖子已通过审核")
|
||||
|
||||
@admin.action(description="批量拒绝")
|
||||
def reject_topics(self, request, queryset):
|
||||
rows_updated = queryset.update(status='rejected')
|
||||
self.message_user(request, f"{rows_updated} 个帖子已拒绝")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
# 当帖子被置顶时(新建或修改状态),默认将排序值设为0
|
||||
if obj.is_pinned and (not change or 'is_pinned' in form.changed_data):
|
||||
obj.order = 0
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
@admin.action(description="重置排序 (0,1,2... 新帖子在前)")
|
||||
def reset_ordering(self, request, queryset):
|
||||
"""
|
||||
将所有帖子按时间倒序重新分配order值 (0, 1, 2, ...)
|
||||
"""
|
||||
all_objects = Topic.objects.all().order_by('-created_at')
|
||||
for index, obj in enumerate(all_objects):
|
||||
if obj.order != index:
|
||||
obj.order = index
|
||||
obj.save(update_fields=['order'])
|
||||
self.message_user(request, f"成功重置了 {all_objects.count()} 个帖子的排序权重(从0开始)。")
|
||||
|
||||
fieldsets = (
|
||||
('帖子内容', {
|
||||
'fields': ('title', 'status', 'category', 'content', 'is_pinned', 'likes')
|
||||
}),
|
||||
('关联信息', {
|
||||
'fields': ('author', 'related_product', 'related_service', 'related_course'),
|
||||
'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论'
|
||||
}),
|
||||
('统计数据', {
|
||||
'fields': ('view_count', 'order', 'created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
@display(description="关联项目")
|
||||
def get_related_item(self, obj):
|
||||
if obj.related_product:
|
||||
return f"[硬件] {obj.related_product.name}"
|
||||
if obj.related_service:
|
||||
return f"[服务] {obj.related_service.title}"
|
||||
if obj.related_course:
|
||||
return f"[课程] {obj.related_course.title}"
|
||||
return "-"
|
||||
|
||||
@display(description="回复数")
|
||||
def reply_count(self, obj):
|
||||
return obj.replies.count()
|
||||
|
||||
@admin.register(Reply)
|
||||
class ReplyAdmin(ModelAdmin):
|
||||
list_display = ('short_content', 'topic', 'author_info_display', 'is_pinned', 'like_count', 'created_at')
|
||||
list_filter = ('is_pinned', 'created_at')
|
||||
search_fields = ('content', 'author__nickname', 'author__phone_number', 'topic__title')
|
||||
autocomplete_fields = ['author', 'topic', 'reply_to']
|
||||
filter_horizontal = ('likes',)
|
||||
list_editable = ('is_pinned',)
|
||||
inlines = [TopicMediaInline]
|
||||
|
||||
fieldsets = (
|
||||
('回复内容', {
|
||||
'fields': ('topic', 'reply_to', 'content', 'likes')
|
||||
}),
|
||||
('发布信息', {
|
||||
'fields': ('author', 'is_pinned', 'created_at')
|
||||
}),
|
||||
)
|
||||
readonly_fields = ('created_at',)
|
||||
|
||||
@display(description="回复者 (手机号/昵称)")
|
||||
def author_info_display(self, obj):
|
||||
if not obj.author:
|
||||
return "-"
|
||||
phone = obj.author.phone_number or "无手机号"
|
||||
nickname = obj.author.nickname or "无昵称"
|
||||
return f"{phone} ({nickname})"
|
||||
|
||||
@display(description="点赞数")
|
||||
def like_count(self, obj):
|
||||
return obj.likes.count()
|
||||
|
||||
@display(description="内容摘要")
|
||||
def short_content(self, obj):
|
||||
return obj.content[:30] + '...' if len(obj.content) > 30 else obj.content
|
||||
|
||||
@admin.register(TopicMedia)
|
||||
class TopicMediaAdmin(ModelAdmin):
|
||||
list_display = ('id', 'media_type', 'file_preview', 'topic', 'reply', 'created_at')
|
||||
list_filter = ('media_type', 'created_at')
|
||||
search_fields = ('file', 'topic__title')
|
||||
autocomplete_fields = ['topic', 'reply']
|
||||
|
||||
@display(description="预览")
|
||||
def file_preview(self, obj):
|
||||
url = ""
|
||||
if obj.file:
|
||||
url = obj.file.url
|
||||
elif obj.file_url:
|
||||
url = obj.file_url
|
||||
|
||||
if obj.media_type == 'image' and url:
|
||||
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
|
||||
return obj.file.name or "外部文件"
|
||||
|
||||
@admin.register(Announcement)
|
||||
class AnnouncementAdmin(ModelAdmin):
|
||||
list_display = ('title', 'image_preview', 'active_label', 'pinned_label', 'priority', 'start_time', 'end_time', 'created_at')
|
||||
list_filter = ('is_active', 'is_pinned', 'created_at')
|
||||
search_fields = ('title', 'content')
|
||||
|
||||
fieldsets = (
|
||||
('公告信息', {
|
||||
'fields': ('title', 'content', 'link_url')
|
||||
}),
|
||||
('图片设置', {
|
||||
'fields': ('image', 'image_url'),
|
||||
'description': '上传图片或填写图片链接,优先显示上传的图片'
|
||||
}),
|
||||
('显示设置', {
|
||||
'fields': ('is_active', 'is_pinned', 'priority'),
|
||||
'classes': ('tab',)
|
||||
}),
|
||||
('排期设置', {
|
||||
'fields': ('start_time', 'end_time'),
|
||||
'classes': ('tab',)
|
||||
}),
|
||||
)
|
||||
|
||||
@display(description="图片预览")
|
||||
def image_preview(self, obj):
|
||||
url = obj.display_image_url
|
||||
if url:
|
||||
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
|
||||
return "无图片"
|
||||
|
||||
@display(
|
||||
description="状态",
|
||||
label={
|
||||
True: "success",
|
||||
False: "danger",
|
||||
}
|
||||
)
|
||||
def active_label(self, obj):
|
||||
return obj.is_active
|
||||
|
||||
@display(
|
||||
description="置顶",
|
||||
label={
|
||||
True: "warning",
|
||||
False: "default",
|
||||
}
|
||||
)
|
||||
def pinned_label(self, obj):
|
||||
return obj.is_pinned
|
||||
149
backend/community/admin_actions.py
Normal file
149
backend/community/admin_actions.py
Normal file
@@ -0,0 +1,149 @@
|
||||
import csv
|
||||
import json
|
||||
import datetime
|
||||
from django.http import HttpResponse
|
||||
from django.utils.encoding import escape_uri_path
|
||||
|
||||
def flatten_json(y):
|
||||
"""
|
||||
Flatten a nested json object
|
||||
"""
|
||||
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_signup_info_keys(queryset):
|
||||
"""
|
||||
Collect all unique keys from the signup_info JSON across the queryset
|
||||
"""
|
||||
keys = set()
|
||||
for obj in queryset:
|
||||
if obj.signup_info and isinstance(obj.signup_info, dict):
|
||||
# Flatten the dictionary first to get all nested keys
|
||||
flat_info = flatten_json(obj.signup_info)
|
||||
keys.update(flat_info.keys())
|
||||
return sorted(list(keys))
|
||||
|
||||
def export_signups_csv(modeladmin, request, queryset):
|
||||
"""
|
||||
Export selected signups to CSV, including flattened JSON fields
|
||||
"""
|
||||
opts = modeladmin.model._meta
|
||||
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
response = HttpResponse(content_type='text/csv; charset=utf-8-sig')
|
||||
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
|
||||
|
||||
writer = csv.writer(response)
|
||||
|
||||
# Base fields to export
|
||||
base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID']
|
||||
|
||||
# Get dynamic JSON keys
|
||||
json_keys = get_signup_info_keys(queryset)
|
||||
|
||||
# Write header
|
||||
writer.writerow(base_headers + json_keys)
|
||||
|
||||
# Write data
|
||||
for obj in queryset:
|
||||
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 = {}
|
||||
if obj.signup_info and isinstance(obj.signup_info, dict):
|
||||
flat_info = flatten_json(obj.signup_info)
|
||||
|
||||
for key in json_keys:
|
||||
val = flat_info.get(key, '')
|
||||
if val is None:
|
||||
val = ''
|
||||
row.append(str(val))
|
||||
|
||||
writer.writerow(row)
|
||||
|
||||
return response
|
||||
|
||||
export_signups_csv.short_description = "导出选中报名记录为 CSV (含详细信息)"
|
||||
|
||||
def export_signups_excel(modeladmin, request, queryset):
|
||||
"""
|
||||
Export selected signups to Excel, including flattened JSON fields
|
||||
"""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError:
|
||||
modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能", level='error')
|
||||
return
|
||||
|
||||
opts = modeladmin.model._meta
|
||||
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',
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = str(opts.verbose_name)[:31] # Sheet name limit is 31 chars
|
||||
|
||||
# Base fields to export
|
||||
base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID']
|
||||
|
||||
# Get dynamic JSON keys
|
||||
json_keys = get_signup_info_keys(queryset)
|
||||
|
||||
# Write header
|
||||
ws.append(base_headers + json_keys)
|
||||
|
||||
# Write data
|
||||
for obj in queryset:
|
||||
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 ''
|
||||
]
|
||||
|
||||
# Add JSON data
|
||||
flat_info = {}
|
||||
if obj.signup_info and isinstance(obj.signup_info, dict):
|
||||
flat_info = flatten_json(obj.signup_info)
|
||||
|
||||
for key in json_keys:
|
||||
val = flat_info.get(key, '')
|
||||
if val is None:
|
||||
val = ''
|
||||
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 (含详细信息)"
|
||||
5
backend/community/apps.py
Normal file
5
backend/community/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommunityConfig(AppConfig):
|
||||
name = 'community'
|
||||
144
backend/community/migrations/0001_initial.py
Normal file
144
backend/community/migrations/0001_initial.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-04 04:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('shop', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Announcement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='公告标题')),
|
||||
('content', models.TextField(verbose_name='公告内容')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='announcements/', verbose_name='公告图片')),
|
||||
('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片链接')),
|
||||
('link_url', models.URLField(blank=True, null=True, verbose_name='跳转链接')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||
('is_pinned', models.BooleanField(default=False, verbose_name='是否置顶')),
|
||||
('priority', models.IntegerField(default=0, help_text='数字越大越靠前', verbose_name='排序权重')),
|
||||
('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始展示时间')),
|
||||
('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束展示时间')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '社区公告',
|
||||
'verbose_name_plural': '社区公告管理',
|
||||
'ordering': ['-is_pinned', '-priority', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Activity',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='活动标题')),
|
||||
('description', models.TextField(verbose_name='活动详情')),
|
||||
('banner', models.ImageField(blank=True, null=True, upload_to='activities/banners/', verbose_name='活动Banner图')),
|
||||
('banner_url', models.URLField(blank=True, help_text='可直接填写图片链接,若同时上传图片,将优先显示上传的图片', null=True, verbose_name='活动Banner链接')),
|
||||
('start_time', models.DateTimeField(verbose_name='开始时间')),
|
||||
('end_time', models.DateTimeField(verbose_name='结束时间')),
|
||||
('location', models.CharField(max_length=100, verbose_name='活动地点')),
|
||||
('max_participants', models.IntegerField(default=50, verbose_name='最大报名人数')),
|
||||
('is_paid', models.BooleanField(default=False, verbose_name='是否收费')),
|
||||
('price', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='报名费用')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||
('is_visible', models.BooleanField(default=True, help_text='关闭后将不在前端列表页显示', verbose_name='是否显示')),
|
||||
('auto_confirm', models.BooleanField(default=False, help_text='开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核', verbose_name='无需审核')),
|
||||
('ask_name', models.BooleanField(default=False, verbose_name='收集姓名')),
|
||||
('ask_phone', models.BooleanField(default=False, verbose_name='收集手机号')),
|
||||
('ask_wechat', models.BooleanField(default=False, verbose_name='收集微信号')),
|
||||
('ask_company', models.BooleanField(default=False, verbose_name='收集公司/机构')),
|
||||
('signup_form_config', models.JSONField(blank=True, default=list, help_text='JSON格式的高级配置,若填写则优先于上方开关。例如:[{"name": "job", "label": "职位", "type": "text", "required": true}]', verbose_name='自定义报名配置')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '社区活动',
|
||||
'verbose_name_plural': '社区活动管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Topic',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='标题')),
|
||||
('category', models.CharField(choices=[('discussion', '技术讨论'), ('help', '求助问答'), ('share', '经验分享'), ('notice', '官方公告')], default='discussion', max_length=20, verbose_name='分类')),
|
||||
('status', models.CharField(choices=[('pending', '待审核'), ('published', '已发布'), ('rejected', '已拒绝')], default='published', max_length=20, verbose_name='状态')),
|
||||
('content', models.TextField(help_text='支持Markdown格式,支持插入图片', verbose_name='内容')),
|
||||
('view_count', models.IntegerField(default=0, verbose_name='浏览量')),
|
||||
('is_pinned', models.BooleanField(default=False, verbose_name='置顶')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发布时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='topics', to='shop.wechatuser', verbose_name='作者')),
|
||||
('likes', models.ManyToManyField(blank=True, related_name='liked_topics', to='shop.wechatuser', verbose_name='点赞用户')),
|
||||
('related_course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.vccourse', verbose_name='关联课程')),
|
||||
('related_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件')),
|
||||
('related_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.service', verbose_name='关联服务')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '论坛帖子',
|
||||
'verbose_name_plural': '论坛帖子管理',
|
||||
'ordering': ['order', '-is_pinned', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Reply',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content', models.TextField(help_text='支持Markdown格式', verbose_name='回复内容')),
|
||||
('is_pinned', models.BooleanField(default=False, verbose_name='置顶')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='回复时间')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='shop.wechatuser', verbose_name='回复者')),
|
||||
('likes', models.ManyToManyField(blank=True, related_name='liked_replies', to='shop.wechatuser', verbose_name='点赞用户')),
|
||||
('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='community.reply', verbose_name='回复楼层')),
|
||||
('topic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='community.topic', verbose_name='所属帖子')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '帖子回复',
|
||||
'verbose_name_plural': '帖子回复管理',
|
||||
'ordering': ['-is_pinned', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TopicMedia',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to='community/media/', verbose_name='文件')),
|
||||
('file_url', models.URLField(blank=True, max_length=500, null=True, verbose_name='文件链接')),
|
||||
('media_type', models.CharField(choices=[('image', '图片'), ('video', '视频'), ('file', '文件')], default='image', max_length=10, verbose_name='媒体类型')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')),
|
||||
('reply', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.reply', verbose_name='所属回复')),
|
||||
('topic', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.topic', verbose_name='所属帖子')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '论坛媒体资源',
|
||||
'verbose_name_plural': '论坛媒体资源管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ActivitySignup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('signup_time', models.DateTimeField(auto_now_add=True, verbose_name='报名时间')),
|
||||
('signup_info', models.JSONField(blank=True, default=dict, verbose_name='报名信息')),
|
||||
('status', models.CharField(choices=[('unpaid', '待支付'), ('pending', '审核中'), ('confirmed', '报名成功'), ('cancelled', '已取消')], default='confirmed', max_length=20, verbose_name='状态')),
|
||||
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='signups', to='community.activity', verbose_name='活动')),
|
||||
('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_signups', to='shop.order', verbose_name='关联订单')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_signups', to='shop.wechatuser', verbose_name='报名用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '活动报名',
|
||||
'verbose_name_plural': '活动报名管理',
|
||||
'unique_together': {('activity', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
20
backend/community/migrations/0002_activity_author.py
Normal file
20
backend/community/migrations/0002_activity_author.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-04 04:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('community', '0001_initial'),
|
||||
('shop', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='activity',
|
||||
name='author',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', verbose_name='发布者'),
|
||||
),
|
||||
]
|
||||
20
backend/community/migrations/0003_alter_activity_author.py
Normal file
20
backend/community/migrations/0003_alter_activity_author.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-17 11:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('community', '0002_activity_author'),
|
||||
('shop', '0039_vccourse_video_embed_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='author',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', to_field='phone_number', verbose_name='发布者'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-18 12:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('community', '0003_alter_activity_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activitysignup',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='announcement',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='reply',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='topic',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='topicmedia',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
0
backend/community/migrations/__init__.py
Normal file
0
backend/community/migrations/__init__.py
Normal file
285
backend/community/models.py
Normal file
285
backend/community/models.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from django.db import models
|
||||
from shop.models import WeChatUser, ESP32Config, Order, Service, VCCourse, ServiceOrder
|
||||
|
||||
class Activity(models.Model):
|
||||
"""
|
||||
社区活动模型
|
||||
"""
|
||||
title = models.CharField(max_length=100, verbose_name="活动标题")
|
||||
description = models.TextField(verbose_name="活动详情")
|
||||
banner = models.ImageField(upload_to='activities/banners/', verbose_name="活动Banner图", null=True, blank=True)
|
||||
banner_url = models.URLField(verbose_name="活动Banner链接", null=True, blank=True, help_text="可直接填写图片链接,若同时上传图片,将优先显示上传的图片")
|
||||
start_time = models.DateTimeField(verbose_name="开始时间")
|
||||
end_time = models.DateTimeField(verbose_name="结束时间")
|
||||
location = models.CharField(max_length=100, verbose_name="活动地点")
|
||||
max_participants = models.IntegerField(default=50, verbose_name="最大报名人数")
|
||||
|
||||
# 费用设置
|
||||
is_paid = models.BooleanField(default=False, verbose_name="是否收费")
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="报名费用")
|
||||
|
||||
author = models.ForeignKey(WeChatUser, to_field='phone_number', on_delete=models.SET_NULL, related_name='activities', verbose_name="发布者", null=True, blank=True)
|
||||
|
||||
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||
is_visible = models.BooleanField(default=True, verbose_name="是否显示", help_text="关闭后将不在前端列表页显示")
|
||||
auto_confirm = models.BooleanField(default=False, verbose_name="无需审核", help_text="开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核")
|
||||
|
||||
# 常用报名信息开关
|
||||
ask_name = models.BooleanField(default=False, verbose_name="收集姓名")
|
||||
ask_phone = models.BooleanField(default=False, verbose_name="收集手机号")
|
||||
ask_wechat = models.BooleanField(default=False, verbose_name="收集微信号")
|
||||
ask_company = models.BooleanField(default=False, verbose_name="收集公司/机构")
|
||||
|
||||
signup_form_config = models.JSONField(
|
||||
default=list,
|
||||
verbose_name="自定义报名配置",
|
||||
blank=True,
|
||||
help_text='JSON格式的高级配置,若填写则优先于上方开关。例如:[{"name": "job", "label": "职位", "type": "text", "required": true}]'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
|
||||
def clean(self):
|
||||
from django.core.exceptions import ValidationError
|
||||
if not self.banner and not self.banner_url:
|
||||
raise ValidationError("Banner图片和Banner链接必须至少填写一项")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def display_banner_url(self):
|
||||
"""
|
||||
获取Banner显示的URL,优先使用上传的图片
|
||||
"""
|
||||
if self.banner:
|
||||
return self.banner.url
|
||||
return self.banner_url
|
||||
|
||||
@property
|
||||
def current_signups(self):
|
||||
"""
|
||||
当前有效报名人数(仅统计已确认/已支付的报名)
|
||||
"""
|
||||
return self.signups.filter(status='confirmed').count()
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
verbose_name = "社区活动"
|
||||
verbose_name_plural = "社区活动管理"
|
||||
|
||||
|
||||
class ActivitySignup(models.Model):
|
||||
"""
|
||||
活动报名记录
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('unpaid', '待支付'),
|
||||
('pending', '审核中'),
|
||||
('confirmed', '报名成功'),
|
||||
('cancelled', '已取消'),
|
||||
)
|
||||
|
||||
activity = models.ForeignKey(Activity, on_delete=models.CASCADE, related_name='signups', verbose_name="活动")
|
||||
user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='activity_signups', verbose_name="报名用户")
|
||||
signup_time = models.DateTimeField(auto_now_add=True, verbose_name="报名时间")
|
||||
signup_info = models.JSONField(
|
||||
default=dict,
|
||||
verbose_name="报名信息",
|
||||
blank=True
|
||||
)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态")
|
||||
|
||||
# 关联订单(针对付费活动)
|
||||
order = models.ForeignKey('shop.Order', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联订单", related_name='activity_signups')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.nickname} - {self.activity.title}"
|
||||
|
||||
def check_payment_status(self):
|
||||
"""
|
||||
检查并同步关联订单的支付状态
|
||||
"""
|
||||
if self.status == 'unpaid' and self.order:
|
||||
if self.order.status == 'paid':
|
||||
self.status = 'confirmed' if self.activity.auto_confirm else 'pending'
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
verbose_name = "活动报名"
|
||||
verbose_name_plural = "活动报名管理"
|
||||
unique_together = ('activity', 'user')
|
||||
|
||||
|
||||
class Topic(models.Model):
|
||||
"""
|
||||
论坛帖子/主题
|
||||
"""
|
||||
title = models.CharField(max_length=200, verbose_name="标题")
|
||||
|
||||
CATEGORY_CHOICES = (
|
||||
('discussion', '技术讨论'),
|
||||
('help', '求助问答'),
|
||||
('share', '经验分享'),
|
||||
('notice', '官方公告'),
|
||||
)
|
||||
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='discussion', verbose_name="分类")
|
||||
|
||||
STATUS_CHOICES = (
|
||||
('pending', '待审核'),
|
||||
('published', '已发布'),
|
||||
('rejected', '已拒绝'),
|
||||
)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='published', verbose_name="状态")
|
||||
|
||||
content = models.TextField(verbose_name="内容", help_text="支持Markdown格式,支持插入图片")
|
||||
author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='topics', verbose_name="作者")
|
||||
|
||||
# 关联对象:硬件、服务、课程
|
||||
related_product = models.ForeignKey(ESP32Config, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联硬件")
|
||||
related_service = models.ForeignKey(Service, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联服务")
|
||||
related_course = models.ForeignKey(VCCourse, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联课程")
|
||||
|
||||
view_count = models.IntegerField(default=0, verbose_name="浏览量")
|
||||
likes = models.ManyToManyField(WeChatUser, related_name='liked_topics', blank=True, verbose_name="点赞用户")
|
||||
is_pinned = models.BooleanField(default=False, verbose_name="置顶")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
order = models.IntegerField(default=0, verbose_name="排序")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 记录是否为新对象,因为super().save后pk就有了
|
||||
is_new = self.pk is None
|
||||
|
||||
# 第一次保存,先入库
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# 如果是新创建,且 order 默认为 0(未指定)
|
||||
if is_new and getattr(self, 'order', 0) == 0:
|
||||
# 将所有其他帖子的 order + 1,腾出 0 的位置
|
||||
Topic.objects.exclude(pk=self.pk).filter(order__gte=0).update(order=models.F('order') + 1)
|
||||
# 确保自己是 0
|
||||
Topic.objects.filter(pk=self.pk).update(order=0)
|
||||
self.order = 0
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def is_verified_owner(self):
|
||||
"""
|
||||
判断作者是否为关联项目(硬件/服务/课程)的已购用户(Verified Owner)
|
||||
"""
|
||||
# 1. 验证硬件
|
||||
if self.related_product:
|
||||
if Order.objects.filter(
|
||||
wechat_user=self.author,
|
||||
config=self.related_product,
|
||||
status__in=['paid', 'shipped']
|
||||
).exists():
|
||||
return True
|
||||
|
||||
# 2. 验证课程
|
||||
if self.related_course:
|
||||
if Order.objects.filter(
|
||||
wechat_user=self.author,
|
||||
course=self.related_course,
|
||||
status__in=['paid', 'shipped']
|
||||
).exists():
|
||||
return True
|
||||
|
||||
# 3. 验证服务
|
||||
if self.related_service:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
verbose_name = "论坛帖子"
|
||||
verbose_name_plural = "论坛帖子管理"
|
||||
ordering = ['order', '-is_pinned', '-created_at']
|
||||
|
||||
|
||||
class Reply(models.Model):
|
||||
"""
|
||||
帖子回复
|
||||
"""
|
||||
topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='replies', verbose_name="所属帖子")
|
||||
content = models.TextField(verbose_name="回复内容", help_text="支持Markdown格式")
|
||||
author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='replies', verbose_name="回复者")
|
||||
reply_to = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="回复楼层")
|
||||
likes = models.ManyToManyField(WeChatUser, related_name='liked_replies', blank=True, verbose_name="点赞用户")
|
||||
is_pinned = models.BooleanField(default=False, verbose_name="置顶")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="回复时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"回复: {self.topic.title}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "帖子回复"
|
||||
verbose_name_plural = "帖子回复管理"
|
||||
ordering = ['-is_pinned', '-created_at']
|
||||
|
||||
|
||||
class TopicMedia(models.Model):
|
||||
"""
|
||||
论坛多媒体资源(图片/视频/文件)
|
||||
"""
|
||||
MEDIA_TYPE_CHOICES = (
|
||||
('image', '图片'),
|
||||
('video', '视频'),
|
||||
('file', '文件'),
|
||||
)
|
||||
|
||||
topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='media', verbose_name="所属帖子", null=True, blank=True)
|
||||
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, related_name='media', verbose_name="所属回复", null=True, blank=True)
|
||||
file = models.FileField(upload_to='community/media/', verbose_name="文件", null=True, blank=True)
|
||||
file_url = models.URLField(max_length=500, verbose_name="文件链接", null=True, blank=True)
|
||||
media_type = models.CharField(max_length=10, choices=MEDIA_TYPE_CHOICES, default='image', verbose_name="媒体类型")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.media_type} - {self.file.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "论坛媒体资源"
|
||||
verbose_name_plural = "论坛媒体资源管理"
|
||||
|
||||
|
||||
class Announcement(models.Model):
|
||||
"""
|
||||
社区公告模型
|
||||
"""
|
||||
title = models.CharField(max_length=100, verbose_name="公告标题")
|
||||
content = models.TextField(verbose_name="公告内容")
|
||||
image = models.ImageField(upload_to='announcements/', verbose_name="公告图片", null=True, blank=True)
|
||||
image_url = models.URLField(verbose_name="图片链接", null=True, blank=True, help_text="优先使用上传的图片")
|
||||
link_url = models.URLField(verbose_name="跳转链接", null=True, blank=True)
|
||||
|
||||
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||
is_pinned = models.BooleanField(default=False, verbose_name="是否置顶")
|
||||
priority = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越大越靠前")
|
||||
|
||||
start_time = models.DateTimeField(verbose_name="开始展示时间", null=True, blank=True)
|
||||
end_time = models.DateTimeField(verbose_name="结束展示时间", null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
@property
|
||||
def display_image_url(self):
|
||||
if self.image:
|
||||
return self.image.url
|
||||
return self.image_url
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
verbose_name = "社区公告"
|
||||
verbose_name_plural = "社区公告管理"
|
||||
ordering = ['-is_pinned', '-priority', '-created_at']
|
||||
23
backend/community/permissions.py
Normal file
23
backend/community/permissions.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from rest_framework import permissions
|
||||
from .utils import get_current_wechat_user
|
||||
|
||||
class IsAuthorOrReadOnly(permissions.BasePermission):
|
||||
"""
|
||||
Object-level permission to only allow authors of an object to edit it.
|
||||
Assumes the model instance has an `author` attribute.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# Read permissions are allowed to any request,
|
||||
# so we'll always allow GET, HEAD or OPTIONS requests.
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Write permissions are only allowed to the author of the object.
|
||||
# We need to manually get the user because we are using custom auth logic (get_current_wechat_user)
|
||||
# instead of request.user for some reason (or in addition to).
|
||||
# However, DRF's request.user might not be set if we don't use a standard authentication class.
|
||||
# Based on views.py, it uses `get_current_wechat_user(request)`.
|
||||
|
||||
current_user = get_current_wechat_user(request)
|
||||
return current_user and obj.author == current_user
|
||||
190
backend/community/serializers.py
Normal file
190
backend/community/serializers.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
|
||||
from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer
|
||||
from .utils import get_current_wechat_user
|
||||
|
||||
class ActivitySerializer(serializers.ModelSerializer):
|
||||
display_banner_url = serializers.ReadOnlyField()
|
||||
signup_form_config = serializers.SerializerMethodField()
|
||||
current_signups = serializers.IntegerField(read_only=True)
|
||||
has_signed_up = serializers.SerializerMethodField()
|
||||
is_signed_up = serializers.SerializerMethodField()
|
||||
my_signup_status = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Activity
|
||||
fields = '__all__'
|
||||
|
||||
def get_has_signed_up(self, obj):
|
||||
return self.get_is_signed_up(obj)
|
||||
|
||||
def get_my_signup_status(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return None
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
# Return the status of the non-cancelled signup
|
||||
signup = obj.signups.filter(user=user).exclude(status='cancelled').first()
|
||||
return signup.status if signup else None
|
||||
return None
|
||||
|
||||
def get_is_signed_up(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return False
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
# Check if there is a valid signup (only confirmed counts)
|
||||
return obj.signups.filter(user=user, status='confirmed').exists()
|
||||
return False
|
||||
|
||||
def get_signup_form_config(self, obj):
|
||||
# 1. 优先使用 JSON 配置
|
||||
if obj.signup_form_config:
|
||||
return obj.signup_form_config
|
||||
|
||||
# 2. 否则根据开关生成默认配置
|
||||
config = []
|
||||
if obj.ask_name:
|
||||
config.append({"name": "name", "label": "姓名", "type": "text", "required": True})
|
||||
if obj.ask_phone:
|
||||
config.append({"name": "phone", "label": "手机号", "type": "number", "required": True})
|
||||
if obj.ask_wechat:
|
||||
config.append({"name": "wechat", "label": "微信号", "type": "text", "required": True})
|
||||
if obj.ask_company:
|
||||
config.append({"name": "company", "label": "公司/机构", "type": "text", "required": False})
|
||||
|
||||
return config
|
||||
|
||||
class ActivitySignupSerializer(serializers.ModelSerializer):
|
||||
activity_info = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ActivitySignup
|
||||
fields = ['id', 'activity', 'activity_info', 'user', 'signup_time', 'status', 'signup_info']
|
||||
read_only_fields = ['signup_time', 'status', 'user']
|
||||
|
||||
def get_activity_info(self, obj):
|
||||
return ActivitySerializer(obj.activity).data
|
||||
|
||||
class TopicMediaSerializer(serializers.ModelSerializer):
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TopicMedia
|
||||
fields = ['id', 'file', 'file_url', 'url', 'media_type', 'created_at']
|
||||
|
||||
def get_url(self, obj):
|
||||
if obj.file:
|
||||
return obj.file.url
|
||||
return obj.file_url
|
||||
|
||||
class ReplySerializer(serializers.ModelSerializer):
|
||||
author_info = WeChatUserSerializer(source='author', read_only=True)
|
||||
media = TopicMediaSerializer(many=True, read_only=True)
|
||||
media_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
write_only=True,
|
||||
required=False
|
||||
)
|
||||
like_count = serializers.IntegerField(source='likes.count', read_only=True)
|
||||
is_liked = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Reply
|
||||
fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at', 'media_ids', 'is_pinned', 'like_count', 'is_liked']
|
||||
read_only_fields = ['author', 'created_at']
|
||||
|
||||
def get_is_liked(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return False
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
return obj.likes.filter(id=user.id).exists()
|
||||
return False
|
||||
|
||||
def create(self, validated_data):
|
||||
media_ids = validated_data.pop('media_ids', [])
|
||||
reply = super().create(validated_data)
|
||||
if media_ids:
|
||||
TopicMedia.objects.filter(id__in=media_ids).update(reply=reply)
|
||||
return reply
|
||||
|
||||
class TopicSerializer(serializers.ModelSerializer):
|
||||
author_info = WeChatUserSerializer(source='author', read_only=True)
|
||||
replies = ReplySerializer(many=True, read_only=True)
|
||||
media = TopicMediaSerializer(many=True, read_only=True)
|
||||
is_verified_owner = serializers.BooleanField(read_only=True)
|
||||
like_count = serializers.IntegerField(source='likes.count', read_only=True)
|
||||
is_liked = serializers.SerializerMethodField()
|
||||
|
||||
product_info = ESP32ConfigSerializer(source='related_product', read_only=True)
|
||||
service_info = ServiceSerializer(source='related_service', read_only=True)
|
||||
course_info = VCCourseSerializer(source='related_course', read_only=True)
|
||||
|
||||
media_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
write_only=True,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Topic
|
||||
fields = [
|
||||
'id', 'title', 'category', 'status', 'content', 'author', 'author_info',
|
||||
'related_product', 'product_info',
|
||||
'related_service', 'service_info',
|
||||
'related_course', 'course_info',
|
||||
'view_count', 'is_pinned', 'created_at', 'updated_at',
|
||||
'is_verified_owner', 'replies', 'media', 'media_ids',
|
||||
'like_count', 'is_liked'
|
||||
]
|
||||
read_only_fields = ['author', 'view_count', 'created_at', 'updated_at', 'is_verified_owner', 'status']
|
||||
|
||||
def get_is_liked(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return False
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
return obj.likes.filter(id=user.id).exists()
|
||||
return False
|
||||
|
||||
def create(self, validated_data):
|
||||
media_ids = validated_data.pop('media_ids', [])
|
||||
topic = super().create(validated_data)
|
||||
if media_ids:
|
||||
TopicMedia.objects.filter(id__in=media_ids).update(topic=topic)
|
||||
return topic
|
||||
|
||||
class AnnouncementSerializer(serializers.ModelSerializer):
|
||||
display_image_url = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = Announcement
|
||||
fields = '__all__'
|
||||
|
||||
class AdminActivitySerializer(serializers.ModelSerializer):
|
||||
signup_form_config = serializers.JSONField(required=False)
|
||||
description = serializers.CharField(
|
||||
style={'base_template': 'textarea.html'},
|
||||
help_text="活动详情内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Activity
|
||||
fields = '__all__'
|
||||
read_only_fields = ['author', 'created_at', 'current_signups']
|
||||
|
||||
class AdminTopicSerializer(serializers.ModelSerializer):
|
||||
content = serializers.CharField(
|
||||
style={'base_template': 'textarea.html'},
|
||||
help_text="帖子内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Topic
|
||||
fields = '__all__'
|
||||
read_only_fields = ['author', 'created_at', 'updated_at', 'view_count', 'is_verified_owner']
|
||||
3
backend/community/tests.py
Normal file
3
backend/community/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
15
backend/community/urls.py
Normal file
15
backend/community/urls.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet, AnnouncementViewSet, AdminPublishViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'activities', ActivityViewSet)
|
||||
router.register(r'topics', TopicViewSet)
|
||||
router.register(r'replies', ReplyViewSet)
|
||||
router.register(r'media', TopicMediaViewSet, basename='media')
|
||||
router.register(r'announcements', AnnouncementViewSet)
|
||||
router.register(r'admin-publish', AdminPublishViewSet, basename='admin-publish')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
55
backend/community/utils.py
Normal file
55
backend/community/utils.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
||||
from shop.models import WeChatUser
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_current_wechat_user(request):
|
||||
"""
|
||||
根据 Authorization 头获取当前微信用户
|
||||
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
|
||||
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
|
||||
"""
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
logger.warning(f"Authentication failed: Missing or invalid Authorization header. Header: {auth_header}")
|
||||
return None
|
||||
token = auth_header.split(' ')[1]
|
||||
signer = TimestampSigner()
|
||||
try:
|
||||
# 签名包含 openid
|
||||
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
|
||||
user = WeChatUser.objects.filter(openid=openid).first()
|
||||
|
||||
if user:
|
||||
return user
|
||||
|
||||
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
|
||||
# 场景:Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
|
||||
logger.info(f"User not found for openid: {openid}, checking for merged account...")
|
||||
if openid.startswith('web_'):
|
||||
try:
|
||||
# 格式: web_13800138000
|
||||
parts = openid.split('_', 1)
|
||||
if len(parts) == 2:
|
||||
phone = parts[1]
|
||||
# 尝试通过手机号查找(查找合并后的主账号)
|
||||
user = WeChatUser.objects.filter(phone_number=phone).first()
|
||||
if user:
|
||||
logger.info(f"Found merged user {user.id} for phone {phone}")
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking merged account: {e}")
|
||||
pass
|
||||
|
||||
logger.warning(f"Authentication failed: User not found for openid {openid}")
|
||||
return None
|
||||
except SignatureExpired:
|
||||
logger.warning("Authentication failed: Signature expired")
|
||||
return None
|
||||
except BadSignature:
|
||||
logger.warning("Authentication failed: Bad signature")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication unexpected error: {e}")
|
||||
return None
|
||||
516
backend/community/views.py
Normal file
516
backend/community/views.py
Normal file
@@ -0,0 +1,516 @@
|
||||
from rest_framework import viewsets, status, mixins, parsers, filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import serializers, permissions
|
||||
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
||||
from django.utils import timezone
|
||||
from django.db import models
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
|
||||
|
||||
from shop.models import WeChatUser, Order
|
||||
from shop.views import get_wechat_pay_client
|
||||
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
|
||||
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer, AdminActivitySerializer, AdminTopicSerializer
|
||||
from .utils import get_current_wechat_user
|
||||
from .permissions import IsAuthorOrReadOnly
|
||||
|
||||
class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
社区活动接口
|
||||
"""
|
||||
queryset = Activity.objects.filter(is_active=True).order_by('-created_at')
|
||||
serializer_class = ActivitySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
# list 接口过滤 is_visible=True
|
||||
if self.action == 'list':
|
||||
qs = qs.filter(is_visible=True)
|
||||
return qs
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
|
||||
# Sync status for current user
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
# Use filter to avoid exception if multiple exist (though unique_together constraint exists)
|
||||
signup = instance.signups.filter(user=user).exclude(status='cancelled').first()
|
||||
if signup:
|
||||
has_changed = signup.check_payment_status()
|
||||
if has_changed:
|
||||
print(f"DEBUG: Synced signup status for user {user.id} activity {instance.id}")
|
||||
|
||||
serializer = self.get_serializer(instance)
|
||||
# Debug print to verify data
|
||||
print(f"DEBUG: Activity {instance.title} current_signups: {instance.current_signups}")
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(summary="报名活动")
|
||||
@action(detail=True, methods=['post'])
|
||||
def signup(self, request, pk=None):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
|
||||
activity = self.get_object()
|
||||
|
||||
# 1. Check confirmed signup
|
||||
if ActivitySignup.objects.filter(activity=activity, user=user, status='confirmed').exists():
|
||||
return Response({'error': '您已报名该活动'}, status=400)
|
||||
|
||||
# 2. Get pending signup (for retry)
|
||||
pending_signup = ActivitySignup.objects.filter(activity=activity, user=user, status='pending').first()
|
||||
|
||||
# 3. Check limit (exclude cancelled, exclude current pending)
|
||||
query = activity.signups.exclude(status='cancelled')
|
||||
if pending_signup:
|
||||
query = query.exclude(id=pending_signup.id)
|
||||
|
||||
if query.count() >= activity.max_participants:
|
||||
return Response({'error': '活动名额已满'}, status=400)
|
||||
|
||||
# Get signup info
|
||||
signup_info = request.data.get('signup_info', {})
|
||||
|
||||
# Validate signup info
|
||||
effective_config = activity.signup_form_config
|
||||
if not effective_config:
|
||||
effective_config = []
|
||||
if activity.ask_name:
|
||||
effective_config.append({"name": "name", "label": "姓名", "type": "text", "required": True})
|
||||
if activity.ask_phone:
|
||||
effective_config.append({"name": "phone", "label": "手机号", "type": "number", "required": True})
|
||||
if activity.ask_wechat:
|
||||
effective_config.append({"name": "wechat", "label": "微信号", "type": "text", "required": True})
|
||||
if activity.ask_company:
|
||||
effective_config.append({"name": "company", "label": "公司/机构", "type": "text", "required": False})
|
||||
|
||||
if effective_config:
|
||||
required_fields = [f['name'] for f in effective_config if f.get('required')]
|
||||
for field in required_fields:
|
||||
val = signup_info.get(field)
|
||||
if val is None or (isinstance(val, str) and not val.strip()):
|
||||
label = next((f['label'] for f in effective_config if f['name'] == field), field)
|
||||
return Response({'error': f'请填写: {label}'}, status=400)
|
||||
|
||||
# Handle Payment Logic
|
||||
if activity.is_paid and activity.price > 0:
|
||||
import time
|
||||
from wechatpayv3 import WeChatPayType
|
||||
|
||||
# Create or Get Order
|
||||
order = None
|
||||
if pending_signup and pending_signup.order:
|
||||
# Reuse existing order if it's pending
|
||||
if pending_signup.order.status == 'pending':
|
||||
order = pending_signup.order
|
||||
# Update contact info if needed
|
||||
contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User'
|
||||
contact_phone = signup_info.get('phone') or user.phone_number or ''
|
||||
if contact_name: order.customer_name = contact_name
|
||||
if contact_phone: order.phone_number = contact_phone
|
||||
|
||||
# Ensure activity is linked
|
||||
if not order.activity:
|
||||
order.activity = activity
|
||||
|
||||
order.save()
|
||||
|
||||
if not order:
|
||||
# Check independent pending order
|
||||
pending_order = Order.objects.filter(
|
||||
wechat_user=user,
|
||||
activity=activity,
|
||||
status='pending'
|
||||
).first()
|
||||
|
||||
if pending_order:
|
||||
order = pending_order
|
||||
# Ensure shipping address is up-to-date
|
||||
order.shipping_address = activity.location or '线下活动'
|
||||
order.save()
|
||||
else:
|
||||
contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User'
|
||||
contact_phone = signup_info.get('phone') or user.phone_number or ''
|
||||
|
||||
order = Order.objects.create(
|
||||
wechat_user=user,
|
||||
activity=activity,
|
||||
total_price=activity.price,
|
||||
status='pending',
|
||||
quantity=1,
|
||||
customer_name=contact_name,
|
||||
phone_number=contact_phone,
|
||||
shipping_address=activity.location or '线下活动',
|
||||
)
|
||||
|
||||
# Generate Pay Code
|
||||
out_trade_no = f"ACT{activity.id}U{user.id}T{int(time.time())}"
|
||||
order.out_trade_no = out_trade_no
|
||||
order.save()
|
||||
|
||||
wxpay, error_msg = get_wechat_pay_client(pay_type=WeChatPayType.NATIVE)
|
||||
if not wxpay:
|
||||
return Response({'error': f'支付配置错误: {error_msg}'}, status=500)
|
||||
|
||||
code, message = wxpay.pay(
|
||||
description=f"报名活动: {activity.title}",
|
||||
out_trade_no=out_trade_no,
|
||||
amount={
|
||||
'total': int(activity.price * 100),
|
||||
'currency': 'CNY'
|
||||
},
|
||||
notify_url=wxpay._notify_url,
|
||||
attach=f'{{"type":"activity","activity_id":{activity.id}}}'
|
||||
)
|
||||
|
||||
import json
|
||||
result = json.loads(message)
|
||||
if code in range(200, 300):
|
||||
code_url = result.get('code_url')
|
||||
|
||||
if pending_signup:
|
||||
pending_signup.signup_info = signup_info
|
||||
pending_signup.order = order
|
||||
pending_signup.status = 'unpaid' # Explicitly set to unpaid
|
||||
pending_signup.save()
|
||||
else:
|
||||
ActivitySignup.objects.create(
|
||||
activity=activity,
|
||||
user=user,
|
||||
signup_info=signup_info,
|
||||
status='unpaid',
|
||||
order=order
|
||||
)
|
||||
|
||||
return Response({
|
||||
'payment_required': True,
|
||||
'code_url': code_url,
|
||||
'order_id': order.id,
|
||||
'price': activity.price,
|
||||
'message': '请完成支付'
|
||||
}, status=200)
|
||||
else:
|
||||
return Response({'error': '支付接口调用失败', 'detail': result}, status=500)
|
||||
|
||||
# Free Activity Signup
|
||||
# Check auto_confirm
|
||||
status_val = 'confirmed' if activity.auto_confirm else 'pending'
|
||||
|
||||
signup = ActivitySignup.objects.create(
|
||||
activity=activity,
|
||||
user=user,
|
||||
signup_info=signup_info,
|
||||
status=status_val
|
||||
)
|
||||
|
||||
# Send SMS for free activity signup (if confirmed)
|
||||
if status_val == 'confirmed':
|
||||
try:
|
||||
from shop.sms_utils import notify_user_activity_signup_success
|
||||
|
||||
# Mock an order object for the SMS template
|
||||
# The template expects: customer_name, wechat_user, phone_number
|
||||
class MockOrder:
|
||||
def __init__(self, user, signup_info):
|
||||
# Ensure we get the name and phone from signup_info first
|
||||
# signup_info keys might vary, let's try common ones
|
||||
self.customer_name = signup_info.get('name') or signup_info.get('username') or user.nickname or "用户"
|
||||
self.wechat_user = user
|
||||
self.phone_number = signup_info.get('phone') or signup_info.get('mobile') or user.phone_number or ""
|
||||
|
||||
mock_order = MockOrder(user, signup_info)
|
||||
|
||||
# Check if we have a valid phone number before sending
|
||||
if mock_order.phone_number:
|
||||
notify_user_activity_signup_success(mock_order, signup)
|
||||
else:
|
||||
print(f"Skipping SMS for signup {signup.id}: No phone number found")
|
||||
except Exception as e:
|
||||
print(f"发送免费活动报名短信失败: {str(e)}")
|
||||
|
||||
serializer = ActivitySignupSerializer(signup)
|
||||
return Response(serializer.data, status=201)
|
||||
|
||||
@extend_schema(summary="我的报名记录")
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_signups(self, request):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
signups = ActivitySignup.objects.filter(user=user).order_by('-signup_time')
|
||||
|
||||
# Sync payment status
|
||||
for signup in signups:
|
||||
signup.check_payment_status()
|
||||
|
||||
serializer = ActivitySignupSerializer(signups, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
class TopicViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
技术论坛帖子接口
|
||||
"""
|
||||
queryset = Topic.objects.all()
|
||||
serializer_class = TopicSerializer
|
||||
permission_classes = [IsAuthorOrReadOnly]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
|
||||
search_fields = ['title', 'content']
|
||||
filterset_fields = ['category', 'is_pinned']
|
||||
ordering_fields = ['created_at', 'view_count', 'order']
|
||||
ordering = ['-is_pinned', 'order', '-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
# 列表接口仅显示已发布的帖子
|
||||
if self.action == 'list':
|
||||
qs = qs.filter(status='published')
|
||||
return qs
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
# Auth check is done in create or permission, but here we need user for save
|
||||
if user:
|
||||
# 如果关联了系统用户(user字段不为空),则是管理员/内部人员,直接发布
|
||||
# 否则进入审核流程
|
||||
status = 'published' if user.user else 'pending'
|
||||
serializer.save(author=user, status=status)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def like(self, request, pk=None):
|
||||
obj = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
|
||||
if obj.likes.filter(id=user.id).exists():
|
||||
obj.likes.remove(user)
|
||||
liked = False
|
||||
else:
|
||||
obj.likes.add(user)
|
||||
liked = True
|
||||
|
||||
return Response({'liked': liked, 'count': obj.likes.count()})
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
instance.view_count += 1
|
||||
instance.save(update_fields=['view_count'])
|
||||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
class ReplyViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
帖子回复接口
|
||||
"""
|
||||
queryset = Reply.objects.all()
|
||||
serializer_class = ReplySerializer
|
||||
permission_classes = [IsAuthorOrReadOnly]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
if user:
|
||||
serializer.save(author=user)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def like(self, request, pk=None):
|
||||
obj = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
|
||||
if obj.likes.filter(id=user.id).exists():
|
||||
obj.likes.remove(user)
|
||||
liked = False
|
||||
else:
|
||||
obj.likes.add(user)
|
||||
liked = True
|
||||
|
||||
return Response({'liked': liked, 'count': obj.likes.count()})
|
||||
|
||||
import requests
|
||||
|
||||
class TopicMediaViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
论坛多媒体资源上传接口 (代理到外部OSS服务)
|
||||
"""
|
||||
permission_classes = [] # 内部鉴权
|
||||
parser_classes = [parsers.MultiPartParser, parsers.FormParser]
|
||||
|
||||
@extend_schema(summary="上传媒体文件 (返回URL用于Markdown)")
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
|
||||
file_obj = request.FILES.get('file')
|
||||
if not file_obj:
|
||||
return Response({'error': '未提供文件'}, status=400)
|
||||
|
||||
# 转发到外部 OSS 上传服务
|
||||
upload_url = "https://data.tangledup-ai.com/upload?folder=uploads%2Fmarket%2Fforum_image"
|
||||
files = {'file': (file_obj.name, file_obj, file_obj.content_type)}
|
||||
|
||||
try:
|
||||
# 这里的 headers 不需要 Content-Type,requests 会自动设置 multipart/form-data
|
||||
response = requests.post(upload_url, files=files, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('success'):
|
||||
# Create TopicMedia record
|
||||
media_type = 'image' if 'image' in file_obj.content_type else 'video'
|
||||
media_obj = TopicMedia.objects.create(
|
||||
file_url=data.get('file_url'),
|
||||
media_type=media_type,
|
||||
# topic will be associated later
|
||||
)
|
||||
|
||||
# 返回符合前端预期的格式
|
||||
return Response({
|
||||
'id': media_obj.id, # Return real DB ID
|
||||
'file': media_obj.file_url,
|
||||
'media_type': media_obj.media_type,
|
||||
'created_at': media_obj.created_at
|
||||
})
|
||||
else:
|
||||
return Response({'error': '外部服务上传失败', 'detail': data}, status=400)
|
||||
else:
|
||||
return Response({'error': f'上传服务响应错误: {response.status_code}', 'detail': response.text}, status=502)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=500)
|
||||
|
||||
class AnnouncementViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
社区公告接口
|
||||
"""
|
||||
queryset = Announcement.objects.all()
|
||||
serializer_class = AnnouncementSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
now = timezone.now()
|
||||
qs = Announcement.objects.filter(is_active=True)
|
||||
# Filter by start_time (if set, must be <= now)
|
||||
qs = qs.filter(models.Q(start_time__isnull=True) | models.Q(start_time__lte=now))
|
||||
# Filter by end_time (if set, must be >= now)
|
||||
qs = qs.filter(models.Q(end_time__isnull=True) | models.Q(end_time__gte=now))
|
||||
return qs.order_by('-is_pinned', '-priority', '-created_at')
|
||||
|
||||
class AdminPublishViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
管理员/API发布接口
|
||||
"""
|
||||
permission_classes = []
|
||||
authentication_classes = []
|
||||
|
||||
def check_api_key(self, request):
|
||||
key = request.headers.get('X-API-KEY') or request.query_params.get('apikey')
|
||||
if key != '123quant-speed':
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_admin_user_by_phone(self, phone):
|
||||
if not phone:
|
||||
return None
|
||||
# Find WeChatUser by phone
|
||||
user = WeChatUser.objects.filter(phone_number=phone).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Check if linked to a system user and has admin privileges (is_staff)
|
||||
if user.user and user.user.is_staff:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
@extend_schema(
|
||||
summary="API发布活动",
|
||||
request=AdminActivitySerializer,
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='apikey',
|
||||
description='API访问密钥',
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='phone_number',
|
||||
description='管理员手机号 (用于关联发布者)',
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY
|
||||
)
|
||||
]
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def publish_activity(self, request):
|
||||
if not self.check_api_key(request):
|
||||
return Response({'error': 'Invalid API Key'}, status=403)
|
||||
|
||||
phone = request.data.get('phone_number') or request.query_params.get('phone_number')
|
||||
user = self.get_admin_user_by_phone(phone)
|
||||
if not user:
|
||||
return Response({'error': 'Admin user not found with this phone number'}, status=404)
|
||||
|
||||
data = request.data.copy()
|
||||
serializer = AdminActivitySerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
activity = serializer.save(author=user)
|
||||
return Response(serializer.data, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
@extend_schema(
|
||||
summary="API发布帖子",
|
||||
request=AdminTopicSerializer,
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='apikey',
|
||||
description='API访问密钥',
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='phone_number',
|
||||
description='管理员手机号 (用于关联发布者)',
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY
|
||||
)
|
||||
]
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def publish_topic(self, request):
|
||||
if not self.check_api_key(request):
|
||||
return Response({'error': 'Invalid API Key'}, status=403)
|
||||
|
||||
phone = request.data.get('phone_number') or request.query_params.get('phone_number')
|
||||
user = self.get_admin_user_by_phone(phone)
|
||||
if not user:
|
||||
return Response({'error': 'Admin user not found with this phone number'}, status=404)
|
||||
|
||||
data = request.data.copy()
|
||||
serializer = AdminTopicSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
# Only set status to published if not provided, otherwise respect the input
|
||||
status = data.get('status', 'published')
|
||||
topic = serializer.save(author=user, status=status)
|
||||
return Response(serializer.data, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
Reference in New Issue
Block a user