This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from unfold.admin import ModelAdmin
|
from unfold.admin import ModelAdmin
|
||||||
from unfold.decorators import display
|
from unfold.decorators import display
|
||||||
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
|
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, ScoreFormula
|
||||||
|
|
||||||
class ScoreDimensionInline(admin.TabularInline):
|
class ScoreDimensionInline(admin.TabularInline):
|
||||||
model = ScoreDimension
|
model = ScoreDimension
|
||||||
@@ -143,3 +143,60 @@ class CommentAdmin(ModelAdmin):
|
|||||||
def content_preview(self, obj):
|
def content_preview(self, obj):
|
||||||
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
||||||
content_preview.short_description = "评语内容"
|
content_preview.short_description = "评语内容"
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreFormulaAdmin(ModelAdmin):
|
||||||
|
"""
|
||||||
|
评分公式管理
|
||||||
|
提供可视化公式编辑功能
|
||||||
|
"""
|
||||||
|
list_display = ['name', 'competition', 'formula_preview_display', 'is_active', 'is_default', 'created_at']
|
||||||
|
list_filter = ['competition', 'is_active', 'is_default']
|
||||||
|
search_fields = ['name', 'description', 'formula', 'competition__title']
|
||||||
|
autocomplete_fields = ['competition']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('基本信息', {
|
||||||
|
'fields': ('competition', 'name', 'description')
|
||||||
|
}),
|
||||||
|
('公式配置', {
|
||||||
|
'fields': ('formula',),
|
||||||
|
'description': '使用维度名称作为变量,例如: 创新性 * 0.3 + 实用性 * 0.5 + 演示效果 * 0.2'
|
||||||
|
}),
|
||||||
|
('公式设置', {
|
||||||
|
'fields': ('is_active', 'is_default')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@display(description="公式预览")
|
||||||
|
def formula_preview_display(self, obj):
|
||||||
|
preview = obj.get_formula_preview()
|
||||||
|
return preview[:100] + '...' if len(preview) > 100 else preview if preview else '-'
|
||||||
|
|
||||||
|
def get_form_kwargs(self, request, *args, **kwargs):
|
||||||
|
kwargs = super().get_form_kwargs(request, *args, **kwargs)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
|
||||||
|
extra_context = extra_context or {}
|
||||||
|
|
||||||
|
if request.method == 'GET' and not object_id:
|
||||||
|
competition_id = request.GET.get('competition')
|
||||||
|
if competition_id:
|
||||||
|
try:
|
||||||
|
from .models import ScoreDimension
|
||||||
|
dimensions = ScoreDimension.objects.filter(competition_id=competition_id)
|
||||||
|
extra_context['dimensions'] = dimensions
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return super().changeform_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('competition/admin/css/formula-editor.css',)
|
||||||
|
}
|
||||||
|
js = ('competition/admin/js/formula-editor.js',)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(ScoreFormula, ScoreFormulaAdmin)
|
||||||
|
|||||||
33
backend/competition/migrations/0008_scoreformula.py
Normal file
33
backend/competition/migrations/0008_scoreformula.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-20 05:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0007_competition_custom_score_formula_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScoreFormula',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='用于标识这个公式,方便管理', max_length=100, verbose_name='公式名称')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='公式说明')),
|
||||||
|
('formula', models.TextField(help_text='使用维度名称作为变量,支持四则运算和函数', verbose_name='计算公式')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||||
|
('is_default', models.BooleanField(default=False, verbose_name='是否设为默认公式')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_formulas', to='competition.competition', verbose_name='所属比赛')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '评分公式',
|
||||||
|
'verbose_name_plural': '评分公式配置',
|
||||||
|
'ordering': ['-is_default', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -177,13 +177,14 @@ class Project(models.Model):
|
|||||||
def calculate_score(self):
|
def calculate_score(self):
|
||||||
"""
|
"""
|
||||||
计算项目得分
|
计算项目得分
|
||||||
支持两种模式:
|
支持三种模式:
|
||||||
1. 默认加权平均:每个评委的得分 = sum(维度分数 × 维度权重),然后所有评委取平均
|
1. 默认加权平均:每个评委的得分 = sum(维度分数 × 维度权重),然后所有评委取平均
|
||||||
2. 自定义算式:使用比赛级别的 custom_score_formula 计算最终得分
|
2. 自定义算式(比赛级别):使用比赛级别的 custom_score_formula 计算最终得分
|
||||||
|
3. 公式配置(公式级别):使用 ScoreFormula 模型中的公式配置
|
||||||
|
|
||||||
自定义算式变量格式:
|
自定义算式变量格式:
|
||||||
- dimension_X: 第X个维度的平均分(所有评委对该维度的平均分)
|
- dimension_X: 第X个维度的平均分(所有评委对该维度的平均分)
|
||||||
- 也可以在算式中直接使用维度ID
|
- 也可以使用维度名称作为变量
|
||||||
"""
|
"""
|
||||||
scores = self.scores.all()
|
scores = self.scores.all()
|
||||||
if not scores.exists():
|
if not scores.exists():
|
||||||
@@ -193,6 +194,15 @@ class Project(models.Model):
|
|||||||
|
|
||||||
competition = self.competition
|
competition = self.competition
|
||||||
|
|
||||||
|
active_formula = ScoreFormula.objects.filter(
|
||||||
|
competition=competition,
|
||||||
|
is_active=True,
|
||||||
|
is_default=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if active_formula:
|
||||||
|
return self._calculate_formula_score(scores, active_formula)
|
||||||
|
|
||||||
if competition.score_calculation_type == 'custom' and competition.custom_score_formula:
|
if competition.score_calculation_type == 'custom' and competition.custom_score_formula:
|
||||||
return self._calculate_custom_score(scores, competition.custom_score_formula)
|
return self._calculate_custom_score(scores, competition.custom_score_formula)
|
||||||
|
|
||||||
@@ -227,7 +237,7 @@ class Project(models.Model):
|
|||||||
|
|
||||||
def _calculate_custom_score(self, scores, formula):
|
def _calculate_custom_score(self, scores, formula):
|
||||||
"""
|
"""
|
||||||
自定义算式模式
|
自定义算式模式(比赛级别)
|
||||||
使用比赛配置的自定义算式计算得分
|
使用比赛配置的自定义算式计算得分
|
||||||
"""
|
"""
|
||||||
dimension_scores = {}
|
dimension_scores = {}
|
||||||
@@ -238,6 +248,7 @@ class Project(models.Model):
|
|||||||
if dim_scores.exists():
|
if dim_scores.exists():
|
||||||
avg = sum(float(s.score) for s in dim_scores) / dim_scores.count()
|
avg = sum(float(s.score) for s in dim_scores) / dim_scores.count()
|
||||||
dimension_scores[f'dimension_{dimension.id}'] = avg
|
dimension_scores[f'dimension_{dimension.id}'] = avg
|
||||||
|
dimension_scores[dimension.name] = avg
|
||||||
|
|
||||||
if not dimension_scores:
|
if not dimension_scores:
|
||||||
self.final_score = 0
|
self.final_score = 0
|
||||||
@@ -254,6 +265,37 @@ class Project(models.Model):
|
|||||||
print(f"算式计算错误: {e}, formula: {formula}, values: {dimension_scores}")
|
print(f"算式计算错误: {e}, formula: {formula}, values: {dimension_scores}")
|
||||||
return self._calculate_default_score(scores)
|
return self._calculate_default_score(scores)
|
||||||
|
|
||||||
|
def _calculate_formula_score(self, scores, formula_obj):
|
||||||
|
"""
|
||||||
|
公式配置模式(使用 ScoreFormula 模型)
|
||||||
|
使用公式配置中的公式计算得分
|
||||||
|
"""
|
||||||
|
dimension_scores = {}
|
||||||
|
|
||||||
|
dimensions = self.competition.score_dimensions.all()
|
||||||
|
for dimension in dimensions:
|
||||||
|
dim_scores = scores.filter(dimension=dimension)
|
||||||
|
if dim_scores.exists():
|
||||||
|
avg = sum(float(s.score) for s in dim_scores) / dim_scores.count()
|
||||||
|
dimension_scores[dimension.name] = avg
|
||||||
|
|
||||||
|
if not dimension_scores:
|
||||||
|
self.final_score = 0
|
||||||
|
self.save()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
formula = formula_obj.formula
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = eval(formula, {"__builtins__": {}}, dimension_scores)
|
||||||
|
final_score = float(result)
|
||||||
|
self.final_score = round(final_score, 2)
|
||||||
|
self.save()
|
||||||
|
return self.final_score
|
||||||
|
except Exception as e:
|
||||||
|
print(f"公式计算错误: {e}, formula: {formula}, values: {dimension_scores}")
|
||||||
|
return self._calculate_default_score(scores)
|
||||||
|
|
||||||
def calculate_judge_score(self, judge):
|
def calculate_judge_score(self, judge):
|
||||||
"""
|
"""
|
||||||
计算指定评委对该项目的得分
|
计算指定评委对该项目的得分
|
||||||
@@ -346,3 +388,70 @@ class Comment(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.judge.user.nickname} -> {self.project.title}"
|
return f"{self.judge.user.nickname} -> {self.project.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreFormula(models.Model):
|
||||||
|
"""
|
||||||
|
评分公式配置
|
||||||
|
用于可视化配置得分计算公式
|
||||||
|
"""
|
||||||
|
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='score_formulas', verbose_name="所属比赛")
|
||||||
|
name = models.CharField(max_length=100, verbose_name="公式名称", help_text="用于标识这个公式,方便管理")
|
||||||
|
description = models.TextField(verbose_name="公式说明", blank=True)
|
||||||
|
|
||||||
|
formula = models.TextField(verbose_name="计算公式", help_text="使用维度名称作为变量,支持四则运算和函数")
|
||||||
|
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||||
|
is_default = models.BooleanField(default=False, verbose_name="是否设为默认公式")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "评分公式"
|
||||||
|
verbose_name_plural = "评分公式配置"
|
||||||
|
ordering = ['-is_default', '-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.competition.title} - {self.name}"
|
||||||
|
|
||||||
|
def get_formula_preview(self):
|
||||||
|
"""
|
||||||
|
获取公式预览,将维度变量替换为维度名称
|
||||||
|
"""
|
||||||
|
if not self.formula:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
dimension_map = {f'd["{d.name}"]': f'[{d.name}]' for d in self.competition.score_dimensions.all()}
|
||||||
|
dimension_map.update({f"d['{d.name}']": f'[{d.name}]' for d in self.competition.score_dimensions.all()})
|
||||||
|
|
||||||
|
result = self.formula
|
||||||
|
for old, new in dimension_map.items():
|
||||||
|
result = result.replace(old, new)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def generate_python_code(self):
|
||||||
|
"""
|
||||||
|
生成可执行的 Python 代码
|
||||||
|
"""
|
||||||
|
if not self.formula:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
dimension_names = [d.name for d in self.competition.score_dimensions.all()]
|
||||||
|
|
||||||
|
code_lines = [
|
||||||
|
"def calculate_score(d):",
|
||||||
|
" '''",
|
||||||
|
f" 计算公式: {self.name}",
|
||||||
|
" 参数 d: 字典,键为维度名称,值为该维度的平均分",
|
||||||
|
" '''",
|
||||||
|
]
|
||||||
|
|
||||||
|
for name in dimension_names:
|
||||||
|
code_lines.append(f" {name} = d.get('{name}', 0)")
|
||||||
|
|
||||||
|
code_lines.append("")
|
||||||
|
code_lines.append(f" return {self.formula}")
|
||||||
|
|
||||||
|
return "\n".join(code_lines)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django.urls import path, include
|
|||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import (
|
from .views import (
|
||||||
CompetitionViewSet, ProjectViewSet, ProjectFileViewSet,
|
CompetitionViewSet, ProjectViewSet, ProjectFileViewSet,
|
||||||
ScoreViewSet, CommentViewSet
|
ScoreViewSet, CommentViewSet, CompetitionDimensionsAPIView
|
||||||
)
|
)
|
||||||
from . import judge_views
|
from . import judge_views
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ urlpatterns = [
|
|||||||
path('admin/', judge_views.admin_entry, name='judge_admin_entry'),
|
path('admin/', judge_views.admin_entry, name='judge_admin_entry'),
|
||||||
path('judge/', include('competition.judge_urls')), # Sub-routes under /judge/
|
path('judge/', include('competition.judge_urls')), # Sub-routes under /judge/
|
||||||
|
|
||||||
|
# API Routes
|
||||||
|
path('competition/<int:competition_id>/dimensions/', CompetitionDimensionsAPIView.as_view(), name='competition-dimensions'),
|
||||||
|
|
||||||
# Existing API Routes
|
# Existing API Routes
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from rest_framework import viewsets, permissions, status, filters, serializers
|
from rest_framework import viewsets, permissions, status, filters, serializers
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action, api_view, permission_classes
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from shop.utils import get_current_wechat_user
|
from shop.utils import get_current_wechat_user
|
||||||
from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension
|
from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension
|
||||||
@@ -280,3 +281,31 @@ class CommentViewSet(viewsets.ModelViewSet):
|
|||||||
raise serializers.ValidationError("您不是该比赛的评委")
|
raise serializers.ValidationError("您不是该比赛的评委")
|
||||||
|
|
||||||
serializer.save(judge=enrollment)
|
serializer.save(judge=enrollment)
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionDimensionsAPIView(APIView):
|
||||||
|
"""
|
||||||
|
获取比赛评分维度的API
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, competition_id):
|
||||||
|
try:
|
||||||
|
competition = Competition.objects.get(id=competition_id)
|
||||||
|
dimensions = ScoreDimension.objects.filter(competition=competition).order_by('order')
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'dimensions': [
|
||||||
|
{
|
||||||
|
'id': d.id,
|
||||||
|
'name': d.name,
|
||||||
|
'weight': float(d.weight),
|
||||||
|
'max_score': d.max_score,
|
||||||
|
'description': d.description
|
||||||
|
}
|
||||||
|
for d in dimensions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return Response(data)
|
||||||
|
except Competition.DoesNotExist:
|
||||||
|
return Response({'error': '比赛不存在'}, status=404)
|
||||||
|
|||||||
Reference in New Issue
Block a user