Python项目-基于Django的在线教育平台开发

发布于:2025-03-09 ⋅ 阅读:(18) ⋅ 点赞:(0)

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 UserSerializer

class IsTeacherOrReadOnly(permissions.BasePermission):
    """只允许教师修改课程内容"""
    def has_permission(self, request, view):
        if request.method in permissions.SAFE_METHODS:
            return True
        return request.user.is_authenticated and request.user.is_teacher

class 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 IsTeacherOrReadOnly

class CourseViewSet(viewsets.ModelViewSet):
    queryset = Course.objects.all()
    serializer_class = CourseSerializer
    permission_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">
    <video
      ref="videoPlayer"
      class="video-js vjs-big-play-centered"
      controls
      preload="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 Lesson

class ProgressConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.user = self.scope['user']
        if not self.user.is_authenticated:
            await self.close()
            return
            
        await self.accept()
        
    async def disconnect(self, close_code):
        pass
        
    async 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_async
    def 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 = True
                progress.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 AliPayAPI

class 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 AsyncWebsocketConsumer

class 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 QuizAttempt

class 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 ContentType

class 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:14
    volumes:
      - postgres_data:/var/lib/postgresql/data/
    env_file:
      - ./.env
    environment:
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_DB=${DB_NAME}
      
  redis:
    image: redis:6
    
  web:
    build: .
    command: gunicorn eduplatform.wsgi:application --bind 0.0.0.0:8000
    volumes:
      - .:/app
      - static_volume:/app/staticfiles
      - media_volume:/app/media
    expose:
      - 8000
    depends_on:
      - db
      - redis
    env_file:
      - ./.env
      
  celery:
    build: .
    command: celery -A eduplatform worker -l INFO
    volumes:
      - .:/app
    depends_on:
      - db
      - redis
    env_file:
      - ./.env
      
  nginx:
    image: nginx:1.21
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./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/certbot
    depends_on:
      - web

volumes:
  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_page

class 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 开发过程中的经验教训

在开发这个在线教育平台的过程中,我们积累了以下经验:

  1. 前期规划的重要性: 详细的需求分析和系统设计对项目成功至关重要
  2. 技术选型需谨慎: Django生态系统提供了丰富的工具,但需根据项目特点选择合适的组件
  3. 性能优化要前置: 从项目初期就考虑缓存策略和数据库优化,避免后期重构
  4. 安全性不容忽视: 特别是涉及支付和用户数据的教育平台,安全措施必须全面

7.2 未来功能规划

平台未来可以考虑添加以下功能:

  1. AI辅助学习: 集成GPT等AI模型,提供个性化学习建议和自动答疑
  2. 移动应用: 开发配套的iOS/Android应用,支持离线学习
  3. 区块链证书: 使用区块链技术颁发不可篡改的课程完成证书
  4. 多语言支持: 添加国际化支持,扩大用户群体
  5. AR/VR内容: 支持增强现实和虚拟现实教学内容

7.3 商业化路径

平台可以通过以下方式实现商业化:

  1. 佣金模式: 向讲师收取课程销售佣金
  2. 订阅制: 提供高级会员服务,包含独家内容和功能
  3. 企业版: 为企业和教育机构提供定制化解决方案
  4. API服务: 向第三方开发者提供教育内容和功能API

Directory Content Summary

Source Directory: ./eduplatform

Directory Structure

eduplatform/
  manage.py
  courses/
    admin.py
    apps.py
    models.py
    __init__.py
    migrations/
  eduplatform/
    asgi.py
    settings.py
    urls.py
    wsgi.py
    __init__.py
  quizzes/
    admin.py
    apps.py
    models.py
    urls.py
    views.py
    __init__.py
    api/
      serializers.py
      urls.py
      views.py
      __init__.py
    migrations/
  static/
    css/
      quiz.css
    js/
      quiz.js
  templates/
    courses/
    quizzes/
      quiz_analytics.html
      quiz_detail.html
      quiz_list.html
      quiz_results.html
      quiz_take.html
  users/
    admin.py
    apps.py
    models.py
    __init__.py
    migrations/

