1. 项目概述
在线教育平台已成为现代教育的重要组成部分,特别是在后疫情时代,远程学习的需求显著增加。本文将详细介绍如何使用Python的Django框架开发一个功能完善的在线教育平台,包括系统设计、核心功能实现以及部署上线等关键环节。
本项目旨在创建一个集课程管理、视频播放、在线测验、学习进度跟踪和社区互动于一体的综合性教育平台,为教育机构和个人讲师提供一站式在线教学解决方案。
2. 技术栈选择
2.1 后端技术
- Django 4.2: 提供强大的ORM、认证系统和管理后台
- Django REST Framework: 构建RESTful API
- Channels: 实现WebSocket通信,支持实时互动功能
- Celery: 处理异步任务,如邮件发送、视频处理
- Redis: 缓存和消息队列
- PostgreSQL: 主数据库存储
2.2 前端技术
- Vue.js 3: 构建响应式用户界面
- Vuex: 状态管理
- Element Plus: UI组件库
- Video.js: 视频播放器
- Chart.js: 数据可视化
- Axios: HTTP请求
2.3 部署与DevOps
- Docker & Docker Compose: 容器化应用
- Nginx: 反向代理和静态资源服务
- Gunicorn: WSGI HTTP服务器
- AWS S3/阿里云OSS: 存储视频和课程资料
- GitHub Actions: CI/CD流程
3. 系统架构设计
3.1 整体架构
系统采用前后端分离架构:
- 前端Vue.js应用通过RESTful API与后端通信
- Django后端处理业务逻辑和数据存储
- WebSocket提供实时通信能力
- 媒体文件存储在云存储服务
- Redis用于缓存和会话管理
3.2 数据库设计
核心数据模型包括:
# users/models.py
class User(AbstractUser):"""扩展Django用户模型"""avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)bio = models.TextField(blank=True)is_teacher = models.BooleanField(default=False)# courses/models.py
class Course(models.Model):"""课程模型"""title = models.CharField(max_length=200)slug = models.SlugField(unique=True)description = models.TextField()instructor = models.ForeignKey(User, on_delete=models.CASCADE)thumbnail = models.ImageField(upload_to='course_thumbnails/')price = models.DecimalField(max_digits=7, decimal_places=2)created_at = models.DateTimeField(auto_now_add=True)updated_at = models.DateTimeField(auto_now=True)is_published = models.BooleanField(default=False)class Section(models.Model):"""课程章节"""course = models.ForeignKey(Course, related_name='sections', on_delete=models.CASCADE)title = models.CharField(max_length=200)order = models.PositiveIntegerField()class Lesson(models.Model):"""课程小节"""section = models.ForeignKey(Section, related_name='lessons', on_delete=models.CASCADE)title = models.CharField(max_length=200)content = models.TextField()video_url = models.URLField(blank=True)order = models.PositiveIntegerField()duration = models.PositiveIntegerField(help_text="Duration in seconds")# enrollments/models.py
class Enrollment(models.Model):"""学生课程注册"""user = models.ForeignKey(User, on_delete=models.CASCADE)course = models.ForeignKey(Course, on_delete=models.CASCADE)enrolled_at = models.DateTimeField(auto_now_add=True)completed = models.BooleanField(default=False)class Progress(models.Model):"""学习进度跟踪"""enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE)lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE)completed = models.BooleanField(default=False)last_position = models.PositiveIntegerField(default=0, help_text="Last video position in seconds")updated_at = models.DateTimeField(auto_now=True)
4. 核心功能实现
4.1 用户认证与权限管理
使用Django内置的认证系统,并扩展为支持教师和学生角色:
# users/views.py
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import User
from .serializers import UserSerializerclass IsTeacherOrReadOnly(permissions.BasePermission):"""只允许教师修改课程内容"""def has_permission(self, request, view):if request.method in permissions.SAFE_METHODS:return Truereturn request.user.is_authenticated and request.user.is_teacherclass UserViewSet(viewsets.ModelViewSet):queryset = User.objects.all()serializer_class = UserSerializer@action(detail=False, methods=['get'])def me(self, request):"""获取当前用户信息"""serializer = self.get_serializer(request.user)return Response(serializer.data)
4.2 课程管理系统
实现课程的CRUD操作,并添加搜索和过滤功能:
# courses/views.py
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Course, Section, Lesson
from .serializers import CourseSerializer, SectionSerializer, LessonSerializer
from users.views import IsTeacherOrReadOnlyclass CourseViewSet(viewsets.ModelViewSet):queryset = Course.objects.all()serializer_class = CourseSerializerpermission_classes = [IsTeacherOrReadOnly]filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]filterset_fields = ['instructor', 'is_published']search_fields = ['title', 'description']ordering_fields = ['created_at', 'price']def perform_create(self, serializer):serializer.save(instructor=self.request.user)
4.3 视频播放与进度跟踪
使用Video.js实现视频播放,并通过WebSocket实时更新学习进度:
# frontend/src/components/VideoPlayer.vue
<template><div class="video-container"><videoref="videoPlayer"class="video-js vjs-big-play-centered"controlspreload="auto"@timeupdate="updateProgress"></video></div>
</template><script>
import videojs from 'video.js';
import 'video.js/dist/video-js.css';export default {props: {lessonId: {type: Number,required: true},videoUrl: {type: String,required: true},startPosition: {type: Number,default: 0}},data() {return {player: null,progressUpdateInterval: null,lastUpdateTime: 0};},mounted() {this.initializePlayer();},methods: {initializePlayer() {this.player = videojs(this.$refs.videoPlayer, {sources: [{ src: this.videoUrl }],fluid: true,playbackRates: [0.5, 1, 1.25, 1.5, 2]});// 设置开始位置this.player.on('loadedmetadata', () => {this.player.currentTime(this.startPosition);});},updateProgress() {const currentTime = Math.floor(this.player.currentTime());// 每15秒或视频暂停时更新进度if (currentTime - this.lastUpdateTime >= 15 || this.player.paused()) {this.lastUpdateTime = currentTime;this.$emit('progress-update', {lessonId: this.lessonId,position: currentTime});}}},beforeUnmount() {if (this.player) {this.player.dispose();}}
};
</script>
后端处理进度更新:
# enrollments/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from .models import Enrollment, Progress
from courses.models import Lessonclass ProgressConsumer(AsyncWebsocketConsumer):async def connect(self):self.user = self.scope['user']if not self.user.is_authenticated:await self.close()returnawait self.accept()async def disconnect(self, close_code):passasync def receive(self, text_data):data = json.loads(text_data)lesson_id = data.get('lessonId')position = data.get('position')if lesson_id and position is not None:await self.update_progress(lesson_id, position)@database_sync_to_asyncdef update_progress(self, lesson_id, position):try:lesson = Lesson.objects.get(id=lesson_id)enrollment = Enrollment.objects.get(user=self.user,course=lesson.section.course)progress, created = Progress.objects.get_or_create(enrollment=enrollment,lesson=lesson,defaults={'last_position': position})if not created:progress.last_position = position# 如果位置超过视频总长度的90%,标记为已完成if position >= lesson.duration * 0.9:progress.completed = Trueprogress.save()except (Lesson.DoesNotExist, Enrollment.DoesNotExist):pass
4.4 在线测验系统
实现测验创建和评分功能:
# quizzes/models.py
class Quiz(models.Model):"""课程测验"""lesson = models.ForeignKey('courses.Lesson', on_delete=models.CASCADE)title = models.CharField(max_length=200)description = models.TextField(blank=True)time_limit = models.PositiveIntegerField(null=True, blank=True, help_text="Time limit in minutes")class Question(models.Model):"""测验问题"""SINGLE_CHOICE = 'single'MULTIPLE_CHOICE = 'multiple'TRUE_FALSE = 'true_false'SHORT_ANSWER = 'short_answer'QUESTION_TYPES = [(SINGLE_CHOICE, '单选题'),(MULTIPLE_CHOICE, '多选题'),(TRUE_FALSE, '判断题'),(SHORT_ANSWER, '简答题'),]quiz = models.ForeignKey(Quiz, related_name='questions', on_delete=models.CASCADE)text = models.TextField()question_type = models.CharField(max_length=20, choices=QUESTION_TYPES)points = models.PositiveIntegerField(default=1)order = models.PositiveIntegerField()class Choice(models.Model):"""选择题选项"""question = models.ForeignKey(Question, related_name='choices', on_delete=models.CASCADE)text = models.CharField(max_length=255)is_correct = models.BooleanField(default=False)class QuizAttempt(models.Model):"""测验尝试记录"""quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE)user = models.ForeignKey('users.User', on_delete=models.CASCADE)started_at = models.DateTimeField(auto_now_add=True)completed_at = models.DateTimeField(null=True, blank=True)score = models.DecimalField(max_digits=5, decimal_places=2, null=True)
4.5 支付与订阅系统
集成支付宝/微信支付接口:
# payments/views.py
from django.shortcuts import redirect
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from .models import Payment
from courses.models import Course
from enrollments.models import Enrollment
from .alipay_utils import AliPayAPIclass CreatePaymentView(APIView):"""创建支付订单"""def post(self, request):course_id = request.data.get('course_id')try:course = Course.objects.get(id=course_id, is_published=True)# 检查用户是否已购买该课程if Enrollment.objects.filter(user=request.user, course=course).exists():return Response({"detail": "您已购买该课程"},status=status.HTTP_400_BAD_REQUEST)# 创建支付记录payment = Payment.objects.create(user=request.user,course=course,amount=course.price,payment_method='alipay')# 调用支付宝接口alipay_api = AliPayAPI()payment_url = alipay_api.create_order(out_trade_no=str(payment.id),total_amount=float(course.price),subject=f"课程: {course.title}")return Response({"payment_url": payment_url})except Course.DoesNotExist:return Response({"detail": "课程不存在"},status=status.HTTP_404_NOT_FOUND)
5. 高级功能实现
5.1 实时直播课堂
使用WebRTC和Django Channels实现实时直播:
# live/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumerclass LiveClassConsumer(AsyncWebsocketConsumer):async def connect(self):self.room_name = self.scope['url_route']['kwargs']['room_name']self.room_group_name = f'live_{self.room_name}'# 加入房间组await self.channel_layer.group_add(self.room_group_name,self.channel_name)await self.accept()async def disconnect(self, close_code):# 离开房间组await self.channel_layer.group_discard(self.room_group_name,self.channel_name)async def receive(self, text_data):data = json.loads(text_data)message_type = data['type']# 根据消息类型处理不同的事件if message_type == 'offer':await self.channel_layer.group_send(self.room_group_name,{'type': 'relay_offer','offer': data['offer'],'user_id': data['user_id']})elif message_type == 'answer':await self.channel_layer.group_send(self.room_group_name,{'type': 'relay_answer','answer': data['answer'],'user_id': data['user_id']})elif message_type == 'ice_candidate':await self.channel_layer.group_send(self.room_group_name,{'type': 'relay_ice_candidate','candidate': data['candidate'],'user_id': data['user_id']})async def relay_offer(self, event):await self.send(text_data=json.dumps({'type': 'offer','offer': event['offer'],'user_id': event['user_id']}))async def relay_answer(self, event):await self.send(text_data=json.dumps({'type': 'answer','answer': event['answer'],'user_id': event['user_id']}))async def relay_ice_candidate(self, event):await self.send(text_data=json.dumps({'type': 'ice_candidate','candidate': event['candidate'],'user_id': event['user_id']}))
5.2 数据分析与学习报告
使用Django ORM和Pandas生成学习报告:
# analytics/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import permissions
import pandas as pd
from django.db.models import Avg, Count, Sum, F, ExpressionWrapper, fields
from django.db.models.functions import TruncDay
from enrollments.models import Enrollment, Progress
from courses.models import Course, Lesson
from quizzes.models import QuizAttemptclass CourseAnalyticsView(APIView):"""课程数据分析"""permission_classes = [permissions.IsAuthenticated]def get(self, request, course_id):# 验证是否为课程创建者try:course = Course.objects.get(id=course_id, instructor=request.user)except Course.DoesNotExist:return Response({"detail": "未找到课程或无权限查看"}, status=404)# 获取课程注册数据enrollments = Enrollment.objects.filter(course=course)total_students = enrollments.count()# 计算完成率completion_rate = enrollments.filter(completed=True).count() / total_students if total_students > 0 else 0# 获取每日注册人数daily_enrollments = (enrollments.annotate(date=TruncDay('enrolled_at')).values('date').annotate(count=Count('id')).order_by('date'))# 获取测验平均分quiz_avg_scores = (QuizAttempt.objects.filter(quiz__lesson__section__course=course,completed_at__isnull=False).values('quiz__title').annotate(avg_score=Avg('score')).order_by('quiz__lesson__section__order', 'quiz__lesson__order'))# 获取视频观看数据video_engagement = (Progress.objects.filter(enrollment__course=course,lesson__video_url__isnull=False).values('lesson__title').annotate(completion_rate=Count('id', filter=F('completed') == True) / Count('id')).order_by('lesson__section__order', 'lesson__order'))return Response({'total_students': total_students,'completion_rate': completion_rate,'daily_enrollments': daily_enrollments,'quiz_avg_scores': quiz_avg_scores,'video_engagement': video_engagement})
5.3 社区与讨论功能
实现课程讨论区:
# discussions/models.py
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentTypeclass Comment(models.Model):"""评论模型,可关联到课程、小节或其他评论"""user = models.ForeignKey('users.User', on_delete=models.CASCADE)content = models.TextField()created_at = models.DateTimeField(auto_now_add=True)updated_at = models.DateTimeField(auto_now=True)# 通用外键,可以关联到任何模型content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)object_id = models.PositiveIntegerField()content_object = GenericForeignKey('content_type', 'object_id')# 回复关系parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, related_name='replies')class Meta:ordering = ['-created_at']class Like(models.Model):"""点赞模型"""user = models.ForeignKey('users.User', on_delete=models.CASCADE)comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name='likes')created_at = models.DateTimeField(auto_now_add=True)class Meta:unique_together = ('user', 'comment')
6. 部署与优化
6.1 Docker容器化
创建Docker配置文件:
# docker-compose.yml
version: '3'services:db:image: postgres:14volumes:- postgres_data:/var/lib/postgresql/data/env_file:- ./.envenvironment:- POSTGRES_PASSWORD=${DB_PASSWORD}- POSTGRES_USER=${DB_USER}- POSTGRES_DB=${DB_NAME}redis:image: redis:6web:build: .command: gunicorn eduplatform.wsgi:application --bind 0.0.0.0:8000volumes:- .:/app- static_volume:/app/staticfiles- media_volume:/app/mediaexpose:- 8000depends_on:- db- redisenv_file:- ./.envcelery:build: .command: celery -A eduplatform worker -l INFOvolumes:- .:/appdepends_on:- db- redisenv_file:- ./.envnginx:image: nginx:1.21ports:- 80:80- 443:443volumes:- ./nginx/conf.d:/etc/nginx/conf.d- static_volume:/var/www/staticfiles- media_volume:/var/www/media- ./nginx/certbot/conf:/etc/letsencrypt- ./nginx/certbot/www:/var/www/certbotdepends_on:- webvolumes:postgres_data:static_volume:media_volume:
6.2 性能优化
实现缓存和数据库优化:
# settings.py
CACHES = {'default': {'BACKEND': 'django_redis.cache.RedisCache','LOCATION': f"redis://{os.environ.get('REDIS_HOST', 'localhost')}:6379/1",'OPTIONS': {'CLIENT_CLASS': 'django_redis.client.DefaultClient',}}
}# 缓存会话
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'# 缓存设置
CACHE_MIDDLEWARE_SECONDS = 60 * 15 # 15分钟
CACHE_MIDDLEWARE_KEY_PREFIX = 'eduplatform'
使用装饰器缓存视图:
# courses/views.py
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_pageclass CourseListView(APIView):@method_decorator(cache_page(60 * 5)) # 缓存5分钟def get(self, request):# ...处理逻辑
6.3 安全性配置
实现安全性最佳实践:
# settings.py
# HTTPS设置
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True# CORS设置
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOWED_ORIGINS = ['https://example.com','https://www.example.com',
]# 内容安全策略
CSP_DEFAULT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'", 'fonts.googleapis.com')
CSP_SCRIPT_SRC = ("'self'", "'unsafe-inline'", "'unsafe-eval'")
CSP_FONT_SRC = ("'self'", 'fonts.gstatic.com')
CSP_IMG_SRC = ("'self'", 'data:', 'blob:', '*.amazonaws.com')
CSP_MEDIA_SRC = ("'self'", 'data:', 'blob:', '*.amazonaws.com')
7. 项目总结与展望
7.1 开发过程中的经验教训
在开发这个在线教育平台的过程中,我们积累了以下经验:
- 前期规划的重要性: 详细的需求分析和系统设计对项目成功至关重要
- 技术选型需谨慎: Django生态系统提供了丰富的工具,但需根据项目特点选择合适的组件
- 性能优化要前置: 从项目初期就考虑缓存策略和数据库优化,避免后期重构
- 安全性不容忽视: 特别是涉及支付和用户数据的教育平台,安全措施必须全面
7.2 未来功能规划
平台未来可以考虑添加以下功能:
- AI辅助学习: 集成GPT等AI模型,提供个性化学习建议和自动答疑
- 移动应用: 开发配套的iOS/Android应用,支持离线学习
- 区块链证书: 使用区块链技术颁发不可篡改的课程完成证书
- 多语言支持: 添加国际化支持,扩大用户群体
- AR/VR内容: 支持增强现实和虚拟现实教学内容
7.3 商业化路径
平台可以通过以下方式实现商业化:
- 佣金模式: 向讲师收取课程销售佣金
- 订阅制: 提供高级会员服务,包含独家内容和功能
- 企业版: 为企业和教育机构提供定制化解决方案
- API服务: 向第三方开发者提供教育内容和功能API
Directory Content Summary
Source Directory: ./eduplatform
Directory Structure
eduplatform/manage.pycourses/admin.pyapps.pymodels.py__init__.pymigrations/eduplatform/asgi.pysettings.pyurls.pywsgi.py__init__.pyquizzes/admin.pyapps.pymodels.pyurls.pyviews.py__init__.pyapi/serializers.pyurls.pyviews.py__init__.pymigrations/static/css/quiz.cssjs/quiz.jstemplates/courses/quizzes/quiz_analytics.htmlquiz_detail.htmlquiz_list.htmlquiz_results.htmlquiz_take.htmlusers/admin.pyapps.pymodels.py__init__.pymigrations/
File Contents
manage.py
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sysdef main():"""Run administrative tasks."""os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eduplatform.settings')try:from django.core.management import execute_from_command_lineexcept ImportError as exc:raise ImportError("Couldn't import Django. Are you sure it's installed?") from excexecute_from_command_line(sys.argv)if __name__ == '__main__':main()
courses\admin.py
"""
Admin configuration for the courses app.
"""
from django.contrib import admin
from .models import Course, Section, Lesson, Enrollment, Progressclass SectionInline(admin.TabularInline):"""Inline admin for sections within a course."""model = Sectionextra = 1class LessonInline(admin.TabularInline):"""Inline admin for lessons within a section."""model = Lessonextra = 1@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):"""Admin configuration for the Course model."""list_display = ('title', 'instructor', 'price', 'is_published', 'created_at')list_filter = ('is_published', 'created_at')search_fields = ('title', 'description', 'instructor__username')prepopulated_fields = {'slug': ('title',)}inlines = [SectionInline]@admin.register(Section)
class SectionAdmin(admin.ModelAdmin):"""Admin configuration for the Section model."""list_display = ('title', 'course', 'order')list_filter = ('course',)search_fields = ('title', 'course__title')inlines = [LessonInline]@admin.register(Lesson)
class LessonAdmin(admin.ModelAdmin):"""Admin configuration for the Lesson model."""list_display = ('title', 'section', 'order', 'duration')list_filter = ('section__course',)search_fields = ('title', 'content', 'section__title')@admin.register(Enrollment)
class EnrollmentAdmin(admin.ModelAdmin):"""Admin configuration for the Enrollment model."""list_display = ('user', 'course', 'enrolled_at', 'completed')list_filter = ('completed', 'enrolled_at')search_fields = ('user__username', 'course__title')@admin.register(Progress)
class ProgressAdmin(admin.ModelAdmin):"""Admin configuration for the Progress model."""list_display = ('enrollment', 'lesson', 'completed', 'last_position', 'updated_at')list_filter = ('completed', 'updated_at')search_fields = ('enrollment__user__username', 'lesson__title')
courses\apps.py
"""
Application configuration for the courses app.
"""
from django.apps import AppConfigclass CoursesConfig(AppConfig):"""Configuration for the courses app."""default_auto_field = 'django.db.models.BigAutoField'name = 'courses'
courses\models.py
"""
Models for the courses app.
"""
from django.db import models
from django.utils.text import slugify
from django.conf import settingsclass Course(models.Model):"""Course model representing a course in the platform."""title = models.CharField(max_length=200)slug = models.SlugField(unique=True)description = models.TextField()instructor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='courses')thumbnail = models.ImageField(upload_to='course_thumbnails/')price = models.DecimalField(max_digits=7, decimal_places=2)created_at = models.DateTimeField(auto_now_add=True)updated_at = models.DateTimeField(auto_now=True)is_published = models.BooleanField(default=False)class Meta:ordering = ['-created_at']def __str__(self):return self.titledef save(self, *args, **kwargs):if not self.slug:self.slug = slugify(self.title)super().save(*args, **kwargs)class Section(models.Model):"""Section model representing a section within a course."""course = models.ForeignKey(Course, related_name='sections', on_delete=models.CASCADE)title = models.CharField(max_length=200)order = models.PositiveIntegerField()class Meta:ordering = ['order']unique_together = ['course', 'order']def __str__(self):return f"{self.course.title} - {self.title}"class Lesson(models.Model):"""Lesson model representing a lesson within a section."""section = models.ForeignKey(Section, related_name='lessons', on_delete=models.CASCADE)title = models.CharField(max_length=200)content = models.TextField()video_url = models.URLField(blank=True)order = models.PositiveIntegerField()duration = models.PositiveIntegerField(help_text="Duration in seconds", default=0)class Meta:ordering = ['order']unique_together = ['section', 'order']def __str__(self):return f"{self.section.title} - {self.title}"class Enrollment(models.Model):"""Enrollment model representing a student enrolled in a course."""user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='enrollments')course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='enrollments')enrolled_at = models.DateTimeField(auto_now_add=True)completed = models.BooleanField(default=False)class Meta:unique_together = ['user', 'course']def __str__(self):return f"{self.user.username} enrolled in {self.course.title}"class Progress(models.Model):"""Progress model tracking a student's progress in a lesson."""enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE, related_name='progress')lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE)completed = models.BooleanField(default=False)last_position = models.PositiveIntegerField(default=0, help_text="Last video position in seconds")updated_at = models.DateTimeField(auto_now=True)class Meta:unique_together = ['enrollment', 'lesson']def __str__(self):return f"Progress for {self.enrollment.user.username} in {self.lesson.title}"
courses_init_.py
eduplatform\asgi.py
"""
ASGI config for eduplatform project.
"""import osfrom django.core.asgi import get_asgi_applicationos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eduplatform.settings')application = get_asgi_application()
eduplatform\settings.py
"""
Django settings for eduplatform project.
"""import os
from pathlib import Path# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-j2x5s7!z3r9t0q8w1e6p4y7u2i9o0p3a4s5d6f7g8h9j0k1l2'# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = TrueALLOWED_HOSTS = []# Application definition
INSTALLED_APPS = ['django.contrib.admin','django.contrib.auth','django.contrib.contenttypes','django.contrib.sessions','django.contrib.messages','django.contrib.staticfiles','rest_framework','users','courses',
]MIDDLEWARE = ['django.middleware.security.SecurityMiddleware','django.contrib.sessions.middleware.SessionMiddleware','django.middleware.common.CommonMiddleware','django.middleware.csrf.CsrfViewMiddleware','django.contrib.auth.middleware.AuthenticationMiddleware','django.contrib.messages.middleware.MessageMiddleware','django.middleware.clickjacking.XFrameOptionsMiddleware',
]ROOT_URLCONF = 'eduplatform.urls'TEMPLATES = [{'BACKEND': 'django.template.backends.django.DjangoTemplates','DIRS': [os.path.join(BASE_DIR, 'templates')],'APP_DIRS': True,'OPTIONS': {'context_processors': ['django.template.context_processors.debug','django.template.context_processors.request','django.contrib.auth.context_processors.auth','django.contrib.messages.context_processors.messages',],},},
]WSGI_APPLICATION = 'eduplatform.wsgi.application'# Database
DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3','NAME': BASE_DIR / 'db.sqlite3',}
}# Password validation
AUTH_PASSWORD_VALIDATORS = [{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',},{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',},{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',},{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',},
]# Custom user model
AUTH_USER_MODEL = 'users.User'# Internationalization
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True# Static files (CSS, JavaScript, Images)
STATIC_URL = 'static/'
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static'),
]
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'# REST Framework settings
REST_FRAMEWORK = {'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.SessionAuthentication','rest_framework.authentication.BasicAuthentication',],'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.IsAuthenticated',],'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination','PAGE_SIZE': 10,
}
eduplatform\urls.py
"""
URL configuration for eduplatform project.
"""
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import staticurlpatterns = [path('admin/', admin.site.urls),path('api/courses/', include('courses.api.urls')),path('', include('courses.urls')),
]if settings.DEBUG:urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
eduplatform\wsgi.py
"""
WSGI config for eduplatform project.
"""import osfrom django.core.wsgi import get_wsgi_applicationos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eduplatform.settings')application = get_wsgi_application()
eduplatform_init_.py
quizzes\admin.py
"""
Admin configuration for the quizzes app.
"""
from django.contrib import admin
from .models import Quiz, Question, Choice, QuizAttempt, Answer, SelectedChoiceclass ChoiceInline(admin.TabularInline):"""Inline admin for choices within a question."""model = Choiceextra = 4class QuestionInline(admin.TabularInline):"""Inline admin for questions within a quiz."""model = Questionextra = 1@admin.register(Quiz)
class QuizAdmin(admin.ModelAdmin):"""Admin configuration for the Quiz model."""list_display = ('title', 'lesson', 'time_limit', 'passing_score', 'created_at')list_filter = ('lesson__section__course', 'created_at')search_fields = ('title', 'description', 'lesson__title')inlines = [QuestionInline]@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):"""Admin configuration for the Question model."""list_display = ('text', 'quiz', 'question_type', 'points', 'order')list_filter = ('quiz', 'question_type')search_fields = ('text', 'quiz__title')inlines = [ChoiceInline]@admin.register(Choice)
class ChoiceAdmin(admin.ModelAdmin):"""Admin configuration for the Choice model."""list_display = ('text', 'question', 'is_correct', 'order')list_filter = ('question__quiz', 'is_correct')search_fields = ('text', 'question__text')class AnswerInline(admin.TabularInline):"""Inline admin for answers within a quiz attempt."""model = Answerextra = 0readonly_fields = ('question', 'text_answer', 'earned_points')@admin.register(QuizAttempt)
class QuizAttemptAdmin(admin.ModelAdmin):"""Admin configuration for the QuizAttempt model."""list_display = ('user', 'quiz', 'started_at', 'completed_at', 'score', 'passed')list_filter = ('quiz', 'passed', 'started_at')search_fields = ('user__username', 'quiz__title')readonly_fields = ('score', 'passed')inlines = [AnswerInline]class SelectedChoiceInline(admin.TabularInline):"""Inline admin for selected choices within an answer."""model = SelectedChoiceextra = 0readonly_fields = ('choice',)@admin.register(Answer)
class AnswerAdmin(admin.ModelAdmin):"""Admin configuration for the Answer model."""list_display = ('question', 'attempt', 'earned_points')list_filter = ('question__quiz', 'attempt__user')search_fields = ('question__text', 'attempt__user__username')readonly_fields = ('attempt', 'question')inlines = [SelectedChoiceInline]
quizzes\apps.py
"""
Application configuration for the quizzes app.
"""
from django.apps import AppConfigclass QuizzesConfig(AppConfig):"""Configuration for the quizzes app."""default_auto_field = 'django.db.models.BigAutoField'name = 'quizzes'
quizzes\models.py
"""
Models for the quizzes app.
"""
from django.db import models
from django.conf import settings
from courses.models import Lessonclass Quiz(models.Model):"""Quiz model representing a quiz within a lesson."""lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='quizzes')title = models.CharField(max_length=200)description = models.TextField(blank=True)time_limit = models.PositiveIntegerField(null=True, blank=True, help_text="Time limit in minutes")passing_score = models.PositiveIntegerField(default=60, help_text="Passing score in percentage")created_at = models.DateTimeField(auto_now_add=True)updated_at = models.DateTimeField(auto_now=True)class Meta:ordering = ['-created_at']verbose_name_plural = "Quizzes"def __str__(self):return self.titledef total_points(self):"""Calculate the total points for this quiz."""return sum(question.points for question in self.questions.all())class Question(models.Model):"""Question model representing a question within a quiz."""SINGLE_CHOICE = 'single'MULTIPLE_CHOICE = 'multiple'TRUE_FALSE = 'true_false'SHORT_ANSWER = 'short_answer'QUESTION_TYPES = [(SINGLE_CHOICE, '单选题'),(MULTIPLE_CHOICE, '多选题'),(TRUE_FALSE, '判断题'),(SHORT_ANSWER, '简答题'),]quiz = models.ForeignKey(Quiz, related_name='questions', on_delete=models.CASCADE)text = models.TextField()question_type = models.CharField(max_length=20, choices=QUESTION_TYPES)points = models.PositiveIntegerField(default=1)order = models.PositiveIntegerField()explanation = models.TextField(blank=True, help_text="Explanation of the correct answer")class Meta:ordering = ['order']unique_together = ['quiz', 'order']def __str__(self):return f"{self.quiz.title} - Question {self.order}"class Choice(models.Model):"""Choice model representing a choice for a question."""question = models.ForeignKey(Question, related_name='choices', on_delete=models.CASCADE)text = models.CharField(max_length=255)is_correct = models.BooleanField(default=False)order = models.PositiveIntegerField(default=0)class Meta:ordering = ['order']def __str__(self):return self.textclass QuizAttempt(models.Model):"""QuizAttempt model representing a student's attempt at a quiz."""quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name='attempts')user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='quiz_attempts')started_at = models.DateTimeField(auto_now_add=True)completed_at = models.DateTimeField(null=True, blank=True)score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)passed = models.BooleanField(default=False)class Meta:ordering = ['-started_at']def __str__(self):return f"{self.user.username}'s attempt at {self.quiz.title}"def calculate_score(self):"""Calculate the score for this attempt."""total_points = self.quiz.total_points()if total_points == 0:return 0earned_points = sum(answer.earned_points for answer in self.answers.all())score = (earned_points / total_points) * 100self.score = round(score, 2)self.passed = self.score >= self.quiz.passing_scorereturn self.scoreclass Answer(models.Model):"""Answer model representing a student's answer to a question."""attempt = models.ForeignKey(QuizAttempt, on_delete=models.CASCADE, related_name='answers')question = models.ForeignKey(Question, on_delete=models.CASCADE)text_answer = models.TextField(blank=True, null=True)earned_points = models.DecimalField(max_digits=5, decimal_places=2, default=0)class Meta:unique_together = ['attempt', 'question']def __str__(self):return f"Answer to {self.question}"class SelectedChoice(models.Model):"""SelectedChoice model representing a student's selected choice for a question."""answer = models.ForeignKey(Answer, on_delete=models.CASCADE, related_name='selected_choices')choice = models.ForeignKey(Choice, on_delete=models.CASCADE)class Meta:unique_together = ['answer', 'choice']def __str__(self):return f"Selected {self.choice.text}"
quizzes\urls.py
"""
URL patterns for the quizzes app.
"""
from django.urls import path
from . import viewsapp_name = 'quizzes'urlpatterns = [path('', views.quiz_list, name='quiz_list'),path('<int:quiz_id>/', views.quiz_detail, name='quiz_detail'),path('<int:quiz_id>/start/', views.quiz_start, name='quiz_start'),path('take/<int:attempt_id>/', views.quiz_take, name='quiz_take'),path('results/<int:attempt_id>/', views.quiz_results, name='quiz_results'),path('<int:quiz_id>/analytics/', views.quiz_analytics, name='quiz_analytics'),
]
quizzes\views.py
"""
Views for the quizzes app.
"""
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.utils import timezone
from django.db.models import Sum, Count, Q
from django.contrib import messages
from django.http import Http404
from datetime import timedelta
from .models import Quiz, QuizAttempt, Answer@login_required
def quiz_list(request):"""Display a list of quizzes available to the user."""# Get quizzes from courses the user is enrolled inquizzes = Quiz.objects.filter(lesson__section__course__enrollments__user=request.user).select_related('lesson__section__course').distinct()context = {'quizzes': quizzes,}return render(request, 'quizzes/quiz_list.html', context)@login_required
def quiz_detail(request, quiz_id):"""Display details of a quiz."""quiz = get_object_or_404(Quiz, id=quiz_id)# Check if user is enrolled in the courseif not quiz.lesson.section.course.enrollments.filter(user=request.user).exists():messages.error(request, "您需要先注册该课程才能参加测验。")return redirect('courses:course_detail', slug=quiz.lesson.section.course.slug)# Get previous attemptsprevious_attempts = QuizAttempt.objects.filter(quiz=quiz,user=request.user).order_by('-started_at')context = {'quiz': quiz,'previous_attempts': previous_attempts,}return render(request, 'quizzes/quiz_detail.html', context)@login_required
def quiz_start(request, quiz_id):"""Start a new quiz attempt."""quiz = get_object_or_404(Quiz, id=quiz_id)# Check if user is enrolled in the courseif not quiz.lesson.section.course.enrollments.filter(user=request.user).exists():messages.error(request, "您需要先注册该课程才能参加测验。")return redirect('courses:course_detail', slug=quiz.lesson.section.course.slug)# Check if there's an incomplete attemptexisting_attempt = QuizAttempt.objects.filter(quiz=quiz,user=request.user,completed_at__isnull=True).first()if existing_attempt:return redirect('quizzes:quiz_take', attempt_id=existing_attempt.id)# Create new attemptattempt = QuizAttempt.objects.create(quiz=quiz, user=request.user)return redirect('quizzes:quiz_take', attempt_id=attempt.id)@login_required
def quiz_take(request, attempt_id):"""Take a quiz."""attempt = get_object_or_404(QuizAttempt, id=attempt_id)# Check if it's the user's attemptif attempt.user != request.user:raise Http404("您无权访问此测验尝试。")# Check if the attempt is already completedif attempt.completed_at is not None:return redirect('quizzes:quiz_results', attempt_id=attempt.id)context = {'quiz': attempt.quiz,'attempt': attempt,}return render(request, 'quizzes/quiz_take.html', context)@login_required
def quiz_results(request, attempt_id):"""Display quiz results."""attempt = get_object_or_404(QuizAttempt, id=attempt_id)# Check if it's the user's attemptif attempt.user != request.user:raise Http404("您无权访问此测验结果。")# Check if the attempt is completedif attempt.completed_at is None:return redirect('quizzes:quiz_take', attempt_id=attempt.id)# Calculate completion timecompletion_time = attempt.completed_at - attempt.started_athours, remainder = divmod(completion_time.total_seconds(), 3600)minutes, seconds = divmod(remainder, 60)if hours > 0:completion_time_str = f"{int(hours)}小时 {int(minutes)}分钟 {int(seconds)}秒"else:completion_time_str = f"{int(minutes)}分钟 {int(seconds)}秒"# Get answers with related questionsanswers = Answer.objects.filter(attempt=attempt).select_related('question').prefetch_related('selected_choices__choice', 'question__choices')context = {'attempt': attempt,'answers': answers,'completion_time': completion_time_str,}return render(request, 'quizzes/quiz_results.html', context)@login_required
def quiz_analytics(request, quiz_id):"""Display analytics for a quiz (for teachers)."""quiz = get_object_or_404(Quiz, id=quiz_id)# Check if user is the instructor of the courseif quiz.lesson.section.course.instructor != request.user:messages.error(request, "您无权查看此测验的分析数据。")return redirect('courses:course_detail', slug=quiz.lesson.section.course.slug)# Get overall statisticstotal_attempts = QuizAttempt.objects.filter(quiz=quiz, completed_at__isnull=False).count()passing_attempts = QuizAttempt.objects.filter(quiz=quiz, completed_at__isnull=False, passed=True).count()if total_attempts > 0:passing_rate = (passing_attempts / total_attempts) * 100else:passing_rate = 0# Get average scoreavg_score = QuizAttempt.objects.filter(quiz=quiz, completed_at__isnull=False).aggregate(avg_score=Sum('score') / Count('id'))['avg_score'] or 0# Get question statisticsquestion_stats = []for question in quiz.questions.all():correct_count = Answer.objects.filter(question=question,attempt__completed_at__isnull=False,earned_points=question.points).count()partial_count = Answer.objects.filter(question=question,attempt__completed_at__isnull=False,earned_points__gt=0,earned_points__lt=question.points).count()incorrect_count = Answer.objects.filter(question=question,attempt__completed_at__isnull=False,earned_points=0).count()total_count = correct_count + partial_count + incorrect_countif total_count > 0:correct_rate = (correct_count / total_count) * 100partial_rate = (partial_count / total_count) * 100incorrect_rate = (incorrect_count / total_count) * 100else:correct_rate = partial_rate = incorrect_rate = 0question_stats.append({'question': question,'correct_count': correct_count,'partial_count': partial_count,'incorrect_count': incorrect_count,'total_count': total_count,'correct_rate': correct_rate,'partial_rate': partial_rate,'incorrect_rate': incorrect_rate,})context = {'quiz': quiz,'total_attempts': total_attempts,'passing_attempts': passing_attempts,'passing_rate': passing_rate,'avg_score': avg_score,'question_stats': question_stats,}return render(request, 'quizzes/quiz_analytics.html', context)
quizzes_init_.py
quizzes\api\serializers.py
"""
Serializers for the quizzes app API.
"""
from rest_framework import serializers
from ..models import Quiz, Question, Choice, QuizAttempt, Answer, SelectedChoiceclass ChoiceSerializer(serializers.ModelSerializer):"""Serializer for the Choice model."""class Meta:model = Choicefields = ['id', 'text', 'order']# Exclude is_correct to prevent cheatingclass QuestionSerializer(serializers.ModelSerializer):"""Serializer for the Question model."""choices = ChoiceSerializer(many=True, read_only=True)class Meta:model = Questionfields = ['id', 'text', 'question_type', 'points', 'order', 'choices']# Exclude explanation until after the quiz is completedclass QuizSerializer(serializers.ModelSerializer):"""Serializer for the Quiz model."""questions_count = serializers.SerializerMethodField()total_points = serializers.SerializerMethodField()class Meta:model = Quizfields = ['id', 'title', 'description', 'time_limit', 'passing_score', 'questions_count', 'total_points', 'created_at']def get_questions_count(self, obj):"""Get the number of questions in the quiz."""return obj.questions.count()def get_total_points(self, obj):"""Get the total points for the quiz."""return obj.total_points()class QuizDetailSerializer(QuizSerializer):"""Detailed serializer for the Quiz model including questions."""questions = QuestionSerializer(many=True, read_only=True)class Meta(QuizSerializer.Meta):fields = QuizSerializer.Meta.fields + ['questions']class SelectedChoiceSerializer(serializers.ModelSerializer):"""Serializer for the SelectedChoice model."""class Meta:model = SelectedChoicefields = ['choice']class AnswerSerializer(serializers.ModelSerializer):"""Serializer for the Answer model."""selected_choices = SelectedChoiceSerializer(many=True, required=False)class Meta:model = Answerfields = ['question', 'text_answer', 'selected_choices']def create(self, validated_data):"""Create an Answer with selected choices."""selected_choices_data = validated_data.pop('selected_choices', [])answer = Answer.objects.create(**validated_data)for choice_data in selected_choices_data:SelectedChoice.objects.create(answer=answer, **choice_data)return answerclass QuizAttemptSerializer(serializers.ModelSerializer):"""Serializer for the QuizAttempt model."""answers = AnswerSerializer(many=True, required=False)class Meta:model = QuizAttemptfields = ['id', 'quiz', 'started_at', 'completed_at', 'score', 'passed', 'answers']read_only_fields = ['started_at', 'completed_at', 'score', 'passed']def create(self, validated_data):"""Create a QuizAttempt with answers."""answers_data = validated_data.pop('answers', [])attempt = QuizAttempt.objects.create(**validated_data)for answer_data in answers_data:selected_choices_data = answer_data.pop('selected_choices', [])answer = Answer.objects.create(attempt=attempt, **answer_data)for choice_data in selected_choices_data:SelectedChoice.objects.create(answer=answer, **choice_data)return attemptclass QuizResultSerializer(serializers.ModelSerializer):"""Serializer for quiz results after completion."""class Meta:model = QuizAttemptfields = ['id', 'quiz', 'started_at', 'completed_at', 'score', 'passed']read_only_fields = ['id', 'quiz', 'started_at', 'completed_at', 'score', 'passed']class QuestionResultSerializer(serializers.ModelSerializer):"""Serializer for question results after quiz completion."""correct_choices = serializers.SerializerMethodField()explanation = serializers.CharField(source='question.explanation')class Meta:model = Answerfields = ['question', 'text_answer', 'earned_points', 'correct_choices', 'explanation']def get_correct_choices(self, obj):"""Get the correct choices for the question."""return Choice.objects.filter(question=obj.question, is_correct=True).values('id', 'text')
quizzes\api\urls.py
"""
URL configuration for the quizzes app API.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import viewsapp_name = 'quizzes'router = DefaultRouter()
router.register('quizzes', views.QuizViewSet, basename='quiz')
router.register('attempts', views.QuizAttemptViewSet, basename='quiz-attempt')urlpatterns = [path('', include(router.urls)),
]
quizzes\api\views.py
"""
Views for the quizzes app API.
"""
from django.utils import timezone
from django.db import transaction
from rest_framework import viewsets, status, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from ..models import Quiz, Question, QuizAttempt, Answer
from .serializers import (QuizSerializer, QuizDetailSerializer, QuizAttemptSerializer,AnswerSerializer, QuizResultSerializer, QuestionResultSerializer
)class IsTeacherOrReadOnly(permissions.BasePermission):"""Custom permission to only allow teachers to edit quizzes."""def has_permission(self, request, view):if request.method in permissions.SAFE_METHODS:return Truereturn request.user.is_authenticated and request.user.is_teacherclass QuizViewSet(viewsets.ModelViewSet):"""API endpoint for quizzes."""queryset = Quiz.objects.all()serializer_class = QuizSerializerpermission_classes = [IsTeacherOrReadOnly]def get_serializer_class(self):"""Return appropriate serializer class based on action."""if self.action == 'retrieve':return QuizDetailSerializerreturn super().get_serializer_class()def get_queryset(self):"""Filter quizzes by lesson if provided."""queryset = super().get_queryset()lesson_id = self.request.query_params.get('lesson')if lesson_id:queryset = queryset.filter(lesson_id=lesson_id)return queryset@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])def start(self, request, pk=None):"""Start a new quiz attempt."""quiz = self.get_object()# Check if there's an incomplete attemptexisting_attempt = QuizAttempt.objects.filter(quiz=quiz,user=request.user,completed_at__isnull=True).first()if existing_attempt:serializer = QuizAttemptSerializer(existing_attempt)return Response(serializer.data)# Create new attemptattempt = QuizAttempt.objects.create(quiz=quiz, user=request.user)serializer = QuizAttemptSerializer(attempt)return Response(serializer.data, status=status.HTTP_201_CREATED)class QuizAttemptViewSet(viewsets.ModelViewSet):"""API endpoint for quiz attempts."""serializer_class = QuizAttemptSerializerpermission_classes = [permissions.IsAuthenticated]def get_queryset(self):"""Return only the user's quiz attempts."""return QuizAttempt.objects.filter(user=self.request.user)@action(detail=True, methods=['post'])@transaction.atomicdef submit(self, request, pk=None):"""Submit answers for a quiz attempt."""attempt = self.get_object()# Check if the attempt is already completedif attempt.completed_at is not None:return Response({"detail": "This quiz attempt has already been submitted."},status=status.HTTP_400_BAD_REQUEST)# Process answersanswers_data = request.data.get('answers', [])for answer_data in answers_data:question_id = answer_data.get('question')text_answer = answer_data.get('text_answer')selected_choice_ids = answer_data.get('selected_choices', [])try:question = Question.objects.get(id=question_id, quiz=attempt.quiz)except Question.DoesNotExist:continue# Create or update answeranswer, created = Answer.objects.get_or_create(attempt=attempt,question=question,defaults={'text_answer': text_answer})if not created and text_answer:answer.text_answer = text_answeranswer.save()# Process selected choicesif question.question_type in [Question.SINGLE_CHOICE, Question.MULTIPLE_CHOICE, Question.TRUE_FALSE]:# Clear existing selectionsanswer.selected_choices.all().delete()# Add new selectionsfor choice_id in selected_choice_ids:try:choice = question.choices.get(id=choice_id)answer.selected_choices.create(choice=choice)except:pass# Calculate points for this answerself._calculate_points(answer)# Mark attempt as completedattempt.completed_at = timezone.now()attempt.calculate_score()attempt.save()# Return resultsreturn Response(QuizResultSerializer(attempt).data)def _calculate_points(self, answer):"""Calculate points for an answer based on question type."""question = answer.questionearned_points = 0if question.question_type == Question.SHORT_ANSWER:# For short answers, teacher will need to grade manually# We could implement AI grading here in the futureearned_points = 0elif question.question_type == Question.TRUE_FALSE or question.question_type == Question.SINGLE_CHOICE:# For true/false and single choice, all selected choices must be correctselected_choices = answer.selected_choices.all()if selected_choices.count() == 1 and selected_choices.first().choice.is_correct:earned_points = question.pointselif question.question_type == Question.MULTIPLE_CHOICE:# For multiple choice, calculate partial creditselected_choices = answer.selected_choices.all()correct_choices = question.choices.filter(is_correct=True)incorrect_choices = question.choices.filter(is_correct=False)# Count correct selectionscorrect_selected = sum(1 for sc in selected_choices if sc.choice.is_correct)# Count incorrect selectionsincorrect_selected = sum(1 for sc in selected_choices if not sc.choice.is_correct)if correct_choices.count() > 0:# Calculate score as: (correct selections - incorrect selections) / total correct choicesscore = max(0, (correct_selected - incorrect_selected) / correct_choices.count())earned_points = score * question.pointsanswer.earned_points = round(earned_points, 2)answer.save()return earned_points@action(detail=True, methods=['get'])def results(self, request, pk=None):"""Get detailed results for a completed quiz attempt."""attempt = self.get_object()# Check if the attempt is completedif attempt.completed_at is None:return Response({"detail": "This quiz attempt has not been completed yet."},status=status.HTTP_400_BAD_REQUEST)# Get quiz resultsquiz_result = QuizResultSerializer(attempt).data# Get question resultsanswers = Answer.objects.filter(attempt=attempt).select_related('question')question_results = QuestionResultSerializer(answers, many=True).datareturn Response({"quiz_result": quiz_result,"question_results": question_results})
quizzes\api_init_.py
static\css\quiz.css
/*** Quiz styling for the eduplatform project.*//* Question container styling */
.question-container {background-color: #fff;border-radius: 0.5rem;padding: 1.5rem;margin-bottom: 1.5rem;box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}.question-header {border-bottom: 1px solid #e9ecef;padding-bottom: 0.75rem;margin-bottom: 1rem;
}/* Question navigation styling */
.question-nav {display: flex;flex-wrap: wrap;gap: 0.5rem;margin-bottom: 1rem;
}.question-nav-btn {width: 2.5rem;height: 2.5rem;display: flex;align-items: center;justify-content: center;font-weight: bold;
}/* Timer styling */
#quiz-timer {font-size: 1.25rem;font-weight: bold;
}/* Form controls styling */
.form-check {margin-bottom: 0.75rem;padding: 0.5rem;border-radius: 0.25rem;transition: background-color 0.2s;
}.form-check:hover {background-color: #f8f9fa;
}.form-check-input {margin-top: 0.3rem;
}.form-check-label {margin-left: 0.5rem;font-size: 1rem;
}textarea.form-control {min-height: 120px;
}/* Quiz results styling */
.accordion-button:not(.collapsed) {background-color: #e7f5ff;color: #0d6efd;
}.accordion-button:focus {box-shadow: none;border-color: rgba(0, 0, 0, 0.125);
}.question-text {margin-bottom: 1rem;
}/* Correct/incorrect answer styling */
.list-group-item {transition: background-color 0.2s;
}.list-group-item:hover {background-color: #f8f9fa;
}/* Explanation box styling */
.explanation-box {background-color: #f8f9fa;border-left: 4px solid #0d6efd;padding: 1rem;margin-top: 1rem;
}/* Responsive adjustments */
@media (max-width: 768px) {.question-container {padding: 1rem;}.question-nav-btn {width: 2rem;height: 2rem;}
}/* Animation for timer warning */
@keyframes pulse {0% {opacity: 1;}50% {opacity: 0.5;}100% {opacity: 1;}
}.bg-danger#quiz-timer {animation: pulse 1s infinite;
}
static\js\quiz.js
/*** Quiz functionality for the eduplatform project.* Handles quiz navigation, timer, and submission.*/let quizTimer;
let timeLeft;
let currentQuestionId;
let questionStates = {};/*** Initialize the quiz functionality* @param {number} quizId - The ID of the quiz* @param {number} attemptId - The ID of the quiz attempt*/
function initQuiz(quizId, attemptId) {// Initialize question statesdocument.querySelectorAll('.question-container').forEach(question => {const questionId = question.dataset.questionId;questionStates[questionId] = {answered: false,visible: false};});// Show first question, hide othersconst questions = document.querySelectorAll('.question-container');if (questions.length > 0) {questions.forEach(q => q.style.display = 'none');questions[0].style.display = 'block';currentQuestionId = questions[0].dataset.questionId;questionStates[currentQuestionId].visible = true;// Update navigationupdateQuestionNavigation();}// Set up timer if time limit existsconst timerElement = document.getElementById('quiz-timer');if (timerElement && timerElement.dataset.timeLimit) {const timeLimit = parseInt(timerElement.dataset.timeLimit);timeLeft = timeLimit * 60; // Convert to secondsstartTimer();}// Set up event listenerssetupEventListeners(attemptId);// Track answer changestrackAnswerChanges();
}/*** Set up event listeners for quiz navigation and submission* @param {number} attemptId - The ID of the quiz attempt*/
function setupEventListeners(attemptId) {// Question navigation buttonsdocument.querySelectorAll('.next-question').forEach(button => {button.addEventListener('click', () => navigateToNextQuestion());});document.querySelectorAll('.prev-question').forEach(button => {button.addEventListener('click', () => navigateToPrevQuestion());});// Question navigation sidebardocument.querySelectorAll('.question-nav-btn').forEach(button => {button.addEventListener('click', () => {const questionId = button.dataset.questionId;showQuestion(questionId);});});// Submit buttonsdocument.getElementById('submit-quiz').addEventListener('click', () => confirmSubmit());document.getElementById('nav-submit-quiz').addEventListener('click', () => confirmSubmit());// Confirmation modal buttonsdocument.getElementById('final-submit').addEventListener('click', () => submitQuiz(attemptId));// Unanswered warning buttonsdocument.getElementById('confirm-submit').addEventListener('click', () => submitQuiz(attemptId));document.getElementById('cancel-submit').addEventListener('click', () => {document.getElementById('unanswered-warning').style.display = 'none';});
}/*** Track changes to answers and update question states*/
function trackAnswerChanges() {// Track radio buttons and checkboxesdocument.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach(input => {input.addEventListener('change', () => {const questionContainer = input.closest('.question-container');const questionId = questionContainer.dataset.questionId;questionStates[questionId].answered = true;updateQuestionNavigation();});});// Track text answersdocument.querySelectorAll('textarea').forEach(textarea => {textarea.addEventListener('input', () => {const questionContainer = textarea.closest('.question-container');const questionId = questionContainer.dataset.questionId;questionStates[questionId].answered = textarea.value.trim() !== '';updateQuestionNavigation();});});
}/*** Update the question navigation sidebar to reflect current state*/
function updateQuestionNavigation() {const navButtons = document.querySelectorAll('.question-nav-btn');navButtons.forEach((button, index) => {const questionId = button.dataset.questionId;// Remove all existing classes firstbutton.classList.remove('btn-outline-secondary', 'btn-primary', 'btn-warning');// Add appropriate class based on stateif (questionId === currentQuestionId) {button.classList.add('btn-warning'); // Current question} else if (questionStates[questionId].answered) {button.classList.add('btn-primary'); // Answered question} else {button.classList.add('btn-outline-secondary'); // Unanswered question}});
}/*** Navigate to the next question*/
function navigateToNextQuestion() {const questions = document.querySelectorAll('.question-container');let currentIndex = -1;// Find current question indexfor (let i = 0; i < questions.length; i++) {if (questions[i].dataset.questionId === currentQuestionId) {currentIndex = i;break;}}// Show next question if availableif (currentIndex < questions.length - 1) {const nextQuestion = questions[currentIndex + 1];showQuestion(nextQuestion.dataset.questionId);}
}/*** Navigate to the previous question*/
function navigateToPrevQuestion() {const questions = document.querySelectorAll('.question-container');let currentIndex = -1;// Find current question indexfor (let i = 0; i < questions.length; i++) {if (questions[i].dataset.questionId === currentQuestionId) {currentIndex = i;break;}}// Show previous question if availableif (currentIndex > 0) {const prevQuestion = questions[currentIndex - 1];showQuestion(prevQuestion.dataset.questionId);}
}/*** Show a specific question by ID* @param {string} questionId - The ID of the question to show*/
function showQuestion(questionId) {// Hide all questionsdocument.querySelectorAll('.question-container').forEach(q => {q.style.display = 'none';questionStates[q.dataset.questionId].visible = false;});// Show selected questionconst questionElement = document.getElementById(`question-${questionId}`);if (questionElement) {questionElement.style.display = 'block';currentQuestionId = questionId;questionStates[questionId].visible = true;// Update navigationupdateQuestionNavigation();}
}/*** Start the quiz timer*/
function startTimer() {const timerDisplay = document.getElementById('timer-display');quizTimer = setInterval(() => {timeLeft--;if (timeLeft <= 0) {clearInterval(quizTimer);alert('时间到!您的测验将自动提交。');submitQuiz();return;}// Update timer displayconst minutes = Math.floor(timeLeft / 60);const seconds = timeLeft % 60;timerDisplay.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;// Add warning class when time is running lowif (timeLeft <= 60) {timerDisplay.parentElement.classList.remove('bg-warning');timerDisplay.parentElement.classList.add('bg-danger');}}, 1000);
}/*** Show confirmation dialog before submitting the quiz*/
function confirmSubmit() {// Check for unanswered questionsconst unansweredCount = countUnansweredQuestions();if (unansweredCount > 0) {// Show warning in modaldocument.getElementById('modal-unanswered-warning').style.display = 'block';document.getElementById('unanswered-count').textContent = unansweredCount;} else {document.getElementById('modal-unanswered-warning').style.display = 'none';}// Show modalconst submitModal = new bootstrap.Modal(document.getElementById('submitConfirmModal'));submitModal.show();
}/*** Count the number of unanswered questions* @returns {number} The number of unanswered questions*/
function countUnansweredQuestions() {let count = 0;for (const questionId in questionStates) {if (!questionStates[questionId].answered) {count++;}}return count;
}/*** Submit the quiz* @param {number} attemptId - The ID of the quiz attempt*/
function submitQuiz(attemptId) {// Stop timer if runningif (quizTimer) {clearInterval(quizTimer);}// Collect all answersconst formData = collectAnswers();// Submit form via AJAXfetch(`/api/quizzes/attempts/${attemptId}/submit/`, {method: 'POST',headers: {'Content-Type': 'application/json','X-CSRFToken': getCookie('csrftoken')},body: JSON.stringify(formData)}).then(response => {if (!response.ok) {throw new Error('提交失败');}return response.json();}).then(data => {// Redirect to results pagewindow.location.href = `/quizzes/results/${attemptId}/`;}).catch(error => {console.error('Error:', error);alert('提交测验时出错:' + error.message);});
}/*** Collect all answers from the form* @returns {Object} The form data as a JSON object*/
function collectAnswers() {const answers = [];document.querySelectorAll('.question-container').forEach(questionContainer => {const questionId = questionContainer.dataset.questionId;const questionType = determineQuestionType(questionContainer);if (questionType === 'short_answer') {const textareaId = `question_${questionId}_text`;const textarea = document.getElementById(textareaId);if (textarea && textarea.value.trim() !== '') {answers.push({question: questionId,text_answer: textarea.value.trim()});}} else {// For single, multiple, and true/false questionsconst selectedChoices = [];const inputs = questionContainer.querySelectorAll(`input[name="question_${questionId}"]:checked`);inputs.forEach(input => {selectedChoices.push(input.value);});if (selectedChoices.length > 0) {answers.push({question: questionId,selected_choices: selectedChoices});}}});return { answers };
}/*** Determine the question type based on the input elements* @param {HTMLElement} questionContainer - The question container element* @returns {string} The question type*/
function determineQuestionType(questionContainer) {if (questionContainer.querySelector('textarea')) {return 'short_answer';} else if (questionContainer.querySelector('input[type="checkbox"]')) {return 'multiple';} else {return 'single'; // Includes true_false}
}/*** Get a cookie by name* @param {string} name - The name of the cookie* @returns {string} The cookie value*/
function getCookie(name) {let cookieValue = null;if (document.cookie && document.cookie !== '') {const cookies = document.cookie.split(';');for (let i = 0; i < cookies.length; i++) {const cookie = cookies[i].trim();if (cookie.substring(0, name.length + 1) === (name + '=')) {cookieValue = decodeURIComponent(cookie.substring(name.length + 1));break;}}}return cookieValue;
}
templates\quizzes\quiz_analytics.html
{% extends "base.html" %}
{% load static %}{% block title %}{{ quiz.title }} - 测验分析{% endblock %}{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/quiz.css' %}">
<style>.stat-card {transition: transform 0.3s;}.stat-card:hover {transform: translateY(-5px);}.chart-container {height: 300px;}
</style>
{% endblock %}{% block content %}
<div class="container mt-4"><nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="{% url 'courses:course_detail' quiz.lesson.section.course.slug %}">{{ quiz.lesson.section.course.title }}</a></li><li class="breadcrumb-item"><a href="{% url 'courses:lesson_detail' quiz.lesson.id %}">{{ quiz.lesson.title }}</a></li><li class="breadcrumb-item"><a href="{% url 'quizzes:quiz_detail' quiz.id %}">{{ quiz.title }}</a></li><li class="breadcrumb-item active" aria-current="page">测验分析</li></ol></nav><div class="card mb-4"><div class="card-header bg-primary text-white"><h1 class="card-title h4 mb-0">{{ quiz.title }} - 测验分析</h1></div><div class="card-body"><div class="row mb-4"><div class="col-md-3"><div class="card bg-light stat-card"><div class="card-body text-center"><h3 class="display-4 mb-0">{{ total_attempts }}</h3><p class="text-muted">总尝试次数</p></div></div></div><div class="col-md-3"><div class="card bg-light stat-card"><div class="card-body text-center"><h3 class="display-4 mb-0">{{ passing_attempts }}</h3><p class="text-muted">通过次数</p></div></div></div><div class="col-md-3"><div class="card bg-light stat-card"><div class="card-body text-center"><h3 class="display-4 mb-0">{{ passing_rate|floatformat:1 }}%</h3><p class="text-muted">通过率</p></div></div></div><div class="col-md-3"><div class="card bg-light stat-card"><div class="card-body text-center"><h3 class="display-4 mb-0">{{ avg_score|floatformat:1 }}%</h3><p class="text-muted">平均分数</p></div></div></div></div><div class="row mb-4"><div class="col-md-6"><div class="card"><div class="card-header"><h5 class="mb-0">通过率分布</h5></div><div class="card-body"><div class="chart-container"><canvas id="passingRateChart"></canvas></div></div></div></div><div class="col-md-6"><div class="card"><div class="card-header"><h5 class="mb-0">分数分布</h5></div><div class="card-body"><div class="chart-container"><canvas id="scoreDistributionChart"></canvas></div></div></div></div></div><h4 class="mb-3">问题分析</h4><div class="table-responsive"><table class="table table-striped table-hover"><thead class="table-light"><tr><th>问题</th><th>类型</th><th>分值</th><th>正确率</th><th>部分正确</th><th>错误率</th><th>详情</th></tr></thead><tbody>{% for stat in question_stats %}<tr><td>{{ stat.question.text|truncatechars:50 }}</td><td>{{ stat.question.get_question_type_display }}</td><td>{{ stat.question.points }}</td><td><div class="progress" style="height: 20px;"><div class="progress-bar bg-success" role="progressbar" style="width: {{ stat.correct_rate }}%;" aria-valuenow="{{ stat.correct_rate }}" aria-valuemin="0" aria-valuemax="100">{{ stat.correct_rate|floatformat:1 }}%</div></div></td><td>{% if stat.question.question_type == 'multiple' or stat.question.question_type == 'short_answer' %}<div class="progress" style="height: 20px;"><div class="progress-bar bg-warning" role="progressbar" style="width: {{ stat.partial_rate }}%;" aria-valuenow="{{ stat.partial_rate }}" aria-valuemin="0" aria-valuemax="100">{{ stat.partial_rate|floatformat:1 }}%</div></div>{% else %}<span class="text-muted">不适用</span>{% endif %}</td><td><div class="progress" style="height: 20px;"><div class="progress-bar bg-danger" role="progressbar" style="width: {{ stat.incorrect_rate }}%;" aria-valuenow="{{ stat.incorrect_rate }}" aria-valuemin="0" aria-valuemax="100">{{ stat.incorrect_rate|floatformat:1 }}%</div></div></td><td><button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#questionModal{{ stat.question.id }}">查看详情</button></td></tr>{% endfor %}</tbody></table></div></div></div><!-- 导出选项 --><div class="card mb-4"><div class="card-header"><h5 class="mb-0">导出数据</h5></div><div class="card-body"><div class="row"><div class="col-md-4"><div class="d-grid"><a href="{% url 'quizzes:export_analytics_pdf' quiz.id %}" class="btn btn-danger"><i class="bi bi-file-earmark-pdf"></i> 导出为PDF</a></div></div><div class="col-md-4"><div class="d-grid"><a href="{% url 'quizzes:export_analytics_excel' quiz.id %}" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> 导出为Excel</a></div></div><div class="col-md-4"><div class="d-grid"><a href="{% url 'quizzes:export_analytics_csv' quiz.id %}" class="btn btn-primary"><i class="bi bi-file-earmark-text"></i> 导出为CSV</a></div></div></div></div></div>
</div><!-- 问题详情模态框 -->
{% for stat in question_stats %}
<div class="modal fade" id="questionModal{{ stat.question.id }}" tabindex="-1" aria-labelledby="questionModalLabel{{ stat.question.id }}" aria-hidden="true"><div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="questionModalLabel{{ stat.question.id }}">问题详情</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div><div class="modal-body"><div class="mb-3"><h6>问题文本:</h6><p>{{ stat.question.text }}</p></div><div class="mb-3"><h6>问题类型:</h6><p>{{ stat.question.get_question_type_display }}</p></div><div class="mb-3"><h6>分值:</h6><p>{{ stat.question.points }}</p></div>{% if stat.question.question_type != 'short_answer' %}<div class="mb-3"><h6>选项:</h6><ul class="list-group">{% for choice in stat.question.choices.all %}<li class="list-group-item {% if choice.is_correct %}list-group-item-success{% endif %}">{{ choice.text }}{% if choice.is_correct %}<span class="badge bg-success float-end">正确答案</span>{% endif %}</li>{% endfor %}</ul></div><div class="mb-3"><h6>选项选择分布:</h6><div class="chart-container"><canvas id="choiceDistributionChart{{ stat.question.id }}"></canvas></div></div>{% endif %}<div class="mb-3"><h6>统计数据:</h6><ul><li>总回答次数: {{ stat.total_count }}</li><li>正确回答次数: {{ stat.correct_count }} ({{ stat.correct_rate|floatformat:1 }}%)</li>{% if stat.question.question_type == 'multiple' or stat.question.question_type == 'short_answer' %}<li>部分正确次数: {{ stat.partial_count }} ({{ stat.partial_rate|floatformat:1 }}%)</li>{% endif %}<li>错误回答次数: {{ stat.incorrect_count }} ({{ stat.incorrect_rate|floatformat:1 }}%)</li></ul></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button></div></div></div>
</div>
{% endfor %}
{% endblock %}{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {// 通过率饼图const passingRateCtx = document.getElementById('passingRateChart').getContext('2d');const passingRateChart = new Chart(passingRateCtx, {type: 'pie',data: {labels: ['通过', '未通过'],datasets: [{data: [{{ passing_attempts }}, {{ total_attempts }} - {{ passing_attempts }}],backgroundColor: ['#28a745', '#dc3545'],borderWidth: 1}]},options: {responsive: true,maintainAspectRatio: false,plugins: {legend: {position: 'bottom'}}}});// 分数分布柱状图const scoreDistributionCtx = document.getElementById('scoreDistributionChart').getContext('2d');const scoreDistributionChart = new Chart(scoreDistributionCtx, {type: 'bar',data: {labels: ['0-20%', '21-40%', '41-60%', '61-80%', '81-100%'],datasets: [{label: '学生数量',data: [{{ score_ranges.0|default:0 }},{{ score_ranges.1|default:0 }},{{ score_ranges.2|default:0 }},{{ score_ranges.3|default:0 }},{{ score_ranges.4|default:0 }}],backgroundColor: '#007bff',borderWidth: 1}]},options: {responsive: true,maintainAspectRatio: false,scales: {y: {beginAtZero: true,ticks: {precision: 0}}},plugins: {legend: {display: false}}}});// 为每个问题创建选项分布图{% for stat in question_stats %}{% if stat.question.question_type != 'short_answer' %}const choiceDistributionCtx{{ stat.question.id }} = document.getElementById('choiceDistributionChart{{ stat.question.id }}').getContext('2d');const choiceDistributionChart{{ stat.question.id }} = new Chart(choiceDistributionCtx{{ stat.question.id }}, {type: 'bar',data: {labels: [{% for choice in stat.question.choices.all %}'{{ choice.text|truncatechars:30 }}',{% endfor %}],datasets: [{label: '选择次数',data: [{% for choice in stat.question.choices.all %}{{ choice.selected_count|default:0 }},{% endfor %}],backgroundColor: [{% for choice in stat.question.choices.all %}'{{ choice.is_correct|yesno:"#28a745,#dc3545" }}',{% endfor %}],borderWidth: 1}]},options: {responsive: true,maintainAspectRatio: false,scales: {y: {beginAtZero: true,ticks: {precision: 0}}},plugins: {legend: {display: false}}}});{% endif %}{% endfor %}});
</script>
{% endblock %}
templates\quizzes\quiz_detail.html
{% extends "base.html" %}
{% load static %}{% block title %}{{ quiz.title }}{% endblock %}{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/quiz.css' %}">
{% endblock %}{% block content %}
<div class="container mt-4"><nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="{% url 'courses:course_detail' quiz.lesson.section.course.slug %}">{{ quiz.lesson.section.course.title }}</a></li><li class="breadcrumb-item"><a href="{% url 'courses:lesson_detail' quiz.lesson.id %}">{{ quiz.lesson.title }}</a></li><li class="breadcrumb-item active" aria-current="page">{{ quiz.title }}</li></ol></nav><div class="card mb-4"><div class="card-header bg-primary text-white"><h1 class="card-title h4 mb-0">{{ quiz.title }}</h1></div><div class="card-body"><div class="row mb-4"><div class="col-md-8"><p>{{ quiz.description }}</p></div><div class="col-md-4"><div class="card bg-light"><div class="card-body"><h5 class="card-title">测验信息</h5><ul class="list-unstyled"><li><strong>题目数量:</strong> {{ quiz.questions_count }}</li><li><strong>总分值:</strong> {{ quiz.total_points }}</li>{% if quiz.time_limit %}<li><strong>时间限制:</strong> {{ quiz.time_limit }} 分钟</li>{% endif %}<li><strong>及格分数:</strong> {{ quiz.passing_score }}%</li></ul></div></div></div></div>{% if previous_attempts %}<div class="mb-4"><h4>历史尝试</h4><div class="table-responsive"><table class="table table-striped"><thead><tr><th>尝试时间</th><th>完成时间</th><th>分数</th><th>状态</th><th>操作</th></tr></thead><tbody>{% for attempt in previous_attempts %}<tr><td>{{ attempt.started_at|date:"Y-m-d H:i" }}</td><td>{{ attempt.completed_at|date:"Y-m-d H:i"|default:"-" }}</td><td>{% if attempt.score %}{{ attempt.score }}%{% else %}-{% endif %}</td><td>{% if attempt.completed_at %}{% if attempt.passed %}<span class="badge bg-success">通过</span>{% else %}<span class="badge bg-danger">未通过</span>{% endif %}{% else %}<span class="badge bg-warning">未完成</span>{% endif %}</td><td>{% if attempt.completed_at %}<a href="{% url 'quizzes:quiz_results' attempt.id %}" class="btn btn-sm btn-info">查看结果</a>{% else %}<a href="{% url 'quizzes:quiz_take' attempt.id %}" class="btn btn-sm btn-warning">继续</a>{% endif %}</td></tr>{% endfor %}</tbody></table></div></div>{% endif %}<div class="d-grid gap-2 col-md-6 mx-auto"><a href="{% url 'quizzes:quiz_start' quiz.id %}" class="btn btn-primary btn-lg">开始测验</a><a href="{% url 'courses:lesson_detail' quiz.lesson.id %}" class="btn btn-outline-secondary">返回课程</a></div></div></div>
</div>
{% endblock %}
templates\quizzes\quiz_list.html
{% extends "base.html" %}
{% load static %}{% block title %}课程测验{% endblock %}{% block content %}
<div class="container mt-4"><h1 class="mb-4">课程测验</h1>{% if quizzes %}<div class="row">{% for quiz in quizzes %}<div class="col-md-6 col-lg-4 mb-4"><div class="card h-100"><div class="card-body"><h5 class="card-title">{{ quiz.title }}</h5><p class="card-text">{{ quiz.description|truncatewords:20 }}</p><div class="d-flex justify-content-between align-items-center"><div><span class="badge bg-info">{{ quiz.questions_count }} 题</span><span class="badge bg-primary">{{ quiz.total_points }} 分</span>{% if quiz.time_limit %}<span class="badge bg-warning">{{ quiz.time_limit }} 分钟</span>{% endif %}</div></div></div><div class="card-footer"><a href="{% url 'quizzes:quiz_detail' quiz.id %}" class="btn btn-primary">查看测验</a></div></div></div>{% endfor %}</div>{% include "pagination.html" with page=quizzes %}{% else %}<div class="alert alert-info">当前没有可用的测验。</div>{% endif %}
</div>
{% endblock %}
templates\quizzes\quiz_results.html
{% extends "base.html" %}
{% load static %}{% block title %}{{ attempt.quiz.title }} - 测验结果{% endblock %}{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/quiz.css' %}">
{% endblock %}{% block content %}
<div class="container mt-4"><nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="{% url 'courses:course_detail' attempt.quiz.lesson.section.course.slug %}">{{ attempt.quiz.lesson.section.course.title }}</a></li><li class="breadcrumb-item"><a href="{% url 'courses:lesson_detail' attempt.quiz.lesson.id %}">{{ attempt.quiz.lesson.title }}</a></li><li class="breadcrumb-item"><a href="{% url 'quizzes:quiz_detail' attempt.quiz.id %}">{{ attempt.quiz.title }}</a></li><li class="breadcrumb-item active" aria-current="page">测验结果</li></ol></nav><div class="card mb-4"><div class="card-header bg-primary text-white"><h1 class="card-title h4 mb-0">{{ attempt.quiz.title }} - 测验结果</h1></div><div class="card-body"><div class="row mb-4"><div class="col-md-6"><h5>测验信息</h5><ul class="list-unstyled"><li><strong>开始时间:</strong> {{ attempt.started_at|date:"Y-m-d H:i:s" }}</li><li><strong>完成时间:</strong> {{ attempt.completed_at|date:"Y-m-d H:i:s" }}</li><li><strong>用时:</strong> {{ completion_time }}</li></ul></div><div class="col-md-6"><div class="card {% if attempt.passed %}bg-success{% else %}bg-danger{% endif %} text-white"><div class="card-body text-center"><h3 class="mb-0">得分: {{ attempt.score }}%</h3><p class="mt-2 mb-0">{% if attempt.passed %}恭喜!您已通过此测验。{% else %}很遗憾,您未通过此测验。通过分数为 {{ attempt.quiz.passing_score }}%。{% endif %}</p></div></div></div></div><div class="progress mb-4" style="height: 30px;"><div class="progress-bar {% if attempt.passed %}bg-success{% else %}bg-danger{% endif %}" role="progressbar" style="width: {{ attempt.score }}%;" aria-valuenow="{{ attempt.score }}" aria-valuemin="0" aria-valuemax="100">{{ attempt.score }}%</div></div><h4 class="mb-3">问题详情</h4><div class="accordion" id="questionAccordion">{% for answer in answers %}<div class="accordion-item"><h2 class="accordion-header" id="heading{{ forloop.counter }}"><button class="accordion-button {% if not forloop.first %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}" aria-expanded="{% if forloop.first %}true{% else %}false{% endif %}" aria-controls="collapse{{ forloop.counter }}"><div class="d-flex justify-content-between w-100 me-3"><div>问题 {{ forloop.counter }}: {{ answer.question.text|truncatechars:80 }}</div><div><span class="badge {% if answer.earned_points == answer.question.points %}bg-success{% elif answer.earned_points > 0 %}bg-warning{% else %}bg-danger{% endif %}">{{ answer.earned_points }}/{{ answer.question.points }} 分</span></div></div></button></h2><div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse {% if forloop.first %}show{% endif %}" aria-labelledby="heading{{ forloop.counter }}" data-bs-parent="#questionAccordion"><div class="accordion-body"><div class="question-text mb-3"><h5>{{ answer.question.text }}</h5><p class="text-muted">{{ answer.question.get_question_type_display }}</p></div>{% if answer.question.question_type == 'short_answer' %}<div class="mb-3"><h6>您的回答:</h6><div class="p-3 bg-light rounded">{{ answer.text_answer|linebreaks|default:"<em>未作答</em>" }}</div></div>{% else %}<div class="mb-3"><h6>选项:</h6><ul class="list-group">{% for choice in answer.question.choices.all %}<li class="list-group-item {% if choice.is_correct %}list-group-item-success{% endif %}{% if choice in answer.selected_choices.all|map:'choice' and not choice.is_correct %}list-group-item-danger{% endif %}">{% if choice in answer.selected_choices.all|map:'choice' %}<i class="bi bi-check-circle-fill me-2 {% if choice.is_correct %}text-success{% else %}text-danger{% endif %}"></i>{% elif choice.is_correct %}<i class="bi bi-check-circle me-2 text-success"></i>{% else %}<i class="bi bi-circle me-2"></i>{% endif %}{{ choice.text }}{% if choice.is_correct %}<span class="badge bg-success ms-2">正确答案</span>{% endif %}</li>{% endfor %}</ul></div>{% endif %}{% if answer.question.explanation %}<div class="mt-3 p-3 bg-light rounded"><h6>解析:</h6><p>{{ answer.question.explanation|linebreaks }}</p></div>{% endif %}</div></div></div>{% endfor %}</div><div class="d-flex justify-content-between mt-4"><a href="{% url 'quizzes:quiz_detail' attempt.quiz.id %}" class="btn btn-outline-secondary"><i class="bi bi-arrow-left"></i> 返回测验</a>{% if not attempt.passed %}<a href="{% url 'quizzes:quiz_start' attempt.quiz.id %}" class="btn btn-primary"><i class="bi bi-arrow-repeat"></i> 重新尝试</a>{% endif %}<a href="{% url 'courses:lesson_detail' attempt.quiz.lesson.id %}" class="btn btn-success">继续学习 <i class="bi bi-arrow-right"></i></a></div></div></div>
</div>
{% endblock %}
templates\quizzes\quiz_take.html
{% extends "base.html" %}
{% load static %}{% block title %}{{ quiz.title }} - 测验{% endblock %}{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/quiz.css' %}">
{% endblock %}{% block content %}
<div class="container-fluid mt-3"><div class="row"><div class="col-md-9"><div class="card"><div class="card-header d-flex justify-content-between align-items-center"><h1 class="h4 mb-0">{{ quiz.title }}</h1><div id="quiz-timer" class="badge bg-warning fs-6 p-2" data-time-limit="{{ quiz.time_limit }}">{% if quiz.time_limit %}<i class="bi bi-clock"></i> <span id="timer-display">{{ quiz.time_limit }}:00</span>{% endif %}</div></div><div class="card-body"><form id="quiz-form" method="post" action="{% url 'quizzes:quiz_submit' attempt.id %}">{% csrf_token %}<div id="quiz-questions">{% for question in quiz.questions.all %}<div class="question-container mb-4" id="question-{{ question.id }}" data-question-id="{{ question.id }}"><div class="question-header d-flex justify-content-between"><h5 class="mb-3">问题 {{ forloop.counter }}: {{ question.text }}</h5><span class="badge bg-info">{{ question.points }} 分</span></div>{% if question.question_type == 'single' %}<div class="mb-3">{% for choice in question.choices.all %}<div class="form-check"><input class="form-check-input" type="radio" name="question_{{ question.id }}" id="choice_{{ choice.id }}" value="{{ choice.id }}"><label class="form-check-label" for="choice_{{ choice.id }}">{{ choice.text }}</label></div>{% endfor %}</div>{% elif question.question_type == 'multiple' %}<div class="mb-3">{% for choice in question.choices.all %}<div class="form-check"><input class="form-check-input" type="checkbox" name="question_{{ question.id }}" id="choice_{{ choice.id }}" value="{{ choice.id }}"><label class="form-check-label" for="choice_{{ choice.id }}">{{ choice.text }}</label></div>{% endfor %}</div>{% elif question.question_type == 'true_false' %}<div class="mb-3">{% for choice in question.choices.all %}<div class="form-check"><input class="form-check-input" type="radio" name="question_{{ question.id }}" id="choice_{{ choice.id }}" value="{{ choice.id }}"><label class="form-check-label" for="choice_{{ choice.id }}">{{ choice.text }}</label></div>{% endfor %}</div>{% elif question.question_type == 'short_answer' %}<div class="mb-3"><textarea class="form-control" name="question_{{ question.id }}_text" id="question_{{ question.id }}_text" rows="4" placeholder="请在此输入您的答案"></textarea></div>{% endif %}<div class="d-flex justify-content-between mt-3">{% if not forloop.first %}<button type="button" class="btn btn-outline-secondary prev-question">上一题</button>{% else %}<div></div>{% endif %}{% if not forloop.last %}<button type="button" class="btn btn-primary next-question">下一题</button>{% else %}<button type="button" class="btn btn-success" id="submit-quiz">提交测验</button>{% endif %}</div></div>{% endfor %}</div><div class="alert alert-warning mt-4" id="unanswered-warning" style="display: none;"><strong>注意!</strong> 您有未回答的问题。确定要提交吗?<div class="mt-2"><button type="button" class="btn btn-sm btn-danger" id="confirm-submit">确认提交</button><button type="button" class="btn btn-sm btn-secondary" id="cancel-submit">继续答题</button></div></div></form></div></div></div><div class="col-md-3"><div class="card sticky-top" style="top: 20px;"><div class="card-header"><h5 class="mb-0">问题导航</h5></div><div class="card-body"><div class="question-nav">{% for question in quiz.questions.all %}<button type="button" class="btn btn-outline-secondary question-nav-btn mb-2" data-question-id="{{ question.id }}">{{ forloop.counter }}</button>{% endfor %}</div><div class="mt-4"><div class="d-grid gap-2"><button type="button" class="btn btn-success" id="nav-submit-quiz">提交测验</button></div></div><div class="mt-4"><div class="legend"><div class="d-flex align-items-center mb-2"><div class="btn-sm btn-outline-secondary me-2" style="width: 30px; height: 30px;"></div><span>未回答</span></div><div class="d-flex align-items-center mb-2"><div class="btn-sm btn-primary me-2" style="width: 30px; height: 30px;"></div><span>已回答</span></div><div class="d-flex align-items-center"><div class="btn-sm btn-warning me-2" style="width: 30px; height: 30px;"></div><span>当前问题</span></div></div></div></div></div></div></div>
</div><!-- 确认提交模态框 -->
<div class="modal fade" id="submitConfirmModal" tabindex="-1" aria-labelledby="submitConfirmModalLabel" aria-hidden="true"><div class="modal-dialog"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="submitConfirmModalLabel">确认提交</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div><div class="modal-body"><p>您确定要提交此测验吗?提交后将无法更改答案。</p><div id="modal-unanswered-warning" class="alert alert-warning" style="display: none;">您有 <span id="unanswered-count">0</span> 个问题尚未回答。</div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button><button type="button" class="btn btn-primary" id="final-submit">确认提交</button></div></div></div>
</div>{% endblock %}{% block extra_js %}
<script src="{% static 'js/quiz.js' %}"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initQuiz({{ quiz.id }}, {{ attempt.id }});});
</script>
{% endblock %}
users\admin.py
"""
Admin configuration for the users app.
"""
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User@admin.register(User)
class CustomUserAdmin(UserAdmin):"""Custom admin configuration for the User model."""list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_teacher')fieldsets = UserAdmin.fieldsets + (('Additional Info', {'fields': ('avatar', 'bio', 'is_teacher')}),)
users\apps.py
"""
Application configuration for the users app.
"""
from django.apps import AppConfigclass UsersConfig(AppConfig):"""Configuration for the users app."""default_auto_field = 'django.db.models.BigAutoField'name = 'users'
users\models.py
"""
User models for the eduplatform project.
"""
from django.db import models
from django.contrib.auth.models import AbstractUserclass User(AbstractUser):"""Custom user model that extends Django's AbstractUser."""avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)bio = models.TextField(blank=True)is_teacher = models.BooleanField(default=False)def __str__(self):return self.username