diff --git a/backend/competition/admin.py b/backend/competition/admin.py index 8c38f3f..ed26af2 100644 --- a/backend/competition/admin.py +++ b/backend/competition/admin.py @@ -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 = "评语内容" diff --git a/backend/competition/migrations/0001_initial.py b/backend/competition/migrations/0001_initial.py new file mode 100644 index 0000000..f13ce84 --- /dev/null +++ b/backend/competition/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/backend/competition/models.py b/backend/competition/models.py index 71a8362..fa62f60 100644 --- a/backend/competition/models.py +++ b/backend/competition/models.py @@ -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}" diff --git a/backend/competition/serializers.py b/backend/competition/serializers.py new file mode 100644 index 0000000..6267974 --- /dev/null +++ b/backend/competition/serializers.py @@ -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'] diff --git a/backend/competition/urls.py b/backend/competition/urls.py new file mode 100644 index 0000000..9624464 --- /dev/null +++ b/backend/competition/urls.py @@ -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)), +] diff --git a/backend/competition/views.py b/backend/competition/views.py index 91ea44a..5a56a48 100644 --- a/backend/competition/views.py +++ b/backend/competition/views.py @@ -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) diff --git a/backend/config/urls.py b/backend/config/urls.py index a1073a1..6ca60f8 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -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'),