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)
|
||||
Reference in New Issue
Block a user