File Contents

manage.py

#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    """Run administrative tasks."""
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eduplatform.settings')
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed?"
        ) from exc
    execute_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, Progress


class SectionInline(admin.TabularInline):
    """
    Inline admin for sections within a course.
    """
    model = Section
    extra = 1


class LessonInline(admin.TabularInline):
    """
    Inline admin for lessons within a section.
    """
    model = Lesson
    extra = 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 AppConfig


class 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 settings


class 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.title
    
    def 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 os

from django.core.asgi import get_asgi_application

os.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 = True

ALLOWED_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 static

urlpatterns = [
    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 os

from django.core.wsgi import get_wsgi_application

os.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, SelectedChoice


class ChoiceInline(admin.TabularInline):
    """
    Inline admin for choices within a question.
    """
    model = Choice
    extra = 4


class QuestionInline(admin.TabularInline):
    """
    Inline admin for questions within a quiz.
    """
    model = Question
    extra = 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 = Answer
    extra = 0
    readonly_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 = SelectedChoice
    extra = 0
    readonly_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 AppConfig


class 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 Lesson


class 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.title
    
    def 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.text


class 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 0
            
        earned_points = sum(answer.earned_points for answer in self.answers.all())
        score = (earned_points / total_points) * 100
        self.score = round(score, 2)
        self.passed = self.score >= self.quiz.passing_score
        return self.score


class 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 views

app_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 in
    quizzes = 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 course
    if 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 attempts
    previous_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 course
    if 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 attempt
    existing_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 attempt
    attempt = 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 attempt
    if attempt.user != request.user:
        raise Http404("您无权访问此测验尝试。")
    
    # Check if the attempt is already completed
    if 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 attempt
    if attempt.user != request.user:
        raise Http404("您无权访问此测验结果。")
    
    # Check if the attempt is completed
    if attempt.completed_at is None:
        return redirect('quizzes:quiz_take', attempt_id=attempt.id)
    
    # Calculate completion time
    completion_time = attempt.completed_at - attempt.started_at
    hours, 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 questions
    answers = 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 course
    if quiz.lesson.section.course.instructor != request.user:
        messages.error(request, "您无权查看此测验的分析数据。")
        return redirect('courses:course_detail', slug=quiz.lesson.section.course.slug)
    
    # Get overall statistics
    total_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) * 100
    else:
        passing_rate = 0
    
    # Get average score
    avg_score = QuizAttempt.objects.filter(
        quiz=quiz, 
        completed_at__isnull=False
    ).aggregate(avg_score=Sum('score') / Count('id'))['avg_score'] or 0
    
    # Get question statistics
    question_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_count
        
        if total_count > 0:
            correct_rate = (correct_count / total_count) * 100
            partial_rate = (partial_count / total_count) * 100
            incorrect_rate = (incorrect_count / total_count) * 100
        else:
            correct_rate = partial_rate = incorrect_rate = 0
        
        question_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, SelectedChoice


class ChoiceSerializer(serializers.ModelSerializer):
    """
    Serializer for the Choice model.
    """
    class Meta:
        model = Choice
        fields = ['id', 'text', 'order']
        # Exclude is_correct to prevent cheating


class QuestionSerializer(serializers.ModelSerializer):
    """
    Serializer for the Question model.
    """
    choices = ChoiceSerializer(many=True, read_only=True)
    
    class Meta:
        model = Question
        fields = ['id', 'text', 'question_type', 'points', 'order', 'choices']
        # Exclude explanation until after the quiz is completed


