This commit is contained in:
@@ -1,3 +1,69 @@
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin
|
||||
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
|
||||
|
||||
# Register your models here.
|
||||
class ScoreDimensionInline(admin.TabularInline):
|
||||
model = ScoreDimension
|
||||
extra = 1
|
||||
tab = True
|
||||
|
||||
class ProjectFileInline(admin.TabularInline):
|
||||
model = ProjectFile
|
||||
extra = 0
|
||||
tab = True
|
||||
|
||||
@admin.register(Competition)
|
||||
class CompetitionAdmin(ModelAdmin):
|
||||
list_display = ['title', 'status', 'start_time', 'end_time', 'is_active', 'created_at']
|
||||
list_filter = ['status', 'is_active']
|
||||
search_fields = ['title', 'description']
|
||||
inlines = [ScoreDimensionInline]
|
||||
|
||||
actions = ['make_published', 'make_ended']
|
||||
|
||||
def make_published(self, request, queryset):
|
||||
queryset.update(status='published')
|
||||
make_published.short_description = "发布选中比赛"
|
||||
|
||||
def make_ended(self, request, queryset):
|
||||
queryset.update(status='ended')
|
||||
make_ended.short_description = "结束选中比赛"
|
||||
|
||||
@admin.register(CompetitionEnrollment)
|
||||
class CompetitionEnrollmentAdmin(ModelAdmin):
|
||||
list_display = ['competition', 'user', 'role', 'status', 'created_at']
|
||||
list_filter = ['competition', 'role', 'status']
|
||||
search_fields = ['user__nickname', 'competition__title']
|
||||
actions = ['approve_enrollment', 'reject_enrollment']
|
||||
|
||||
def approve_enrollment(self, request, queryset):
|
||||
queryset.update(status='approved')
|
||||
approve_enrollment.short_description = "通过审核"
|
||||
|
||||
def reject_enrollment(self, request, queryset):
|
||||
queryset.update(status='rejected')
|
||||
reject_enrollment.short_description = "拒绝申请"
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(ModelAdmin):
|
||||
list_display = ['title', 'competition', 'contestant', 'status', 'final_score', 'created_at']
|
||||
list_filter = ['competition', 'status']
|
||||
search_fields = ['title', 'contestant__user__nickname']
|
||||
inlines = [ProjectFileInline]
|
||||
readonly_fields = ['final_score']
|
||||
|
||||
@admin.register(Score)
|
||||
class ScoreAdmin(ModelAdmin):
|
||||
list_display = ['project', 'judge', 'dimension', 'score', 'created_at']
|
||||
list_filter = ['project__competition', 'dimension']
|
||||
search_fields = ['project__title', 'judge__user__nickname']
|
||||
|
||||
@admin.register(Comment)
|
||||
class CommentAdmin(ModelAdmin):
|
||||
list_display = ['project', 'judge', 'content_preview', 'created_at']
|
||||
list_filter = ['project__competition']
|
||||
search_fields = ['project__title', 'judge__user__nickname', 'content']
|
||||
|
||||
def content_preview(self, obj):
|
||||
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
||||
content_preview.short_description = "评语内容"
|
||||
|
||||
141
backend/competition/migrations/0001_initial.py
Normal file
141
backend/competition/migrations/0001_initial.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-10 02:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('shop', '0039_vccourse_video_embed_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Competition',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='比赛名称')),
|
||||
('description', models.TextField(verbose_name='比赛简介')),
|
||||
('rule_description', models.TextField(verbose_name='规则说明')),
|
||||
('condition_description', models.TextField(blank=True, verbose_name='参赛条件说明')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='competitions/covers/', verbose_name='封面图')),
|
||||
('start_time', models.DateTimeField(verbose_name='开始时间')),
|
||||
('end_time', models.DateTimeField(verbose_name='结束时间')),
|
||||
('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布'), ('registration', '报名中'), ('submission', '作品提交中'), ('judging', '评审中'), ('ended', '已结束')], default='draft', max_length=20, verbose_name='状态')),
|
||||
('is_active', models.BooleanField(default=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': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CompetitionEnrollment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('contestant', '选手'), ('judge', '评委'), ('guest', '嘉宾')], default='contestant', max_length=20, verbose_name='角色')),
|
||||
('status', models.CharField(choices=[('pending', '待审核'), ('approved', '已通过'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='competition.competition', verbose_name='所属比赛')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competitions', to='shop.wechatuser', verbose_name='用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '比赛人员',
|
||||
'verbose_name_plural': '人员管理',
|
||||
'unique_together': {('competition', 'user')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Project',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='项目名称')),
|
||||
('description', models.TextField(verbose_name='项目介绍')),
|
||||
('team_info', models.TextField(blank=True, verbose_name='团队介绍')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='competitions/projects/covers/', verbose_name='项目封面')),
|
||||
('status', models.CharField(choices=[('draft', '草稿'), ('submitted', '已提交')], default='draft', max_length=20, verbose_name='状态')),
|
||||
('final_score', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='最终得分')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='competition.competition', verbose_name='所属比赛')),
|
||||
('contestant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='competition.competitionenrollment', verbose_name='参赛选手')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '参赛项目',
|
||||
'verbose_name_plural': '项目管理',
|
||||
'ordering': ['-final_score', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Comment',
|
||||
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='评论时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_comments', to='competition.competitionenrollment', verbose_name='评委')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='competition.project', verbose_name='所属项目')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '评委评语',
|
||||
'verbose_name_plural': '评语管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file_type', models.CharField(choices=[('ppt', 'PPT演示文稿'), ('pdf', 'PDF文档'), ('image', '图片'), ('video', '视频'), ('doc', '文档'), ('other', '其他')], default='other', max_length=20, verbose_name='文件类型')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to='competitions/projects/files/', verbose_name='文件')),
|
||||
('file_url', models.URLField(blank=True, help_text='视频等大文件建议使用外部链接', null=True, verbose_name='文件链接')),
|
||||
('name', models.CharField(blank=True, max_length=100, verbose_name='文件名称')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='competition.project', verbose_name='所属项目')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '项目附件',
|
||||
'verbose_name_plural': '附件管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScoreDimension',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='维度名称')),
|
||||
('description', models.TextField(blank=True, verbose_name='维度说明')),
|
||||
('weight', models.DecimalField(decimal_places=2, default=1.0, help_text='例如 0.3 表示 30%', max_digits=5, verbose_name='权重')),
|
||||
('max_score', models.IntegerField(default=100, verbose_name='满分值')),
|
||||
('order', models.IntegerField(default=0, verbose_name='排序权重')),
|
||||
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_dimensions', to='competition.competition', verbose_name='所属比赛')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '评分维度',
|
||||
'verbose_name_plural': '评分维度配置',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Score',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('score', models.DecimalField(decimal_places=1, max_digits=5, verbose_name='得分')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='打分时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_scores', to='competition.competitionenrollment', verbose_name='评委')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scores', to='competition.project', verbose_name='所属项目')),
|
||||
('dimension', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.scoredimension', verbose_name='评分维度')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '评分记录',
|
||||
'verbose_name_plural': '评分记录',
|
||||
'unique_together': {('project', 'judge', 'dimension')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,253 @@
|
||||
from django.db import models
|
||||
from shop.models import WeChatUser
|
||||
|
||||
# Create your models here.
|
||||
class Competition(models.Model):
|
||||
"""
|
||||
比赛管理模型
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('draft', '草稿'),
|
||||
('published', '已发布'),
|
||||
('registration', '报名中'),
|
||||
('submission', '作品提交中'),
|
||||
('judging', '评审中'),
|
||||
('ended', '已结束'),
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200, verbose_name="比赛名称")
|
||||
description = models.TextField(verbose_name="比赛简介")
|
||||
rule_description = models.TextField(verbose_name="规则说明")
|
||||
condition_description = models.TextField(verbose_name="参赛条件说明", blank=True)
|
||||
|
||||
cover_image = models.ImageField(upload_to='competitions/covers/', verbose_name="封面图", null=True, blank=True)
|
||||
|
||||
start_time = models.DateTimeField(verbose_name="开始时间")
|
||||
end_time = models.DateTimeField(verbose_name="结束时间")
|
||||
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
|
||||
|
||||
is_active = models.BooleanField(default=True, 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
|
||||
|
||||
class Meta:
|
||||
verbose_name = "比赛"
|
||||
verbose_name_plural = "比赛管理"
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
class CompetitionEnrollment(models.Model):
|
||||
"""
|
||||
比赛人员报名/角色分配
|
||||
"""
|
||||
ROLE_CHOICES = (
|
||||
('contestant', '选手'),
|
||||
('judge', '评委'),
|
||||
('guest', '嘉宾'),
|
||||
)
|
||||
|
||||
STATUS_CHOICES = (
|
||||
('pending', '待审核'),
|
||||
('approved', '已通过'),
|
||||
('rejected', '已拒绝'),
|
||||
)
|
||||
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='enrollments', verbose_name="所属比赛")
|
||||
user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='competitions', verbose_name="用户")
|
||||
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='contestant', verbose_name="角色")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "比赛人员"
|
||||
verbose_name_plural = "人员管理"
|
||||
unique_together = ('competition', 'user')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.competition.title} - {self.user.nickname} ({self.get_role_display()})"
|
||||
|
||||
|
||||
class ScoreDimension(models.Model):
|
||||
"""
|
||||
评分维度配置
|
||||
"""
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='score_dimensions', verbose_name="所属比赛")
|
||||
name = models.CharField(max_length=100, verbose_name="维度名称")
|
||||
description = models.TextField(verbose_name="维度说明", blank=True)
|
||||
weight = models.DecimalField(max_digits=5, decimal_places=2, default=1.00, verbose_name="权重", help_text="例如 0.3 表示 30%")
|
||||
max_score = models.IntegerField(default=100, verbose_name="满分值")
|
||||
|
||||
order = models.IntegerField(default=0, verbose_name="排序权重")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "评分维度"
|
||||
verbose_name_plural = "评分维度配置"
|
||||
ordering = ['order']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.competition.title} - {self.name}"
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
"""
|
||||
参赛项目/作品
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('draft', '草稿'),
|
||||
('submitted', '已提交'),
|
||||
)
|
||||
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='projects', verbose_name="所属比赛")
|
||||
contestant = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='projects', verbose_name="参赛选手")
|
||||
|
||||
title = models.CharField(max_length=200, verbose_name="项目名称")
|
||||
description = models.TextField(verbose_name="项目介绍")
|
||||
team_info = models.TextField(verbose_name="团队介绍", blank=True)
|
||||
|
||||
cover_image = models.ImageField(upload_to='competitions/projects/covers/', verbose_name="项目封面", null=True, blank=True)
|
||||
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
|
||||
|
||||
# 最终得分缓存,避免每次实时计算
|
||||
final_score = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name="最终得分")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "参赛项目"
|
||||
verbose_name_plural = "项目管理"
|
||||
ordering = ['-final_score', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def calculate_score(self):
|
||||
"""
|
||||
计算项目得分
|
||||
计算公式:
|
||||
1. 获取所有评委对该项目的打分
|
||||
2. 按维度加权平均
|
||||
这里简化处理:
|
||||
总分 = (所有评委的总加权分之和) / 评委人数
|
||||
其中每个评委对项目的打分 = sum(维度分 * 维度权重)
|
||||
"""
|
||||
# 获取所有评分
|
||||
scores = self.scores.all()
|
||||
if not scores.exists():
|
||||
return 0
|
||||
|
||||
# 找出所有参与评分的评委
|
||||
judges = set(score.judge for score in scores)
|
||||
if not judges:
|
||||
return 0
|
||||
|
||||
total_weighted_score = 0
|
||||
|
||||
for judge in judges:
|
||||
judge_score = 0
|
||||
# 获取该评委对该项目的所有维度打分
|
||||
judge_scores = scores.filter(judge=judge)
|
||||
|
||||
current_judge_total_score = 0
|
||||
current_judge_total_weight = 0
|
||||
|
||||
for score in judge_scores:
|
||||
current_judge_total_score += score.score * score.dimension.weight
|
||||
current_judge_total_weight += score.dimension.weight
|
||||
|
||||
if current_judge_total_weight > 0:
|
||||
judge_score = current_judge_total_score / current_judge_total_weight
|
||||
# 如果是百分制,这里算出来就是0-100
|
||||
|
||||
total_weighted_score += judge_score
|
||||
|
||||
# 平均分
|
||||
avg_score = total_weighted_score / len(judges)
|
||||
self.final_score = avg_score
|
||||
self.save()
|
||||
return avg_score
|
||||
|
||||
|
||||
class ProjectFile(models.Model):
|
||||
"""
|
||||
项目附件
|
||||
"""
|
||||
FILE_TYPE_CHOICES = (
|
||||
('ppt', 'PPT演示文稿'),
|
||||
('pdf', 'PDF文档'),
|
||||
('image', '图片'),
|
||||
('video', '视频'),
|
||||
('doc', '文档'),
|
||||
('other', '其他'),
|
||||
)
|
||||
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='files', verbose_name="所属项目")
|
||||
file_type = models.CharField(max_length=20, choices=FILE_TYPE_CHOICES, default='other', verbose_name="文件类型")
|
||||
|
||||
file = models.FileField(upload_to='competitions/projects/files/', verbose_name="文件", null=True, blank=True)
|
||||
file_url = models.URLField(verbose_name="文件链接", null=True, blank=True, help_text="视频等大文件建议使用外部链接")
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name="文件名称", blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "项目附件"
|
||||
verbose_name_plural = "附件管理"
|
||||
|
||||
def __str__(self):
|
||||
return self.name or f"{self.get_file_type_display()}"
|
||||
|
||||
|
||||
class Score(models.Model):
|
||||
"""
|
||||
评委打分
|
||||
"""
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='scores', verbose_name="所属项目")
|
||||
judge = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='given_scores', verbose_name="评委")
|
||||
dimension = models.ForeignKey(ScoreDimension, on_delete=models.CASCADE, verbose_name="评分维度")
|
||||
|
||||
score = models.DecimalField(max_digits=5, decimal_places=1, verbose_name="得分")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="打分时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "评分记录"
|
||||
verbose_name_plural = "评分记录"
|
||||
unique_together = ('project', 'judge', 'dimension')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.judge.user.nickname} -> {self.project.title}: {self.score}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
# 触发重新计算分数
|
||||
self.project.calculate_score()
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
"""
|
||||
评委评语
|
||||
"""
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments', verbose_name="所属项目")
|
||||
judge = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='given_comments', verbose_name="评委")
|
||||
|
||||
content = models.TextField(verbose_name="评语内容")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="评论时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "评委评语"
|
||||
verbose_name_plural = "评语管理"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.judge.user.nickname} -> {self.project.title}"
|
||||
|
||||
72
backend/competition/serializers.py
Normal file
72
backend/competition/serializers.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
|
||||
from shop.serializers import WeChatUserSerializer
|
||||
|
||||
class ScoreDimensionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ScoreDimension
|
||||
fields = ['id', 'name', 'description', 'weight', 'max_score', 'order']
|
||||
|
||||
class CompetitionSerializer(serializers.ModelSerializer):
|
||||
score_dimensions = ScoreDimensionSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Competition
|
||||
fields = ['id', 'title', 'description', 'rule_description', 'condition_description',
|
||||
'cover_image', 'start_time', 'end_time', 'status', 'is_active',
|
||||
'score_dimensions', 'created_at']
|
||||
|
||||
class CompetitionEnrollmentSerializer(serializers.ModelSerializer):
|
||||
user = WeChatUserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CompetitionEnrollment
|
||||
fields = ['id', 'competition', 'user', 'role', 'status', 'created_at']
|
||||
read_only_fields = ['status']
|
||||
|
||||
class ProjectFileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProjectFile
|
||||
fields = ['id', 'project', 'file_type', 'file', 'file_url', 'name', 'created_at']
|
||||
|
||||
def validate_file(self, value):
|
||||
if not value:
|
||||
return value
|
||||
# 50MB limit
|
||||
limit_mb = 50
|
||||
if value.size > limit_mb * 1024 * 1024:
|
||||
raise serializers.ValidationError(f"文件大小不能超过 {limit_mb}MB")
|
||||
return value
|
||||
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
files = ProjectFileSerializer(many=True, read_only=True)
|
||||
contestant_info = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ['id', 'competition', 'contestant', 'title', 'description', 'team_info',
|
||||
'cover_image', 'status', 'final_score', 'files', 'contestant_info', 'created_at']
|
||||
read_only_fields = ['final_score', 'contestant']
|
||||
|
||||
def get_contestant_info(self, obj):
|
||||
return {
|
||||
"nickname": obj.contestant.user.nickname,
|
||||
"avatar_url": obj.contestant.user.avatar_url
|
||||
}
|
||||
|
||||
class ScoreSerializer(serializers.ModelSerializer):
|
||||
judge_name = serializers.CharField(source='judge.user.nickname', read_only=True)
|
||||
dimension_name = serializers.CharField(source='dimension.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Score
|
||||
fields = ['id', 'project', 'judge', 'dimension', 'score', 'judge_name', 'dimension_name', 'created_at']
|
||||
read_only_fields = ['judge']
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
judge_name = serializers.CharField(source='judge.user.nickname', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ['id', 'project', 'judge', 'content', 'judge_name', 'created_at']
|
||||
read_only_fields = ['judge']
|
||||
17
backend/competition/urls.py
Normal file
17
backend/competition/urls.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import (
|
||||
CompetitionViewSet, ProjectViewSet, ProjectFileViewSet,
|
||||
ScoreViewSet, CommentViewSet
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'competitions', CompetitionViewSet)
|
||||
router.register(r'projects', ProjectViewSet, basename='project')
|
||||
router.register(r'files', ProjectFileViewSet, basename='projectfile')
|
||||
router.register(r'scores', ScoreViewSet, basename='score')
|
||||
router.register(r'comments', CommentViewSet, basename='comment')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
@@ -1,3 +1,232 @@
|
||||
from django.shortcuts import render
|
||||
from rest_framework import viewsets, permissions, status, filters
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from shop.utils import get_current_wechat_user
|
||||
from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension
|
||||
from .serializers import (
|
||||
CompetitionSerializer, CompetitionEnrollmentSerializer,
|
||||
ProjectSerializer, ProjectFileSerializer,
|
||||
ScoreSerializer, CommentSerializer, ScoreDimensionSerializer
|
||||
)
|
||||
|
||||
# Create your views here.
|
||||
class CompetitionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
比赛视图集
|
||||
"""
|
||||
queryset = Competition.objects.filter(is_active=True).order_by('-created_at')
|
||||
serializer_class = CompetitionSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
filter_backends = [filters.SearchFilter]
|
||||
search_fields = ['title', 'description']
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def enroll(self, request, pk=None):
|
||||
"""
|
||||
报名参加比赛
|
||||
"""
|
||||
competition = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({"detail": "未登录"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
role = request.data.get('role', 'contestant')
|
||||
|
||||
# 检查是否已报名
|
||||
if CompetitionEnrollment.objects.filter(competition=competition, user=user).exists():
|
||||
return Response({"detail": "您已报名该比赛"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
enrollment = CompetitionEnrollment.objects.create(
|
||||
competition=competition,
|
||||
user=user,
|
||||
role=role,
|
||||
status='pending' # 默认待审核
|
||||
)
|
||||
|
||||
return Response(CompetitionEnrollmentSerializer(enrollment).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def my_enrollment(self, request, pk=None):
|
||||
"""
|
||||
获取我的报名信息
|
||||
"""
|
||||
competition = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({"detail": "未登录"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
try:
|
||||
enrollment = CompetitionEnrollment.objects.get(competition=competition, user=user)
|
||||
return Response(CompetitionEnrollmentSerializer(enrollment).data)
|
||||
except CompetitionEnrollment.DoesNotExist:
|
||||
return Response({"detail": "未报名"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class ProjectViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
参赛项目视图集
|
||||
"""
|
||||
serializer_class = ProjectSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Project.objects.all()
|
||||
competition_id = self.request.query_params.get('competition')
|
||||
if competition_id:
|
||||
queryset = queryset.filter(competition_id=competition_id)
|
||||
|
||||
# 如果是普通用户,只能看到已提交的项目,或者自己草稿的项目
|
||||
user = get_current_wechat_user(self.request)
|
||||
if user:
|
||||
# 查找用户在这个比赛中的角色
|
||||
# 如果是评委,可以看到所有项目(包括草稿吗?通常评委只看提交的)
|
||||
# 这里简化:评委看所有submitted,用户看所有submitted + 自己的draft
|
||||
|
||||
# 找到用户参与的所有比赛角色
|
||||
enrollments = CompetitionEnrollment.objects.filter(user=user)
|
||||
judge_competitions = enrollments.filter(role='judge').values_list('competition_id', flat=True)
|
||||
|
||||
# 基本查询:所有已提交的项目
|
||||
q = Q(status='submitted')
|
||||
|
||||
# 加上自己创建的项目 (即使是draft)
|
||||
q |= Q(contestant__user=user)
|
||||
|
||||
# 加上自己是评委的比赛的所有项目 (通常评委只看submitted,但如果需要预审可以看draft,这里假设只看submitted)
|
||||
# q |= Q(competition__in=judge_competitions)
|
||||
|
||||
queryset = queryset.filter(q)
|
||||
else:
|
||||
# 未登录用户只能看已提交
|
||||
queryset = queryset.filter(status='submitted')
|
||||
|
||||
return queryset.order_by('-final_score', '-created_at')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
if not user:
|
||||
raise serializers.ValidationError("请先登录")
|
||||
|
||||
competition = serializer.validated_data['competition']
|
||||
|
||||
# 检查是否有参赛资格
|
||||
try:
|
||||
enrollment = CompetitionEnrollment.objects.get(
|
||||
competition=competition,
|
||||
user=user,
|
||||
role='contestant',
|
||||
status='approved'
|
||||
)
|
||||
except CompetitionEnrollment.DoesNotExist:
|
||||
raise serializers.ValidationError("您没有参赛资格或审核未通过")
|
||||
|
||||
serializer.save(contestant=enrollment)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def submit(self, request, pk=None):
|
||||
"""
|
||||
提交项目(从草稿转为已提交)
|
||||
"""
|
||||
project = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
|
||||
if project.contestant.user != user:
|
||||
return Response({"detail": "无权操作"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
project.status = 'submitted'
|
||||
project.save()
|
||||
return Response({"status": "submitted"})
|
||||
|
||||
|
||||
class ProjectFileViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
项目附件管理
|
||||
"""
|
||||
serializer_class = ProjectFileSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
return ProjectFile.objects.all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# 简单权限控制:只有项目拥有者可以上传
|
||||
project = serializer.validated_data['project']
|
||||
user = get_current_wechat_user(self.request)
|
||||
|
||||
if not user or project.contestant.user != user:
|
||||
raise serializers.ValidationError("无权上传文件")
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
class ScoreViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
评分管理
|
||||
"""
|
||||
serializer_class = ScoreSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
project_id = self.request.query_params.get('project')
|
||||
if project_id:
|
||||
return Score.objects.filter(project_id=project_id)
|
||||
return Score.objects.all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
if not user:
|
||||
raise serializers.ValidationError("请先登录")
|
||||
|
||||
project = serializer.validated_data['project']
|
||||
|
||||
# 检查是否是评委
|
||||
try:
|
||||
enrollment = CompetitionEnrollment.objects.get(
|
||||
competition=project.competition,
|
||||
user=user,
|
||||
role='judge',
|
||||
status='approved'
|
||||
)
|
||||
except CompetitionEnrollment.DoesNotExist:
|
||||
raise serializers.ValidationError("您不是该比赛的评委")
|
||||
|
||||
# 检查是否重复打分
|
||||
dimension = serializer.validated_data['dimension']
|
||||
if Score.objects.filter(project=project, judge=enrollment, dimension=dimension).exists():
|
||||
raise serializers.ValidationError("您已对该维度打分")
|
||||
|
||||
serializer.save(judge=enrollment)
|
||||
|
||||
|
||||
class CommentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
评语管理
|
||||
"""
|
||||
serializer_class = CommentSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
project_id = self.request.query_params.get('project')
|
||||
if project_id:
|
||||
return Comment.objects.filter(project_id=project_id)
|
||||
return Comment.objects.all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
if not user:
|
||||
raise serializers.ValidationError("请先登录")
|
||||
|
||||
project = serializer.validated_data['project']
|
||||
|
||||
# 检查是否是评委
|
||||
try:
|
||||
enrollment = CompetitionEnrollment.objects.get(
|
||||
competition=project.competition,
|
||||
user=user,
|
||||
role='judge',
|
||||
status='approved'
|
||||
)
|
||||
except CompetitionEnrollment.DoesNotExist:
|
||||
raise serializers.ValidationError("您不是该比赛的评委")
|
||||
|
||||
serializer.save(judge=enrollment)
|
||||
|
||||
@@ -8,6 +8,7 @@ urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('shop.urls')),
|
||||
path('api/community/', include('community.urls')),
|
||||
path('api/competition/', include('competition.urls')),
|
||||
|
||||
# Swagger文档路由
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
|
||||
Reference in New Issue
Block a user