Files
market_page/backend/community/admin.py
jeremygan2021 de1e409447
All checks were successful
Deploy to Server / deploy (push) Successful in 1m53s
admin phone serch
2026-03-17 19:21:32 +08:00

375 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', '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="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', '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 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', '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')
@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', '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 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