class QuizSerializer(serializers.ModelSerializer):
    """
    Serializer for the Quiz model.
    """
    questions_count = serializers.SerializerMethodField()
    total_points = serializers.SerializerMethodField()
    
    class Meta:
        model = Quiz
        fields = ['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 = SelectedChoice
        fields = ['choice']


class AnswerSerializer(serializers.ModelSerializer):
    """
    Serializer for the Answer model.
    """
    selected_choices = SelectedChoiceSerializer(many=True, required=False)
    
    class Meta:
        model = Answer
        fields = ['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 answer


class QuizAttemptSerializer(serializers.ModelSerializer):
    """
    Serializer for the QuizAttempt model.
    """
    answers = AnswerSerializer(many=True, required=False)
    
    class Meta:
        model = QuizAttempt
        fields = ['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 attempt


class QuizResultSerializer(serializers.ModelSerializer):
    """
    Serializer for quiz results after completion.
    """
    class Meta:
        model = QuizAttempt
        fields = ['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 = Answer
        fields = ['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 views

app_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 True
        return request.user.is_authenticated and request.user.is_teacher


class QuizViewSet(viewsets.ModelViewSet):
    """
    API endpoint for quizzes.
    """
    queryset = Quiz.objects.all()
    serializer_class = QuizSerializer
    permission_classes = [IsTeacherOrReadOnly]
    
    def get_serializer_class(self):
        """
        Return appropriate serializer class based on action.
        """
        if self.action == 'retrieve':
            return QuizDetailSerializer
        return 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 attempt
        existing_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 attempt
        attempt = 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 = QuizAttemptSerializer
    permission_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.atomic
    def submit(self, request, pk=None):
        """
        Submit answers for a quiz attempt.
        """
        attempt = self.get_object()
        
        # Check if the attempt is already completed
        if attempt.completed_at is not None:
            return Response(
                {"detail": "This quiz attempt has already been submitted."},
                status=status.HTTP_400_BAD_REQUEST
            )
        
        # Process answers
        answers_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 answer
            answer, 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_answer
                answer.save()
            
            # Process selected choices
            if question.question_type in [Question.SINGLE_CHOICE, Question.MULTIPLE_CHOICE, Question.TRUE_FALSE]:
                # Clear existing selections
                answer.selected_choices.all().delete()
                
                # Add new selections
                for 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 answer
            self._calculate_points(answer)
        
        # Mark attempt as completed
        attempt.completed_at = timezone.now()
        attempt.calculate_score()
        attempt.save()
        
        # Return results
        return Response(QuizResultSerializer(attempt).data)
    
    def _calculate_points(self, answer):
        """
        Calculate points for an answer based on question type.
        """
        question = answer.question
        earned_points = 0
        
        if question.question_type == Question.SHORT_ANSWER:
            # For short answers, teacher will need to grade manually
            # We could implement AI grading here in the future
            earned_points = 0
        elif question.question_type == Question.TRUE_FALSE or question.question_type == Question.SINGLE_CHOICE:
            # For true/false and single choice, all selected choices must be correct
            selected_choices = answer.selected_choices.all()
            if selected_choices.count() == 1 and selected_choices.first().choice.is_correct:
                earned_points = question.points
        elif question.question_type == Question.MULTIPLE_CHOICE:
            # For multiple choice, calculate partial credit
            selected_choices = answer.selected_choices.all()
            correct_choices = question.choices.filter(is_correct=True)
            incorrect_choices = question.choices.filter(is_correct=False)
            
            # Count correct selections
            correct_selected = sum(1 for sc in selected_choices if sc.choice.is_correct)
            # Count incorrect selections
            incorrect_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 choices
                score = max(0, (correct_selected - incorrect_selected) / correct_choices.count())
                earned_points = score * question.points
        
        answer.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 completed
        if attempt.completed_at is None:
            return Response(
                {"detail": "This quiz attempt has not been completed yet."},
                status=status.HTTP_400_BAD_REQUEST
            )
        
        # Get quiz results
        quiz_result = QuizResultSerializer(attempt).data
        
        # Get question results
        answers = Answer.objects.filter(attempt=attempt).select_related('question')
        question_results = QuestionResultSerializer(answers, many=True).data
        
        return 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 states
    document.querySelectorAll('.question-container').forEach(question => {
        const questionId = question.dataset.questionId;
        questionStates[questionId] = {
            answered: false,
            visible: false
        };
    });

    // Show first question, hide others
    const 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 navigation
        updateQuestionNavigation();
    }

    // Set up timer if time limit exists
    const timerElement = document.getElementById('quiz-timer');
    if (timerElement && timerElement.dataset.timeLimit) {
        const timeLimit = parseInt(timerElement.dataset.timeLimit);
        timeLeft = timeLimit * 60; // Convert to seconds
        startTimer();
    }

    // Set up event listeners
    setupEventListeners(attemptId);
    
    // Track answer changes
    trackAnswerChanges();
}

/**
 * Set up event listeners for quiz navigation and submission
 * @param {number} attemptId - The ID of the quiz attempt
 */
function setupEventListeners(attemptId) {
    // Question navigation buttons
    document.querySelectorAll('.next-question').forEach(button => {
        button.addEventListener('click', () => navigateToNextQuestion());
    });

    document.querySelectorAll('.prev-question').forEach(button => {
        button.addEventListener('click', () => navigateToPrevQuestion());
    });

    // Question navigation sidebar
    document.querySelectorAll('.question-nav-btn').forEach(button => {
        button.addEventListener('click', () => {
            const questionId = button.dataset.questionId;
            showQuestion(questionId);
        });
    });

    // Submit buttons
    document.getElementById('submit-quiz').addEventListener('click', () => confirmSubmit());
    document.getElementById('nav-submit-quiz').addEventListener('click', () => confirmSubmit());
    
    // Confirmation modal buttons
    document.getElementById('final-submit').addEventListener('click', () => submitQuiz(attemptId));
    
    // Unanswered warning buttons
    document.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 checkboxes
    document.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 answers
    document.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 first
        button.classList.remove('btn-outline-secondary', 'btn-primary', 'btn-warning');
        
        // Add appropriate class based on state
        if (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 index
    for (let i = 0; i < questions.length; i++) {
        if (questions[i].dataset.questionId === currentQuestionId) {
            currentIndex = i;
            break;
        }
    }
    
    // Show next question if available
    if (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 index
    for (let i = 0; i < questions.length; i++) {
        if (questions[i].dataset.questionId === currentQuestionId) {
            currentIndex = i;
            break;
        }
    }
    
    // Show previous question if available
    if (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 questions
    document.querySelectorAll('.question-container').forEach(q => {
        q.style.display = 'none';
        questionStates[q.dataset.questionId].visible = false;
    });
    
    // Show selected question
    const questionElement = document.getElementById(`question-${questionId}`);
    if (questionElement) {
        questionElement.style.display = 'block';
        currentQuestionId = questionId;
        questionStates[questionId].visible = true;
        
        // Update navigation
        updateQuestionNavigation();
    }
}

/**
 * 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 display
        const minutes = Math.floor(timeLeft / 60);
        const seconds = timeLeft % 60;
        timerDisplay.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
        
        // Add warning class when time is running low
        if (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 questions
    const unansweredCount = countUnansweredQuestions();
    
    if (unansweredCount > 0) {
        // Show warning in modal
        document.getElementById('modal-unanswered-warning').style.display = 'block';
        document.getElementById('unanswered-count').textContent = unansweredCount;
    } else {
        document.getElementById('modal-unanswered-warning').style.display = 'none';
    }
    
    // Show modal
    const 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 running
    if (quizTimer) {
        clearInterval(quizTimer);
    }
    
    // Collect all answers
    const formData = collectAnswers();
    
    // Submit form via AJAX
    fetch(`/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 page
        window.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 questions
            const 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 AppConfig


class 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 AbstractUser


class 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