This commit is contained in:
@@ -1,3 +1,69 @@
|
|||||||
from django.contrib import admin
|
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 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('admin/', admin.site.urls),
|
||||||
path('api/', include('shop.urls')),
|
path('api/', include('shop.urls')),
|
||||||
path('api/community/', include('community.urls')),
|
path('api/community/', include('community.urls')),
|
||||||
|
path('api/competition/', include('competition.urls')),
|
||||||
|
|
||||||
# Swagger文档路由
|
# Swagger文档路由
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
|||||||
Reference in New Issue
Block a user