community
This commit is contained in:
0
backend/community/__init__.py
Normal file
0
backend/community/__init__.py
Normal file
160
backend/community/admin.py
Normal file
160
backend/community/admin.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from unfold.admin import ModelAdmin, TabularInline
|
||||||
|
from unfold.decorators import display
|
||||||
|
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia
|
||||||
|
|
||||||
|
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', 'media_type', 'created_at')
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
can_delete = True
|
||||||
|
|
||||||
|
@admin.register(Activity)
|
||||||
|
class ActivityAdmin(ModelAdmin):
|
||||||
|
list_display = ('title', 'banner_display', 'start_time', 'location', 'signup_count', 'is_active', 'created_at')
|
||||||
|
list_filter = ('is_active', 'start_time')
|
||||||
|
search_fields = ('title', 'location')
|
||||||
|
inlines = [ActivitySignupInline]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('基本信息', {
|
||||||
|
'fields': ('title', 'description', 'banner', 'is_active')
|
||||||
|
}),
|
||||||
|
('时间与地点', {
|
||||||
|
'fields': ('start_time', 'end_time', 'location'),
|
||||||
|
'classes': ('tab',)
|
||||||
|
}),
|
||||||
|
('报名设置', {
|
||||||
|
'fields': ('max_participants',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
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')
|
||||||
|
list_filter = ('status', 'signup_time', 'activity')
|
||||||
|
search_fields = ('user__nickname', 'activity__title')
|
||||||
|
autocomplete_fields = ['activity', 'user']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('报名详情', {
|
||||||
|
'fields': ('activity', 'user', 'status')
|
||||||
|
}),
|
||||||
|
('时间信息', {
|
||||||
|
'fields': ('signup_time',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
readonly_fields = ('signup_time',)
|
||||||
|
|
||||||
|
@display(
|
||||||
|
description="状态",
|
||||||
|
label={
|
||||||
|
"pending": "warning",
|
||||||
|
"confirmed": "success",
|
||||||
|
"cancelled": "danger",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def status_label(self, obj):
|
||||||
|
return obj.status
|
||||||
|
|
||||||
|
@admin.register(Topic)
|
||||||
|
class TopicAdmin(ModelAdmin):
|
||||||
|
list_display = ('title', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at')
|
||||||
|
list_filter = ('is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
|
||||||
|
search_fields = ('title', 'content', 'author__nickname')
|
||||||
|
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
|
||||||
|
inlines = [TopicMediaInline, ReplyInline]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('帖子内容', {
|
||||||
|
'fields': ('title', 'content', 'is_pinned')
|
||||||
|
}),
|
||||||
|
('关联信息', {
|
||||||
|
'fields': ('author', 'related_product', 'related_service', 'related_course'),
|
||||||
|
'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论'
|
||||||
|
}),
|
||||||
|
('统计数据', {
|
||||||
|
'fields': ('view_count', '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', 'created_at')
|
||||||
|
list_filter = ('created_at',)
|
||||||
|
search_fields = ('content', 'author__nickname', 'topic__title')
|
||||||
|
autocomplete_fields = ['author', 'topic', 'reply_to']
|
||||||
|
inlines = [TopicMediaInline]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('回复内容', {
|
||||||
|
'fields': ('topic', 'reply_to', 'content')
|
||||||
|
}),
|
||||||
|
('发布信息', {
|
||||||
|
'fields': ('author', 'created_at')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
|
||||||
|
@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):
|
||||||
|
if obj.media_type == 'image':
|
||||||
|
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.file.url)
|
||||||
|
return obj.file.name
|
||||||
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'
|
||||||
85
backend/community/migrations/0001_initial.py
Normal file
85
backend/community/migrations/0001_initial.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-11 06:43
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shop', '0025_vccourse_alter_courseenrollment_course_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
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(upload_to='activities/banners/', 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_active', models.BooleanField(default=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='标题')),
|
||||||
|
('content', models.TextField(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='更新时间')),
|
||||||
|
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='topics', to='shop.wechatuser', verbose_name='作者')),
|
||||||
|
('related_product', models.ForeignKey(blank=True, help_text='如果是技术求助,请选择关联的硬件', null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '论坛帖子',
|
||||||
|
'verbose_name_plural': '论坛帖子管理',
|
||||||
|
'ordering': ['-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(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='回复者')),
|
||||||
|
('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': ['created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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='报名时间')),
|
||||||
|
('status', models.CharField(choices=[('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='活动')),
|
||||||
|
('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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-11 06:48
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('community', '0001_initial'),
|
||||||
|
('shop', '0025_vccourse_alter_courseenrollment_course_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='topic',
|
||||||
|
name='related_course',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.vccourse', verbose_name='关联课程'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='topic',
|
||||||
|
name='related_service',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.service', verbose_name='关联服务'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='topic',
|
||||||
|
name='related_product',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-11 06:57
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('community', '0002_topic_related_course_topic_related_service_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='reply',
|
||||||
|
name='content',
|
||||||
|
field=models.TextField(help_text='支持Markdown格式', verbose_name='回复内容'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='topic',
|
||||||
|
name='content',
|
||||||
|
field=models.TextField(help_text='支持Markdown格式,支持插入图片', verbose_name='内容'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TopicMedia',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file', models.FileField(upload_to='community/media/', 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': '论坛媒体资源管理',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/community/migrations/__init__.py
Normal file
0
backend/community/migrations/__init__.py
Normal file
155
backend/community/models.py
Normal file
155
backend/community/models.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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图")
|
||||||
|
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_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "社区活动"
|
||||||
|
verbose_name_plural = "社区活动管理"
|
||||||
|
|
||||||
|
|
||||||
|
class ActivitySignup(models.Model):
|
||||||
|
"""
|
||||||
|
活动报名记录
|
||||||
|
"""
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('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="报名时间")
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.nickname} - {self.activity.title}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "活动报名"
|
||||||
|
verbose_name_plural = "活动报名管理"
|
||||||
|
unique_together = ('activity', 'user')
|
||||||
|
|
||||||
|
|
||||||
|
class Topic(models.Model):
|
||||||
|
"""
|
||||||
|
论坛帖子/主题
|
||||||
|
"""
|
||||||
|
title = models.CharField(max_length=200, 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="浏览量")
|
||||||
|
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="更新时间")
|
||||||
|
|
||||||
|
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:
|
||||||
|
# ServiceOrder 模型中没有 direct link to WeChatUser (only phone/name),
|
||||||
|
# 但我们假设通过手机号或未来关联来验证,目前先检查 ServiceOrder 是否有对应记录。
|
||||||
|
# 由于 ServiceOrder 目前设计没有直接关联 WeChatUser 字段,我们暂时尝试通过名字或后续改进。
|
||||||
|
# 经检查 shop/models.py, ServiceOrder 确实只有 customer_name/phone_number.
|
||||||
|
# 这里为了严谨,我们暂时仅对有关联的进行检查,或者需要改进 ServiceOrder。
|
||||||
|
# 鉴于当前任务范围,如果 ServiceOrder 没有 user 字段,我们可能无法精确验证,
|
||||||
|
# 除非我们假设用户填写的手机号与微信用户关联。
|
||||||
|
# *修正*: 为了快速实现,我们先跳过 ServiceOrder 的精确验证,或者仅仅返回 False,
|
||||||
|
# 等待后续 ServiceOrder 添加 wechat_user 字段。
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "论坛帖子"
|
||||||
|
verbose_name_plural = "论坛帖子管理"
|
||||||
|
ordering = ['-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="回复楼层")
|
||||||
|
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 = ['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="文件")
|
||||||
|
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 = "论坛媒体资源管理"
|
||||||
49
backend/community/serializers.py
Normal file
49
backend/community/serializers.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia
|
||||||
|
from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer
|
||||||
|
|
||||||
|
class ActivitySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Activity
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
class ActivitySignupSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ActivitySignup
|
||||||
|
fields = ['id', 'activity', 'user', 'signup_time', 'status']
|
||||||
|
read_only_fields = ['signup_time', 'status']
|
||||||
|
|
||||||
|
class TopicMediaSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = TopicMedia
|
||||||
|
fields = ['id', 'file', 'media_type', 'created_at']
|
||||||
|
|
||||||
|
class ReplySerializer(serializers.ModelSerializer):
|
||||||
|
author_info = WeChatUserSerializer(source='author', read_only=True)
|
||||||
|
media = TopicMediaSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Reply
|
||||||
|
fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at']
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Topic
|
||||||
|
fields = [
|
||||||
|
'id', 'title', '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'
|
||||||
|
]
|
||||||
|
read_only_fields = ['view_count', 'created_at', 'updated_at', '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.
|
||||||
13
backend/community/urls.py
Normal file
13
backend/community/urls.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'activities', ActivityViewSet)
|
||||||
|
router.register(r'topics', TopicViewSet)
|
||||||
|
router.register(r'replies', ReplyViewSet)
|
||||||
|
router.register(r'media', TopicMediaViewSet)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
118
backend/community/views.py
Normal file
118
backend/community/views.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
from rest_framework import viewsets, status, mixins, parsers
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
|
||||||
|
from shop.models import WeChatUser
|
||||||
|
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia
|
||||||
|
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer
|
||||||
|
|
||||||
|
def get_current_wechat_user(request):
|
||||||
|
"""
|
||||||
|
根据 Authorization 头获取当前微信用户 (复用 shop app 的逻辑)
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
if not auth_header or not auth_header.startswith('Bearer '):
|
||||||
|
return None
|
||||||
|
token = auth_header.split(' ')[1]
|
||||||
|
signer = TimestampSigner()
|
||||||
|
try:
|
||||||
|
# 签名包含 openid
|
||||||
|
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
|
||||||
|
return WeChatUser.objects.filter(openid=openid).first()
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
社区活动接口
|
||||||
|
"""
|
||||||
|
queryset = Activity.objects.filter(is_active=True).order_by('-created_at')
|
||||||
|
serializer_class = ActivitySerializer
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
# Check if already signed up
|
||||||
|
if ActivitySignup.objects.filter(activity=activity, user=user).exists():
|
||||||
|
return Response({'error': '您已报名该活动'}, status=400)
|
||||||
|
|
||||||
|
if activity.signups.count() >= activity.max_participants:
|
||||||
|
return Response({'error': '活动名额已满'}, status=400)
|
||||||
|
|
||||||
|
signup = ActivitySignup.objects.create(activity=activity, user=user)
|
||||||
|
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')
|
||||||
|
serializer = ActivitySignupSerializer(signups, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
class TopicViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
技术论坛帖子接口
|
||||||
|
"""
|
||||||
|
queryset = Topic.objects.all()
|
||||||
|
serializer_class = TopicSerializer
|
||||||
|
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
class ReplyViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
帖子回复接口
|
||||||
|
"""
|
||||||
|
queryset = Reply.objects.all()
|
||||||
|
serializer_class = ReplySerializer
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
class TopicMediaViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin):
|
||||||
|
"""
|
||||||
|
论坛多媒体资源上传接口
|
||||||
|
"""
|
||||||
|
queryset = TopicMedia.objects.all()
|
||||||
|
serializer_class = TopicMediaSerializer
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 允许上传时不关联 Topic (发帖前上传),或后续关联
|
||||||
|
# 主要是返回 url
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
Binary file not shown.
Binary file not shown.
@@ -44,6 +44,7 @@ INSTALLED_APPS = [
|
|||||||
'drf_spectacular', # Swagger文档生成
|
'drf_spectacular', # Swagger文档生成
|
||||||
'drf_spectacular_sidecar',
|
'drf_spectacular_sidecar',
|
||||||
'shop',
|
'shop',
|
||||||
|
'community',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -264,6 +265,32 @@ UNFOLD = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "社区与论坛",
|
||||||
|
"separator": True,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "活动管理",
|
||||||
|
"icon": "event",
|
||||||
|
"link": reverse_lazy("admin:community_activity_changelist"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "活动报名",
|
||||||
|
"icon": "how_to_reg",
|
||||||
|
"link": reverse_lazy("admin:community_activitysignup_changelist"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "技术论坛帖子",
|
||||||
|
"icon": "forum",
|
||||||
|
"link": reverse_lazy("admin:community_topic_changelist"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "帖子回复",
|
||||||
|
"icon": "chat_bubble",
|
||||||
|
"link": reverse_lazy("admin:community_reply_changelist"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "系统配置",
|
"title": "系统配置",
|
||||||
"separator": True,
|
"separator": True,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, Sp
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('api/', include('shop.urls')),
|
path('api/', include('shop.urls')),
|
||||||
|
path('api/community/', include('community.urls')),
|
||||||
|
|
||||||
# Swagger文档路由
|
# Swagger文档路由
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user