diff --git a/apps/edu/apps.py b/apps/edu/apps.py index 3c43f722..eb55b0cc 100644 --- a/apps/edu/apps.py +++ b/apps/edu/apps.py @@ -3,4 +3,4 @@ from django.apps import AppConfig class EduConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'edu' + name = 'apps.edu' diff --git a/apps/edu/filters.py b/apps/edu/filters.py new file mode 100644 index 00000000..9c8be02f --- /dev/null +++ b/apps/edu/filters.py @@ -0,0 +1,47 @@ +from django_filters import rest_framework as filters +from django.utils import timezone +from apps.system.models import User +from .models import Exam, ExamRecord + +class ExamFilter(filters.FilterSet): + can_attend = filters.BooleanFilter(method='filter_can_attend') + is_my = filters.BooleanFilter(method='filter_is_my') + class Meta: + model = Exam + fields = { + 'close_time': ['gte', 'lte'], + 'paper': ['exact'], + 'is_public': ['exact'], + } + + def filter_can_attend(self, queryset, name, value): + if value: + now = timezone.now() + return queryset.filter(open_time__lte=now, close_time__gte=now)| queryset.filter(close_time__isnull=True) + return queryset + + def filter_is_my(self, queryset, name, value): + if value: + user:User = self.request.user + dept = user.belong_dept + qs = queryset.filter(is_public=True) + qs = qs|qs.filter(p_users=user) + if dept: + qs = qs|qs.filter(p_depts=dept) + return qs + return queryset + +class ExamRecordFilter(filters.FilterSet): + is_my = filters.BooleanFilter(method='filter_is_my') + class Meta: + model = ExamRecord + fields = { + 'start_time': ['exact', 'gte', 'lte'], + 'is_pass': ['exact'], + 'is_submited': ['exact'], + } + def filter_is_my(self, queryset, name, value): + if value: + user = self.request.user + return queryset.filter(create_by=user) + return queryset \ No newline at end of file diff --git a/apps/edu/migrations/0001_initial.py b/apps/edu/migrations/0001_initial.py new file mode 100644 index 00000000..58973df7 --- /dev/null +++ b/apps/edu/migrations/0001_initial.py @@ -0,0 +1,194 @@ +# Generated by Django 3.2.12 on 2024-05-31 07:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('system', '0003_alter_permission_parent'), + ] + + operations = [ + migrations.CreateModel( + name='AnswerDetail', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('total_score', models.FloatField(default=0, verbose_name='该题满分')), + ('user_answer', models.JSONField(blank=True, null=True)), + ('score', models.FloatField(default=0, verbose_name='本题得分')), + ('is_right', models.BooleanField(default=False, verbose_name='是否正确')), + ], + options={ + 'verbose_name': '答题记录', + 'verbose_name_plural': '答题记录', + }, + ), + migrations.CreateModel( + name='Exam', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=100, verbose_name='名称')), + ('open_time', models.DateTimeField(verbose_name='开启时间')), + ('close_time', models.DateTimeField(blank=True, null=True, verbose_name='关闭时间')), + ('chance', models.IntegerField(default=1, verbose_name='考试机会')), + ('is_public', models.BooleanField(default=False, verbose_name='是否公开')), + ('paper_json', models.JSONField(blank=True, null=True, verbose_name='试卷JSON')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='exam_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('p_depts', models.ManyToManyField(blank=True, to='system.Dept', verbose_name='指定部门')), + ('p_users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='指定用户')), + ], + options={ + 'verbose_name': '在线考试', + 'verbose_name_plural': '在线考试', + }, + ), + migrations.CreateModel( + name='Paper', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=200, verbose_name='名称')), + ('limit', models.IntegerField(default=0, verbose_name='限时(分钟)')), + ('total_score', models.FloatField(default=0, verbose_name='满分')), + ('pass_score', models.FloatField(default=0, verbose_name='通过分数')), + ('danxuan_count', models.IntegerField(default=0, verbose_name='单选数量')), + ('danxuan_score', models.FloatField(default=2, verbose_name='单选分数')), + ('duoxuan_count', models.IntegerField(default=0, verbose_name='多选数量')), + ('duoxuan_score', models.FloatField(default=4, verbose_name='多选分数')), + ('panduan_count', models.IntegerField(default=0, verbose_name='判断数量')), + ('panduan_score', models.FloatField(default=2, verbose_name='判断分数')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='paper_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ], + options={ + 'verbose_name': '押题卷', + 'verbose_name_plural': '押题卷', + }, + ), + migrations.CreateModel( + name='Questioncat', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=200, verbose_name='名称')), + ('sort', models.PositiveIntegerField(default=0, verbose_name='排序')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questioncat_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cate_parent', to='edu.questioncat', verbose_name='父级')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questioncat_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': '题库分类', + 'verbose_name_plural': '题库分类', + }, + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.TextField(verbose_name='题干')), + ('img', models.TextField(blank=True, null=True, verbose_name='题干图片')), + ('type', models.PositiveSmallIntegerField(choices=[(10, '单选'), (20, '多选'), (30, '判断')], verbose_name='题型')), + ('level', models.PositiveSmallIntegerField(choices=[(10, '简单'), (20, '一般'), (30, '困难')], default=20, verbose_name='难度')), + ('options', models.JSONField(verbose_name='选项')), + ('right', models.JSONField(verbose_name='正确答案')), + ('resolution', models.TextField(blank=True, null=True, verbose_name='解析')), + ('enabled', models.BooleanField(default=False, verbose_name='是否启用')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='question_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('questioncat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questioncat', to='edu.questioncat', verbose_name='所属题库')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='question_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': '题目', + 'verbose_name_plural': '题目', + }, + ), + migrations.CreateModel( + name='PaperQuestion', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('total_score', models.FloatField(default=0, verbose_name='单题满分')), + ('sort', models.IntegerField(default=0, verbose_name='排序')), + ('paper', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='edu.paper', verbose_name='试卷')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='edu.question', verbose_name='试题')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='paper', + name='questions', + field=models.ManyToManyField(through='edu.PaperQuestion', to='edu.Question'), + ), + migrations.AddField( + model_name='paper', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='paper_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.CreateModel( + name='ExamRecord', + fields=[ + ('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('score', models.FloatField(default=0, verbose_name='得分')), + ('took', models.IntegerField(default=0, verbose_name='耗时(秒)')), + ('start_time', models.DateTimeField(verbose_name='开始答题时间')), + ('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束答题时间')), + ('is_pass', models.BooleanField(default=True, verbose_name='是否通过')), + ('is_submited', models.BooleanField(default=False, verbose_name='是否提交')), + ('is_last', models.BooleanField(default=False, verbose_name='是否最后一次考试记录')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='examrecord_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('exam', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='record_exam', to='edu.exam', verbose_name='关联考试')), + ('questions', models.ManyToManyField(through='edu.AnswerDetail', to='edu.Question', verbose_name='答题记录')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='examrecord_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': '考试记录', + 'verbose_name_plural': '考试记录', + }, + ), + migrations.AddField( + model_name='exam', + name='paper', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='edu.paper', verbose_name='使用的试卷'), + ), + migrations.AddField( + model_name='exam', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='exam_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AddField( + model_name='answerdetail', + name='examrecord', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='detail_er', to='edu.examrecord'), + ), + migrations.AddField( + model_name='answerdetail', + name='question', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='edu.question'), + ), + ] diff --git a/apps/edu/models.py b/apps/edu/models.py index 71a83623..40e7e6f5 100644 --- a/apps/edu/models.py +++ b/apps/edu/models.py @@ -1,3 +1,115 @@ from django.db import models +from apps.utils.models import CommonAModel, BaseModel, CommonADModel +from apps.system.models import User, Dept # Create your models here. +class Questioncat(CommonAModel): + name = models.CharField(max_length=200, verbose_name='名称') + parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, verbose_name='父级', related_name='cate_parent') + sort = models.PositiveIntegerField(default=0, verbose_name='排序') + + class Meta: + verbose_name = '题库分类' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name +class Question(CommonAModel): + Q_DAN = 10 + Q_DUO = 20 + Q_PAN = 30 + Q_EASY = 10 + Q_SIMPLE = 20 + Q_HARD = 30 + name = models.TextField('题干') + img = models.TextField('题干图片', null=True, blank=True) + type = models.PositiveSmallIntegerField('题型', choices=((Q_DAN, '单选'), (Q_DUO, '多选'), (Q_PAN, '判断'))) + level = models.PositiveSmallIntegerField('难度', default=Q_SIMPLE, choices=((Q_EASY, '简单'), (Q_SIMPLE, '一般'), (Q_HARD, '困难'))) + questioncat = models.ForeignKey(Questioncat, blank=True, null=True, on_delete=models.SET_NULL, verbose_name='所属题库', related_name='questioncat') + options = models.JSONField('选项') + right = models.JSONField('正确答案') + resolution = models.TextField('解析', null=True, blank=True) + enabled = models.BooleanField('是否启用', default=False) + class Meta: + verbose_name = '题目' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class Paper(CommonAModel): + name = models.CharField(max_length=200, verbose_name='名称') + questions = models.ManyToManyField(Question, through='PaperQuestion') + limit = models.IntegerField(default=0, verbose_name='限时(分钟)') + total_score = models.FloatField(default=0, verbose_name='满分') + pass_score = models.FloatField(default=0, verbose_name='通过分数') + danxuan_count = models.IntegerField(default=0, verbose_name='单选数量') + danxuan_score = models.FloatField(default=2, verbose_name='单选分数') + duoxuan_count = models.IntegerField(default=0, verbose_name='多选数量') + duoxuan_score = models.FloatField(default=4, verbose_name='多选分数') + panduan_count = models.IntegerField(default=0, verbose_name='判断数量') + panduan_score = models.FloatField(default=2, verbose_name='判断分数') + class Meta: + verbose_name = '押题卷' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class PaperQuestion(BaseModel): + paper = models.ForeignKey(Paper, on_delete=models.CASCADE, verbose_name='试卷') + question = models.ForeignKey(Question, on_delete=models.CASCADE, verbose_name='试题') + total_score = models.FloatField(default=0, verbose_name='单题满分') + sort = models.IntegerField(default=0, verbose_name='排序') + + +class Exam(CommonADModel): + name = models.CharField('名称', max_length=100) + open_time = models.DateTimeField('开启时间') + close_time = models.DateTimeField('关闭时间', null=True, blank=True) + chance = models.IntegerField('考试机会', default=1) # 0表示不限次数 + paper = models.ForeignKey(Paper, verbose_name='使用的试卷', on_delete=models.SET_NULL, null=True, blank=True) + is_public = models.BooleanField('是否公开', default=False) + p_users = models.ManyToManyField(User, verbose_name='指定用户', blank=True) + p_depts = models.ManyToManyField(Dept, verbose_name='指定部门', blank=True) + paper_json = models.JSONField('试卷JSON', null=True, blank=True) + + class Meta: + verbose_name = '在线考试' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + +class ExamRecord(CommonADModel): + ''' + 考试记录表 + ''' + exam = models.ForeignKey(Exam, on_delete=models.CASCADE, verbose_name='关联考试', related_name='record_exam') + score = models.FloatField(default=0, verbose_name='得分') + took = models.IntegerField(default=0, verbose_name='耗时(秒)') + start_time = models.DateTimeField(verbose_name='开始答题时间') + end_time = models.DateTimeField(verbose_name='结束答题时间', null=True, blank=True) + questions = models.ManyToManyField(Question, verbose_name='答题记录', through='AnswerDetail') + is_pass = models.BooleanField(default=True, verbose_name='是否通过') + is_submited = models.BooleanField(default=False, verbose_name='是否提交') + is_last = models.BooleanField(default=False, verbose_name='是否最后一次考试记录') + + class Meta: + verbose_name = '考试记录' + verbose_name_plural = verbose_name + + +class AnswerDetail(BaseModel): + examrecord = models.ForeignKey(ExamRecord, on_delete=models.CASCADE, related_name='detail_er') + total_score = models.FloatField(default=0, verbose_name='该题满分') + question = models.ForeignKey(Question, on_delete=models.CASCADE) + user_answer = models.JSONField(null=True,blank=True) + score = models.FloatField(default=0, verbose_name='本题得分') + is_right = models.BooleanField(default=False, verbose_name='是否正确') + + class Meta: + verbose_name = '答题记录' + verbose_name_plural = verbose_name \ No newline at end of file diff --git a/apps/edu/serializers.py b/apps/edu/serializers.py new file mode 100644 index 00000000..65ed7861 --- /dev/null +++ b/apps/edu/serializers.py @@ -0,0 +1,161 @@ +from apps.utils.serializers import CustomModelSerializer +from rest_framework import serializers +from .models import Questioncat, Question, Paper, PaperQuestion, Exam, ExamRecord, AnswerDetail +from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE +from django.db import transaction + +class QuestioncatSerializer(CustomModelSerializer): + class Meta: + model = Questioncat + fields = '__all__' + +class QuestionSerializer(CustomModelSerializer): + class Meta: + model = Question + fields = '__all__' + +class PaperQuestionSerializer(CustomModelSerializer): + class Meta: + model = PaperQuestion + fields = '__all__' + read_only_fields = EXCLUDE_FIELDS_BASE + + +class PaperListSerializer(CustomModelSerializer): + class Meta: + model = Paper + exclude = ["questions"] + +class PaperPatchSerializer(CustomModelSerializer): + class Meta: + model = Paper + fields = ["name"] + +class PaperSerializer(CustomModelSerializer): + detail = PaperQuestionSerializer(many=True) + class Meta: + model = Paper + exclude = ["questions"] + read_only_fields = ["editable"] + EXCLUDE_FIELDS + + def create(self, validated_data): + detail = validated_data.pop("detail", []) + with transaction.atomic(): + paper = super().create(validated_data) + qs = [PaperQuestion(paper=paper, question=item["question"], total_score=item["total_score"], sort=item["sort"]) for item in detail] + PaperQuestion.objects.bulk_create(qs) + return paper + + def update(self, instance, validated_data): + detail = validated_data.pop("detail", []) + with transaction.atomic(): + paper = super().update(instance, validated_data) + # 删除未有的数据 + question_ids = [item["question"].id for item in detail] + PaperQuestion.objects.filter(paper=paper).exclude(question__id__in=question_ids).delete() + # 更新新数据 + for item in detail: + PaperQuestion.objects.update_or_create( + paper=instance, + question=item["question"], + defaults={ + "total_score": item["total_score"], + "sort": item["sort"] + } + ) + return paper + + def to_representation(self, instance): + representation = super().to_representation(instance) + detail_data = representation['detail'] + sorted_detail_data = sorted(detail_data, key=lambda x: (x['sort'], x['create_time'])) + representation['detail'] = sorted_detail_data + return sorted_detail_data + + +class ExamSerializer(CustomModelSerializer): + create_by_name = serializers.CharField(source='create_by.name', read_only=True) + paper = serializers.PrimaryKeyRelatedField(queryset=Paper.objects.all(), label='有考试记录,编辑时忽略') + class Meta: + model = Exam + fields = "__all__" + + def update(self, instance, validated_data): + if ExamRecord.objects.filter(exam=instance).exists(): + validated_data.pop("paper", None) + return super().update(instance, validated_data) + + + +class AnswerDetailOutSerializer(CustomModelSerializer): + name = serializers.ReadOnlyField(source='question.name') + options = serializers.ReadOnlyField(source='question.options') + type = serializers.ReadOnlyField(source='question.type') + img = serializers.ReadOnlyField(source='question.img') + questioncat_name = serializers.ReadOnlyField( + source='question.questioncat.name') + level = serializers.ReadOnlyField(source='question.level') + + class Meta: + model = AnswerDetail + fields = ['id', 'question', 'name', 'options', 'type', 'level', + 'total_score', 'questioncat_name', 'img', 'user_answer', 'score', 'is_right'] + + +class AnswerDetailSerializer(AnswerDetailOutSerializer): + right = serializers.ReadOnlyField(source='question.right') + class Meta: + model = AnswerDetail + fields = ['id', 'question', 'name', 'options', 'type', 'level', 'right', + 'total_score', 'questioncat_name', 'img', 'user_answer', 'score', 'is_right'] + +class AnswerDetailUpdateSerializer(serializers.Serializer): + id = serializers.CharField(label='下发ID') + user_answer = serializers.JSONField(label='作答') + +class ExamRecordInitSerizlier(CustomModelSerializer): + detail = serializers.SerializerMethodField() + + class Meta: + model = ExamRecord + fields = ["id", "detail"] + + def get_detail(self, obj): + objs = AnswerDetail.objects.select_related('question').filter( + examrecord=obj).order_by('id') + return AnswerDetailOutSerializer(instance=objs, many=True).data + + +class TookSerializerMixin: + took_format = serializers.SerializerMethodField() + + def get_took_format(self, obj): + m, s = divmod(obj.took, 60) + h, m = divmod(m, 60) + return "%02d:%02d:%02d" % (h, m, s) + +class ExamRecordSerializer(CustomModelSerializer, TookSerializerMixin): + create_by_name = serializers.CharField(source='create_by.name', read_only=True) + class Meta: + model = ExamRecord + exclude = ["questions"] + + +class ExamRecordDetailSerializer(ExamRecordSerializer, TookSerializerMixin): + detail = serializers.SerializerMethodField() + + class Meta: + model = ExamRecord + fields = "__all__" + + def get_detail(self, obj): + objs = AnswerDetail.objects.select_related('question').filter( + examrecord=obj).order_by('id') + return AnswerDetailSerializer(instance=objs, many=True).data + +class ExamRecordSubmitSerializer(serializers.ModelSerializer): + detail = AnswerDetailUpdateSerializer(many=True) + + class Meta: + model = ExamRecord + fields = ['detail'] \ No newline at end of file diff --git a/apps/edu/urls.py b/apps/edu/urls.py new file mode 100644 index 00000000..ca6630ba --- /dev/null +++ b/apps/edu/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from apps.edu.views import QuestioncatViewSet, QuestionViewSet, PaperViewSet, ExamViewSet, ExamRecordViewSet + +API_BASE_URL = 'api/edu/' +HTML_BASE_URL = 'edu/' + +router = DefaultRouter() +router.register('questioncat', QuestioncatViewSet, basename='questioncat') +router.register('question', QuestionViewSet, basename='question') +router.register('paper', PaperViewSet, basename='paper') +router.register('exam', ExamViewSet, basename='exam') +router.register('examrecord', ExamRecordViewSet, basename='examrecord') +urlpatterns = [ + path(API_BASE_URL, include(router.urls)), +] diff --git a/apps/edu/views.py b/apps/edu/views.py index 91ea44a2..8a3ccf78 100644 --- a/apps/edu/views.py +++ b/apps/edu/views.py @@ -1,3 +1,177 @@ -from django.shortcuts import render +from apps.utils.viewsets import CustomModelViewSet, CustomGenericViewSet +from apps.utils.mixins import ListModelMixin, DestroyModelMixin +from rest_framework.mixins import RetrieveModelMixin +from rest_framework.exceptions import ParseError +from rest_framework.decorators import action +from rest_framework.serializers import Serializer +from django.db import transaction +from .models import Questioncat, Question, Paper, PaperQuestion, Exam, ExamRecord, AnswerDetail +from .serializers import (QuestioncatSerializer, QuestionSerializer, ExamSerializer, + ExamRecordInitSerizlier, ExamRecordSerializer, ExamRecordDetailSerializer, ExamRecordSubmitSerializer, + PaperSerializer, PaperListSerializer, PaperPatchSerializer) +from django.utils import timezone +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from drf_yasg.utils import swagger_auto_schema +from apps.utils.permission import has_perm +from .filters import ExamFilter, ExamRecordFilter +from apps.system.models import User # Create your views here. +class QuestioncatViewSet(CustomModelViewSet): + queryset = Questioncat.objects.all() + serializer_class = QuestioncatSerializer + + +class QuestionViewSet(CustomModelViewSet): + queryset = Question.objects.all() + serializer_class = QuestionSerializer + filterset_fields = ["questioncat", "type", "enabled"] + + def update(self, request, *args, **kwargs): + obj: Question = self.get_object() + if AnswerDetail.objects.filter(question=obj).exists(): + raise ParseError("存在答题,该题目不可编辑") + return super().update(request, *args, **kwargs) + + +class PaperViewSet(CustomModelViewSet): + queryset = Paper.objects.all() + serializer_class = PaperSerializer + list_serializer_class = PaperListSerializer + partial_update_serializer_class = PaperPatchSerializer + + def update(self, request, *args, **kwargs): + obj: Paper = self.get_object() + if Exam.objects.filter(paper=obj).exists(): + raise ParseError("存在考试,该试卷不可编辑") + return super().update(request, *args, **kwargs) + +class ExamViewSet(CustomModelViewSet): + queryset = Exam.objects.all() + serializer_class = ExamSerializer + filterset_class = ExamFilter + + def get_queryset(self): + qs = super().get_queryset() + if has_perm(self.request.user, 'exam.view'): + return qs + user:User = self.request.user + dept = user.belong_dept + qs = qs.filter(is_public=True) + qs = qs|qs.filter(p_users=user) + if dept: + qs = qs|qs.filter(p_depts=dept) + return qs + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if ExamRecord.objects.filter(exam=instance).exists(): + raise ParseError('存在考试记录,禁止删除') + return super().destroy(request, *args, **kwargs) + + @swagger_auto_schema(request_body=Serializer, responses={200: ExamRecordInitSerizlier}) + @action(methods=['post'], detail=True, perms_map=[{'post': '*'}], serializer_class=Serializer) + def attend(self, request, *args, **kwargs): + """ + 参加考试 + + 返回考试具体题目信息 + """ + exam: Exam = self.get_object() + now = timezone.now() + if now < exam.open_time or now > exam.close_time: + raise ParseError('不在考试时间范围') + tests = ExamRecord.objects.filter( + exam=exam, create_by=request.user) + chance_used = tests.count() + if chance_used > exam.chance: + raise ParseError('考试机会已用完') + if exam.paper: + with transaction.atomic(): + tests.update(is_last=False) + er = ExamRecord() + er.start_time = now + er.is_pass = False + er.is_submited = False + er.exam = exam + er.create_by = request.user + er.is_last = True + er.save() + pqs = PaperQuestion.objects.filter(paper=exam.paper).order_by('sort', 'id') + details = [] + for i in pqs: + details.append(AnswerDetail(examrecord=er, question=i.question, total_score=i.total_score)) + AnswerDetail.objects.bulk_create(details) + sr = ExamRecordInitSerizlier(er) + res_data = sr.data + res_data.update({"chance_used": chance_used}) + return Response(sr.data, status=201) + raise ParseError('暂不支持') + + +class ExamRecordViewSet(ListModelMixin, DestroyModelMixin, RetrieveModelMixin, CustomGenericViewSet): + """ + 考试记录 + """ + perms_map = {"get": "*", "delete": "examrecord.delete"} + queryset = ExamRecord.objects.all() + list_serializer_class = ExamRecordSerializer + retrieve_serializer_class = ExamRecordDetailSerializer + search_fields = ('create_by__name', 'create_by__username', 'exam__name') + filterset_class = ExamRecordFilter + + def get_queryset(self): + qs = super().get_queryset() + if has_perm(self.request.user, "examrecord.view"): + return qs + return qs.filter(create_by=self.request.user) + + + @swagger_auto_schema(request_body=ExamRecordSubmitSerializer, responses={200: ExamRecordSerializer}) + @action(methods=['post'], detail=True, perms_map=[{'post': '*'}], serializer_class=ExamRecordSubmitSerializer, permission_classes = [IsAuthenticated]) + @transaction.atomic + def submit(self, request, pk=None): + ''' + 提交答卷 + + 提交答卷 + ''' + er: ExamRecord = self.get_object() + now = timezone.now() + if er.is_submited: + raise ParseError('该考试记录已提交') + exam:Exam = er.exam + if not exam: + raise ParseError('暂不支持') + took = (now - er.start_time).total_seconds() + if took > exam.paper.limit * 60: + raise ParseError('答题时间超时,提交失败') + serializer = ExamRecordSubmitSerializer(data = request.data) + serializer.is_valid(raise_exception=True) + vdata = serializer.validated_data + questions_ = vdata['detail_'] + # 后端判卷 + ads = AnswerDetail.objects.select_related('question').filter(examrecord=er).order_by('id') + total_score = 0 + try: + for index, ad in enumerate(ads): + ad.user_answer = questions_[index]['user_answer'] + if ad.question.type == '多选': + if set(ad.question.right) == set(ad.user_answer): + ad.is_right = True + ad.score = ad.total_score + else: + if ad.question.right == ad.user_answer: + ad.is_right = True + ad.score = ad.total_score + ad.save() + total_score = total_score + ad.score + except Exception as e: + raise ParseError('判卷失败, 请检查试卷:' + str(e)) + er.score = total_score + er.took = took + er.end_time = now + er.is_submited = True + er.save() + return Response(ExamRecordSerializer(er).data) \ No newline at end of file diff --git a/server/settings.py b/server/settings.py index d13ebdd1..4aedb490 100755 --- a/server/settings.py +++ b/server/settings.py @@ -76,7 +76,8 @@ INSTALLED_APPS = [ 'apps.sam', 'apps.pum', 'apps.pm', - 'apps.enp' + 'apps.enp', + 'apps.edu' ] MIDDLEWARE = [ diff --git a/server/urls.py b/server/urls.py index 8c49f360..4894df5c 100755 --- a/server/urls.py +++ b/server/urls.py @@ -66,6 +66,7 @@ urlpatterns = [ path('', include('apps.pum.urls')), path('', include('apps.pm.urls')), path('', include('apps.enp.urls')), + path('', include('apps.edu.urls')), # 前端页面入口 path('', TemplateView.as_view(template_name="index.html")),