国产亚洲精品福利在线无卡一,国产精久久一区二区三区,亚洲精品无码国模,精品久久久久久无码专区不卡

當(dāng)前位置: 首頁 > news >正文

網(wǎng)站數(shù)據(jù)庫空間增大企業(yè)網(wǎng)站推廣公司

網(wǎng)站數(shù)據(jù)庫空間增大,企業(yè)網(wǎng)站推廣公司,頁面緊急情況訪問升級跳轉(zhuǎn),找公司做網(wǎng)站多少錢成都1. 項(xiàng)目概述 在線教育平臺已成為現(xiàn)代教育的重要組成部分,特別是在后疫情時代,遠(yuǎn)程學(xué)習(xí)的需求顯著增加。本文將詳細(xì)介紹如何使用Python的Django框架開發(fā)一個功能完善的在線教育平臺,包括系統(tǒng)設(shè)計(jì)、核心功能實(shí)現(xiàn)以及部署上線等關(guān)鍵環(huán)節(jié)。 本項(xiàng)…

1. 項(xiàng)目概述

在線教育平臺已成為現(xiàn)代教育的重要組成部分,特別是在后疫情時代,遠(yuǎn)程學(xué)習(xí)的需求顯著增加。本文將詳細(xì)介紹如何使用Python的Django框架開發(fā)一個功能完善的在線教育平臺,包括系統(tǒng)設(shè)計(jì)、核心功能實(shí)現(xiàn)以及部署上線等關(guān)鍵環(huán)節(jié)。

本項(xiàng)目旨在創(chuàng)建一個集課程管理、視頻播放、在線測驗(yàn)、學(xué)習(xí)進(jìn)度跟蹤和社區(qū)互動于一體的綜合性教育平臺,為教育機(jī)構(gòu)和個人講師提供一站式在線教學(xué)解決方案。

2. 技術(shù)棧選擇

2.1 后端技術(shù)

  • Django 4.2: 提供強(qiáng)大的ORM、認(rèn)證系統(tǒng)和管理后臺
  • Django REST Framework: 構(gòu)建RESTful API
  • Channels: 實(shí)現(xiàn)WebSocket通信,支持實(shí)時互動功能
  • Celery: 處理異步任務(wù),如郵件發(fā)送、視頻處理
  • Redis: 緩存和消息隊(duì)列
  • PostgreSQL: 主數(shù)據(jù)庫存儲

2.2 前端技術(shù)

  • Vue.js 3: 構(gòu)建響應(yīng)式用戶界面
  • Vuex: 狀態(tài)管理
  • Element Plus: UI組件庫
  • Video.js: 視頻播放器
  • Chart.js: 數(shù)據(jù)可視化
  • Axios: HTTP請求

2.3 部署與DevOps

  • Docker & Docker Compose: 容器化應(yīng)用
  • Nginx: 反向代理和靜態(tài)資源服務(wù)
  • Gunicorn: WSGI HTTP服務(wù)器
  • AWS S3/阿里云OSS: 存儲視頻和課程資料
  • GitHub Actions: CI/CD流程

3. 系統(tǒng)架構(gòu)設(shè)計(jì)

3.1 整體架構(gòu)

系統(tǒng)采用前后端分離架構(gòu):

  • 前端Vue.js應(yīng)用通過RESTful API與后端通信
  • Django后端處理業(yè)務(wù)邏輯和數(shù)據(jù)存儲
  • WebSocket提供實(shí)時通信能力
  • 媒體文件存儲在云存儲服務(wù)
  • Redis用于緩存和會話管理

3.2 數(shù)據(jù)庫設(shè)計(jì)

核心數(shù)據(jù)模型包括:

# users/models.py
class User(AbstractUser):"""擴(kuò)展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):"""課程章節(jié)"""course = models.ForeignKey(Course, related_name='sections', on_delete=models.CASCADE)title = models.CharField(max_length=200)order = models.PositiveIntegerField()class Lesson(models.Model):"""課程小節(jié)"""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):"""學(xué)生課程注冊"""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):"""學(xué)習(xí)進(jìn)度跟蹤"""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. 核心功能實(shí)現(xiàn)

4.1 用戶認(rèn)證與權(quán)限管理

使用Django內(nèi)置的認(rèn)證系統(tǒng),并擴(kuò)展為支持教師和學(xué)生角色:

# users/views.py
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import User
from .serializers import UserSerializerclass IsTeacherOrReadOnly(permissions.BasePermission):"""只允許教師修改課程內(nèi)容"""def has_permission(self, request, view):if request.method in permissions.SAFE_METHODS:return Truereturn request.user.is_authenticated and request.user.is_teacherclass UserViewSet(viewsets.ModelViewSet):queryset = User.objects.all()serializer_class = UserSerializer@action(detail=False, methods=['get'])def me(self, request):"""獲取當(dāng)前用戶信息"""serializer = self.get_serializer(request.user)return Response(serializer.data)

4.2 課程管理系統(tǒng)

實(shí)現(xiàn)課程的CRUD操作,并添加搜索和過濾功能:

# courses/views.py
from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Course, Section, Lesson
from .serializers import CourseSerializer, SectionSerializer, LessonSerializer
from users.views import IsTeacherOrReadOnlyclass CourseViewSet(viewsets.ModelViewSet):queryset = Course.objects.all()serializer_class = CourseSerializerpermission_classes = [IsTeacherOrReadOnly]filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]filterset_fields = ['instructor', 'is_published']search_fields = ['title', 'description']ordering_fields = ['created_at', 'price']def perform_create(self, serializer):serializer.save(instructor=self.request.user)

4.3 視頻播放與進(jìn)度跟蹤

使用Video.js實(shí)現(xiàn)視頻播放,并通過WebSocket實(shí)時更新學(xué)習(xí)進(jìn)度:

# frontend/src/components/VideoPlayer.vue
<template><div class="video-container"><videoref="videoPlayer"class="video-js vjs-big-play-centered"controlspreload="auto"@timeupdate="updateProgress"></video></div>
</template><script>
import videojs from 'video.js';
import 'video.js/dist/video-js.css';export default {props: {lessonId: {type: Number,required: true},videoUrl: {type: String,required: true},startPosition: {type: Number,default: 0}},data() {return {player: null,progressUpdateInterval: null,lastUpdateTime: 0};},mounted() {this.initializePlayer();},methods: {initializePlayer() {this.player = videojs(this.$refs.videoPlayer, {sources: [{ src: this.videoUrl }],fluid: true,playbackRates: [0.5, 1, 1.25, 1.5, 2]});// 設(shè)置開始位置this.player.on('loadedmetadata', () => {this.player.currentTime(this.startPosition);});},updateProgress() {const currentTime = Math.floor(this.player.currentTime());// 每15秒或視頻暫停時更新進(jìn)度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>

后端處理進(jìn)度更新:

# enrollments/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from .models import Enrollment, Progress
from courses.models import Lessonclass ProgressConsumer(AsyncWebsocketConsumer):async def connect(self):self.user = self.scope['user']if not self.user.is_authenticated:await self.close()returnawait self.accept()async def disconnect(self, close_code):passasync def receive(self, text_data):data = json.loads(text_data)lesson_id = data.get('lessonId')position = data.get('position')if lesson_id and position is not None:await self.update_progress(lesson_id, position)@database_sync_to_asyncdef update_progress(self, lesson_id, position):try:lesson = Lesson.objects.get(id=lesson_id)enrollment = Enrollment.objects.get(user=self.user,course=lesson.section.course)progress, created = Progress.objects.get_or_create(enrollment=enrollment,lesson=lesson,defaults={'last_position': position})if not created:progress.last_position = position# 如果位置超過視頻總長度的90%,標(biāo)記為已完成if position >= lesson.duration * 0.9:progress.completed = Trueprogress.save()except (Lesson.DoesNotExist, Enrollment.DoesNotExist):pass

4.4 在線測驗(yàn)系統(tǒng)

實(shí)現(xiàn)測驗(yàn)創(chuàng)建和評分功能:

# quizzes/models.py
class Quiz(models.Model):"""課程測驗(yàn)"""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):"""測驗(yàn)問題"""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):"""選擇題選項(xiàng)"""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):"""測驗(yàn)嘗試記錄"""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 支付與訂閱系統(tǒng)

集成支付寶/微信支付接口:

# payments/views.py
from django.shortcuts import redirect
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from .models import Payment
from courses.models import Course
from enrollments.models import Enrollment
from .alipay_utils import AliPayAPIclass CreatePaymentView(APIView):"""創(chuàng)建支付訂單"""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)# 創(chuàng)建支付記錄payment = Payment.objects.create(user=request.user,course=course,amount=course.price,payment_method='alipay')# 調(diào)用支付寶接口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. 高級功能實(shí)現(xiàn)

5.1 實(shí)時直播課堂

使用WebRTC和Django Channels實(shí)現(xiàn)實(shí)時直播:

# live/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumerclass LiveClassConsumer(AsyncWebsocketConsumer):async def connect(self):self.room_name = self.scope['url_route']['kwargs']['room_name']self.room_group_name = f'live_{self.room_name}'# 加入房間組await self.channel_layer.group_add(self.room_group_name,self.channel_name)await self.accept()async def disconnect(self, close_code):# 離開房間組await self.channel_layer.group_discard(self.room_group_name,self.channel_name)async def receive(self, text_data):data = json.loads(text_data)message_type = data['type']# 根據(jù)消息類型處理不同的事件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 數(shù)據(jù)分析與學(xué)習(xí)報(bào)告

使用Django ORM和Pandas生成學(xué)習(xí)報(bào)告:

# analytics/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import permissions
import pandas as pd
from django.db.models import Avg, Count, Sum, F, ExpressionWrapper, fields
from django.db.models.functions import TruncDay
from enrollments.models import Enrollment, Progress
from courses.models import Course, Lesson
from quizzes.models import QuizAttemptclass CourseAnalyticsView(APIView):"""課程數(shù)據(jù)分析"""permission_classes = [permissions.IsAuthenticated]def get(self, request, course_id):# 驗(yàn)證是否為課程創(chuàng)建者try:course = Course.objects.get(id=course_id, instructor=request.user)except Course.DoesNotExist:return Response({"detail": "未找到課程或無權(quán)限查看"}, status=404)# 獲取課程注冊數(shù)據(jù)enrollments = Enrollment.objects.filter(course=course)total_students = enrollments.count()# 計(jì)算完成率completion_rate = enrollments.filter(completed=True).count() / total_students if total_students > 0 else 0# 獲取每日注冊人數(shù)daily_enrollments = (enrollments.annotate(date=TruncDay('enrolled_at')).values('date').annotate(count=Count('id')).order_by('date'))# 獲取測驗(yàn)平均分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'))# 獲取視頻觀看數(shù)據(jù)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 社區(qū)與討論功能

實(shí)現(xiàn)課程討論區(qū):

# discussions/models.py
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentTypeclass Comment(models.Model):"""評論模型,可關(guān)聯(lián)到課程、小節(jié)或其他評論"""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)# 通用外鍵,可以關(guān)聯(lián)到任何模型content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)object_id = models.PositiveIntegerField()content_object = GenericForeignKey('content_type', 'object_id')# 回復(fù)關(guān)系parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, related_name='replies')class Meta:ordering = ['-created_at']class Like(models.Model):"""點(diǎn)贊模型"""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. 部署與優(yōu)化

6.1 Docker容器化

創(chuàng)建Docker配置文件:

# docker-compose.yml
version: '3'services:db:image: postgres:14volumes:- postgres_data:/var/lib/postgresql/data/env_file:- ./.envenvironment:- POSTGRES_PASSWORD=${DB_PASSWORD}- POSTGRES_USER=${DB_USER}- POSTGRES_DB=${DB_NAME}redis:image: redis:6web:build: .command: gunicorn eduplatform.wsgi:application --bind 0.0.0.0:8000volumes:- .:/app- static_volume:/app/staticfiles- media_volume:/app/mediaexpose:- 8000depends_on:- db- redisenv_file:- ./.envcelery:build: .command: celery -A eduplatform worker -l INFOvolumes:- .:/appdepends_on:- db- redisenv_file:- ./.envnginx:image: nginx:1.21ports:- 80:80- 443:443volumes:- ./nginx/conf.d:/etc/nginx/conf.d- static_volume:/var/www/staticfiles- media_volume:/var/www/media- ./nginx/certbot/conf:/etc/letsencrypt- ./nginx/certbot/www:/var/www/certbotdepends_on:- webvolumes:postgres_data:static_volume:media_volume:

6.2 性能優(yōu)化

實(shí)現(xiàn)緩存和數(shù)據(jù)庫優(yōu)化:

# 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'# 緩存設(shè)置
CACHE_MIDDLEWARE_SECONDS = 60 * 15  # 15分鐘
CACHE_MIDDLEWARE_KEY_PREFIX = 'eduplatform'

使用裝飾器緩存視圖:

# courses/views.py
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_pageclass CourseListView(APIView):@method_decorator(cache_page(60 * 5))  # 緩存5分鐘def get(self, request):# ...處理邏輯

6.3 安全性配置

實(shí)現(xiàn)安全性最佳實(shí)踐:

# settings.py
# HTTPS設(shè)置
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True# CORS設(shè)置
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOWED_ORIGINS = ['https://example.com','https://www.example.com',
]# 內(nèi)容安全策略
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. 項(xiàng)目總結(jié)與展望

7.1 開發(fā)過程中的經(jīng)驗(yàn)教訓(xùn)

在開發(fā)這個在線教育平臺的過程中,我們積累了以下經(jīng)驗(yàn):

  1. 前期規(guī)劃的重要性: 詳細(xì)的需求分析和系統(tǒng)設(shè)計(jì)對項(xiàng)目成功至關(guān)重要
  2. 技術(shù)選型需謹(jǐn)慎: Django生態(tài)系統(tǒng)提供了豐富的工具,但需根據(jù)項(xiàng)目特點(diǎn)選擇合適的組件
  3. 性能優(yōu)化要前置: 從項(xiàng)目初期就考慮緩存策略和數(shù)據(jù)庫優(yōu)化,避免后期重構(gòu)
  4. 安全性不容忽視: 特別是涉及支付和用戶數(shù)據(jù)的教育平臺,安全措施必須全面

7.2 未來功能規(guī)劃

平臺未來可以考慮添加以下功能:

  1. AI輔助學(xué)習(xí): 集成GPT等AI模型,提供個性化學(xué)習(xí)建議和自動答疑
  2. 移動應(yīng)用: 開發(fā)配套的iOS/Android應(yīng)用,支持離線學(xué)習(xí)
  3. 區(qū)塊鏈證書: 使用區(qū)塊鏈技術(shù)頒發(fā)不可篡改的課程完成證書
  4. 多語言支持: 添加國際化支持,擴(kuò)大用戶群體
  5. AR/VR內(nèi)容: 支持增強(qiáng)現(xiàn)實(shí)和虛擬現(xiàn)實(shí)教學(xué)內(nèi)容

7.3 商業(yè)化路徑

平臺可以通過以下方式實(shí)現(xiàn)商業(yè)化:

  1. 傭金模式: 向講師收取課程銷售傭金
  2. 訂閱制: 提供高級會員服務(wù),包含獨(dú)家內(nèi)容和功能
  3. 企業(yè)版: 為企業(yè)和教育機(jī)構(gòu)提供定制化解決方案
  4. API服務(wù): 向第三方開發(fā)者提供教育內(nèi)容和功能API

Directory Content Summary

Source Directory: ./eduplatform

Directory Structure

eduplatform/manage.pycourses/admin.pyapps.pymodels.py__init__.pymigrations/eduplatform/asgi.pysettings.pyurls.pywsgi.py__init__.pyquizzes/admin.pyapps.pymodels.pyurls.pyviews.py__init__.pyapi/serializers.pyurls.pyviews.py__init__.pymigrations/static/css/quiz.cssjs/quiz.jstemplates/courses/quizzes/quiz_analytics.htmlquiz_detail.htmlquiz_list.htmlquiz_results.htmlquiz_take.htmlusers/admin.pyapps.pymodels.py__init__.pymigrations/

File Contents

manage.py

#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sysdef main():"""Run administrative tasks."""os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eduplatform.settings')try:from django.core.management import execute_from_command_lineexcept ImportError as exc:raise ImportError("Couldn't import Django. Are you sure it's installed?") from excexecute_from_command_line(sys.argv)if __name__ == '__main__':main()

courses\admin.py

"""
Admin configuration for the courses app.
"""
from django.contrib import admin
from .models import Course, Section, Lesson, Enrollment, Progressclass SectionInline(admin.TabularInline):"""Inline admin for sections within a course."""model = Sectionextra = 1class LessonInline(admin.TabularInline):"""Inline admin for lessons within a section."""model = Lessonextra = 1@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):"""Admin configuration for the Course model."""list_display = ('title', 'instructor', 'price', 'is_published', 'created_at')list_filter = ('is_published', 'created_at')search_fields = ('title', 'description', 'instructor__username')prepopulated_fields = {'slug': ('title',)}inlines = [SectionInline]@admin.register(Section)
class SectionAdmin(admin.ModelAdmin):"""Admin configuration for the Section model."""list_display = ('title', 'course', 'order')list_filter = ('course',)search_fields = ('title', 'course__title')inlines = [LessonInline]@admin.register(Lesson)
class LessonAdmin(admin.ModelAdmin):"""Admin configuration for the Lesson model."""list_display = ('title', 'section', 'order', 'duration')list_filter = ('section__course',)search_fields = ('title', 'content', 'section__title')@admin.register(Enrollment)
class EnrollmentAdmin(admin.ModelAdmin):"""Admin configuration for the Enrollment model."""list_display = ('user', 'course', 'enrolled_at', 'completed')list_filter = ('completed', 'enrolled_at')search_fields = ('user__username', 'course__title')@admin.register(Progress)
class ProgressAdmin(admin.ModelAdmin):"""Admin configuration for the Progress model."""list_display = ('enrollment', 'lesson', 'completed', 'last_position', 'updated_at')list_filter = ('completed', 'updated_at')search_fields = ('enrollment__user__username', 'lesson__title')

courses\apps.py

"""
Application configuration for the courses app.
"""
from django.apps import AppConfigclass CoursesConfig(AppConfig):"""Configuration for the courses app."""default_auto_field = 'django.db.models.BigAutoField'name = 'courses'

courses\models.py

"""
Models for the courses app.
"""
from django.db import models
from django.utils.text import slugify
from django.conf import settingsclass Course(models.Model):"""Course model representing a course in the platform."""title = models.CharField(max_length=200)slug = models.SlugField(unique=True)description = models.TextField()instructor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='courses')thumbnail = models.ImageField(upload_to='course_thumbnails/')price = models.DecimalField(max_digits=7, decimal_places=2)created_at = models.DateTimeField(auto_now_add=True)updated_at = models.DateTimeField(auto_now=True)is_published = models.BooleanField(default=False)class Meta:ordering = ['-created_at']def __str__(self):return self.titledef save(self, *args, **kwargs):if not self.slug:self.slug = slugify(self.title)super().save(*args, **kwargs)class Section(models.Model):"""Section model representing a section within a course."""course = models.ForeignKey(Course, related_name='sections', on_delete=models.CASCADE)title = models.CharField(max_length=200)order = models.PositiveIntegerField()class Meta:ordering = ['order']unique_together = ['course', 'order']def __str__(self):return f"{self.course.title} - {self.title}"class Lesson(models.Model):"""Lesson model representing a lesson within a section."""section = models.ForeignKey(Section, related_name='lessons', on_delete=models.CASCADE)title = models.CharField(max_length=200)content = models.TextField()video_url = models.URLField(blank=True)order = models.PositiveIntegerField()duration = models.PositiveIntegerField(help_text="Duration in seconds", default=0)class Meta:ordering = ['order']unique_together = ['section', 'order']def __str__(self):return f"{self.section.title} - {self.title}"class Enrollment(models.Model):"""Enrollment model representing a student enrolled in a course."""user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='enrollments')course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='enrollments')enrolled_at = models.DateTimeField(auto_now_add=True)completed = models.BooleanField(default=False)class Meta:unique_together = ['user', 'course']def __str__(self):return f"{self.user.username} enrolled in {self.course.title}"class Progress(models.Model):"""Progress model tracking a student's progress in a lesson."""enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE, related_name='progress')lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE)completed = models.BooleanField(default=False)last_position = models.PositiveIntegerField(default=0, help_text="Last video position in seconds")updated_at = models.DateTimeField(auto_now=True)class Meta:unique_together = ['enrollment', 'lesson']def __str__(self):return f"Progress for {self.enrollment.user.username} in {self.lesson.title}"

courses_init_.py


eduplatform\asgi.py

"""
ASGI config for eduplatform project.
"""import osfrom django.core.asgi import get_asgi_applicationos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eduplatform.settings')application = get_asgi_application()

eduplatform\settings.py

"""
Django settings for eduplatform project.
"""import os
from pathlib import Path# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-j2x5s7!z3r9t0q8w1e6p4y7u2i9o0p3a4s5d6f7g8h9j0k1l2'# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = TrueALLOWED_HOSTS = []# Application definition
INSTALLED_APPS = ['django.contrib.admin','django.contrib.auth','django.contrib.contenttypes','django.contrib.sessions','django.contrib.messages','django.contrib.staticfiles','rest_framework','users','courses',
]MIDDLEWARE = ['django.middleware.security.SecurityMiddleware','django.contrib.sessions.middleware.SessionMiddleware','django.middleware.common.CommonMiddleware','django.middleware.csrf.CsrfViewMiddleware','django.contrib.auth.middleware.AuthenticationMiddleware','django.contrib.messages.middleware.MessageMiddleware','django.middleware.clickjacking.XFrameOptionsMiddleware',
]ROOT_URLCONF = 'eduplatform.urls'TEMPLATES = [{'BACKEND': 'django.template.backends.django.DjangoTemplates','DIRS': [os.path.join(BASE_DIR, 'templates')],'APP_DIRS': True,'OPTIONS': {'context_processors': ['django.template.context_processors.debug','django.template.context_processors.request','django.contrib.auth.context_processors.auth','django.contrib.messages.context_processors.messages',],},},
]WSGI_APPLICATION = 'eduplatform.wsgi.application'# Database
DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3','NAME': BASE_DIR / 'db.sqlite3',}
}# Password validation
AUTH_PASSWORD_VALIDATORS = [{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',},{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',},{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',},{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',},
]# Custom user model
AUTH_USER_MODEL = 'users.User'# Internationalization
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True# Static files (CSS, JavaScript, Images)
STATIC_URL = 'static/'
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static'),
]
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'# REST Framework settings
REST_FRAMEWORK = {'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.SessionAuthentication','rest_framework.authentication.BasicAuthentication',],'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.IsAuthenticated',],'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination','PAGE_SIZE': 10,
}

eduplatform\urls.py

"""
URL configuration for eduplatform project.
"""
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import staticurlpatterns = [path('admin/', admin.site.urls),path('api/courses/', include('courses.api.urls')),path('', include('courses.urls')),
]if settings.DEBUG:urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

eduplatform\wsgi.py

"""
WSGI config for eduplatform project.
"""import osfrom django.core.wsgi import get_wsgi_applicationos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eduplatform.settings')application = get_wsgi_application()

eduplatform_init_.py


quizzes\admin.py

"""
Admin configuration for the quizzes app.
"""
from django.contrib import admin
from .models import Quiz, Question, Choice, QuizAttempt, Answer, SelectedChoiceclass ChoiceInline(admin.TabularInline):"""Inline admin for choices within a question."""model = Choiceextra = 4class QuestionInline(admin.TabularInline):"""Inline admin for questions within a quiz."""model = Questionextra = 1@admin.register(Quiz)
class QuizAdmin(admin.ModelAdmin):"""Admin configuration for the Quiz model."""list_display = ('title', 'lesson', 'time_limit', 'passing_score', 'created_at')list_filter = ('lesson__section__course', 'created_at')search_fields = ('title', 'description', 'lesson__title')inlines = [QuestionInline]@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):"""Admin configuration for the Question model."""list_display = ('text', 'quiz', 'question_type', 'points', 'order')list_filter = ('quiz', 'question_type')search_fields = ('text', 'quiz__title')inlines = [ChoiceInline]@admin.register(Choice)
class ChoiceAdmin(admin.ModelAdmin):"""Admin configuration for the Choice model."""list_display = ('text', 'question', 'is_correct', 'order')list_filter = ('question__quiz', 'is_correct')search_fields = ('text', 'question__text')class AnswerInline(admin.TabularInline):"""Inline admin for answers within a quiz attempt."""model = Answerextra = 0readonly_fields = ('question', 'text_answer', 'earned_points')@admin.register(QuizAttempt)
class QuizAttemptAdmin(admin.ModelAdmin):"""Admin configuration for the QuizAttempt model."""list_display = ('user', 'quiz', 'started_at', 'completed_at', 'score', 'passed')list_filter = ('quiz', 'passed', 'started_at')search_fields = ('user__username', 'quiz__title')readonly_fields = ('score', 'passed')inlines = [AnswerInline]class SelectedChoiceInline(admin.TabularInline):"""Inline admin for selected choices within an answer."""model = SelectedChoiceextra = 0readonly_fields = ('choice',)@admin.register(Answer)
class AnswerAdmin(admin.ModelAdmin):"""Admin configuration for the Answer model."""list_display = ('question', 'attempt', 'earned_points')list_filter = ('question__quiz', 'attempt__user')search_fields = ('question__text', 'attempt__user__username')readonly_fields = ('attempt', 'question')inlines = [SelectedChoiceInline]

quizzes\apps.py

"""
Application configuration for the quizzes app.
"""
from django.apps import AppConfigclass QuizzesConfig(AppConfig):"""Configuration for the quizzes app."""default_auto_field = 'django.db.models.BigAutoField'name = 'quizzes'

quizzes\models.py

"""
Models for the quizzes app.
"""
from django.db import models
from django.conf import settings
from courses.models import Lessonclass Quiz(models.Model):"""Quiz model representing a quiz within a lesson."""lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='quizzes')title = models.CharField(max_length=200)description = models.TextField(blank=True)time_limit = models.PositiveIntegerField(null=True, blank=True, help_text="Time limit in minutes")passing_score = models.PositiveIntegerField(default=60, help_text="Passing score in percentage")created_at = models.DateTimeField(auto_now_add=True)updated_at = models.DateTimeField(auto_now=True)class Meta:ordering = ['-created_at']verbose_name_plural = "Quizzes"def __str__(self):return self.titledef total_points(self):"""Calculate the total points for this quiz."""return sum(question.points for question in self.questions.all())class Question(models.Model):"""Question model representing a question within a quiz."""SINGLE_CHOICE = 'single'MULTIPLE_CHOICE = 'multiple'TRUE_FALSE = 'true_false'SHORT_ANSWER = 'short_answer'QUESTION_TYPES = [(SINGLE_CHOICE, '單選題'),(MULTIPLE_CHOICE, '多選題'),(TRUE_FALSE, '判斷題'),(SHORT_ANSWER, '簡答題'),]quiz = models.ForeignKey(Quiz, related_name='questions', on_delete=models.CASCADE)text = models.TextField()question_type = models.CharField(max_length=20, choices=QUESTION_TYPES)points = models.PositiveIntegerField(default=1)order = models.PositiveIntegerField()explanation = models.TextField(blank=True, help_text="Explanation of the correct answer")class Meta:ordering = ['order']unique_together = ['quiz', 'order']def __str__(self):return f"{self.quiz.title} - Question {self.order}"class Choice(models.Model):"""Choice model representing a choice for a question."""question = models.ForeignKey(Question, related_name='choices', on_delete=models.CASCADE)text = models.CharField(max_length=255)is_correct = models.BooleanField(default=False)order = models.PositiveIntegerField(default=0)class Meta:ordering = ['order']def __str__(self):return self.textclass QuizAttempt(models.Model):"""QuizAttempt model representing a student's attempt at a quiz."""quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name='attempts')user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='quiz_attempts')started_at = models.DateTimeField(auto_now_add=True)completed_at = models.DateTimeField(null=True, blank=True)score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)passed = models.BooleanField(default=False)class Meta:ordering = ['-started_at']def __str__(self):return f"{self.user.username}'s attempt at {self.quiz.title}"def calculate_score(self):"""Calculate the score for this attempt."""total_points = self.quiz.total_points()if total_points == 0:return 0earned_points = sum(answer.earned_points for answer in self.answers.all())score = (earned_points / total_points) * 100self.score = round(score, 2)self.passed = self.score >= self.quiz.passing_scorereturn self.scoreclass Answer(models.Model):"""Answer model representing a student's answer to a question."""attempt = models.ForeignKey(QuizAttempt, on_delete=models.CASCADE, related_name='answers')question = models.ForeignKey(Question, on_delete=models.CASCADE)text_answer = models.TextField(blank=True, null=True)earned_points = models.DecimalField(max_digits=5, decimal_places=2, default=0)class Meta:unique_together = ['attempt', 'question']def __str__(self):return f"Answer to {self.question}"class SelectedChoice(models.Model):"""SelectedChoice model representing a student's selected choice for a question."""answer = models.ForeignKey(Answer, on_delete=models.CASCADE, related_name='selected_choices')choice = models.ForeignKey(Choice, on_delete=models.CASCADE)class Meta:unique_together = ['answer', 'choice']def __str__(self):return f"Selected {self.choice.text}"

quizzes\urls.py

"""
URL patterns for the quizzes app.
"""
from django.urls import path
from . import viewsapp_name = 'quizzes'urlpatterns = [path('', views.quiz_list, name='quiz_list'),path('<int:quiz_id>/', views.quiz_detail, name='quiz_detail'),path('<int:quiz_id>/start/', views.quiz_start, name='quiz_start'),path('take/<int:attempt_id>/', views.quiz_take, name='quiz_take'),path('results/<int:attempt_id>/', views.quiz_results, name='quiz_results'),path('<int:quiz_id>/analytics/', views.quiz_analytics, name='quiz_analytics'),
]

quizzes\views.py

"""
Views for the quizzes app.
"""
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.utils import timezone
from django.db.models import Sum, Count, Q
from django.contrib import messages
from django.http import Http404
from datetime import timedelta
from .models import Quiz, QuizAttempt, Answer@login_required
def quiz_list(request):"""Display a list of quizzes available to the user."""# Get quizzes from courses the user is enrolled inquizzes = Quiz.objects.filter(lesson__section__course__enrollments__user=request.user).select_related('lesson__section__course').distinct()context = {'quizzes': quizzes,}return render(request, 'quizzes/quiz_list.html', context)@login_required
def quiz_detail(request, quiz_id):"""Display details of a quiz."""quiz = get_object_or_404(Quiz, id=quiz_id)# Check if user is enrolled in the courseif not quiz.lesson.section.course.enrollments.filter(user=request.user).exists():messages.error(request, "您需要先注冊該課程才能參加測驗(yàn)。")return redirect('courses:course_detail', slug=quiz.lesson.section.course.slug)# Get previous attemptsprevious_attempts = QuizAttempt.objects.filter(quiz=quiz,user=request.user).order_by('-started_at')context = {'quiz': quiz,'previous_attempts': previous_attempts,}return render(request, 'quizzes/quiz_detail.html', context)@login_required
def quiz_start(request, quiz_id):"""Start a new quiz attempt."""quiz = get_object_or_404(Quiz, id=quiz_id)# Check if user is enrolled in the courseif not quiz.lesson.section.course.enrollments.filter(user=request.user).exists():messages.error(request, "您需要先注冊該課程才能參加測驗(yàn)。")return redirect('courses:course_detail', slug=quiz.lesson.section.course.slug)# Check if there's an incomplete attemptexisting_attempt = QuizAttempt.objects.filter(quiz=quiz,user=request.user,completed_at__isnull=True).first()if existing_attempt:return redirect('quizzes:quiz_take', attempt_id=existing_attempt.id)# Create new attemptattempt = QuizAttempt.objects.create(quiz=quiz, user=request.user)return redirect('quizzes:quiz_take', attempt_id=attempt.id)@login_required
def quiz_take(request, attempt_id):"""Take a quiz."""attempt = get_object_or_404(QuizAttempt, id=attempt_id)# Check if it's the user's attemptif attempt.user != request.user:raise Http404("您無權(quán)訪問此測驗(yàn)嘗試。")# Check if the attempt is already completedif attempt.completed_at is not None:return redirect('quizzes:quiz_results', attempt_id=attempt.id)context = {'quiz': attempt.quiz,'attempt': attempt,}return render(request, 'quizzes/quiz_take.html', context)@login_required
def quiz_results(request, attempt_id):"""Display quiz results."""attempt = get_object_or_404(QuizAttempt, id=attempt_id)# Check if it's the user's attemptif attempt.user != request.user:raise Http404("您無權(quán)訪問此測驗(yàn)結(jié)果。")# Check if the attempt is completedif attempt.completed_at is None:return redirect('quizzes:quiz_take', attempt_id=attempt.id)# Calculate completion timecompletion_time = attempt.completed_at - attempt.started_athours, remainder = divmod(completion_time.total_seconds(), 3600)minutes, seconds = divmod(remainder, 60)if hours > 0:completion_time_str = f"{int(hours)}小時 {int(minutes)}分鐘 {int(seconds)}秒"else:completion_time_str = f"{int(minutes)}分鐘 {int(seconds)}秒"# Get answers with related questionsanswers = Answer.objects.filter(attempt=attempt).select_related('question').prefetch_related('selected_choices__choice', 'question__choices')context = {'attempt': attempt,'answers': answers,'completion_time': completion_time_str,}return render(request, 'quizzes/quiz_results.html', context)@login_required
def quiz_analytics(request, quiz_id):"""Display analytics for a quiz (for teachers)."""quiz = get_object_or_404(Quiz, id=quiz_id)# Check if user is the instructor of the courseif quiz.lesson.section.course.instructor != request.user:messages.error(request, "您無權(quán)查看此測驗(yàn)的分析數(shù)據(jù)。")return redirect('courses:course_detail', slug=quiz.lesson.section.course.slug)# Get overall statisticstotal_attempts = QuizAttempt.objects.filter(quiz=quiz, completed_at__isnull=False).count()passing_attempts = QuizAttempt.objects.filter(quiz=quiz, completed_at__isnull=False, passed=True).count()if total_attempts > 0:passing_rate = (passing_attempts / total_attempts) * 100else:passing_rate = 0# Get average scoreavg_score = QuizAttempt.objects.filter(quiz=quiz, completed_at__isnull=False).aggregate(avg_score=Sum('score') / Count('id'))['avg_score'] or 0# Get question statisticsquestion_stats = []for question in quiz.questions.all():correct_count = Answer.objects.filter(question=question,attempt__completed_at__isnull=False,earned_points=question.points).count()partial_count = Answer.objects.filter(question=question,attempt__completed_at__isnull=False,earned_points__gt=0,earned_points__lt=question.points).count()incorrect_count = Answer.objects.filter(question=question,attempt__completed_at__isnull=False,earned_points=0).count()total_count = correct_count + partial_count + incorrect_countif total_count > 0:correct_rate = (correct_count / total_count) * 100partial_rate = (partial_count / total_count) * 100incorrect_rate = (incorrect_count / total_count) * 100else:correct_rate = partial_rate = incorrect_rate = 0question_stats.append({'question': question,'correct_count': correct_count,'partial_count': partial_count,'incorrect_count': incorrect_count,'total_count': total_count,'correct_rate': correct_rate,'partial_rate': partial_rate,'incorrect_rate': incorrect_rate,})context = {'quiz': quiz,'total_attempts': total_attempts,'passing_attempts': passing_attempts,'passing_rate': passing_rate,'avg_score': avg_score,'question_stats': question_stats,}return render(request, 'quizzes/quiz_analytics.html', context)

quizzes_init_.py


quizzes\api\serializers.py

"""
Serializers for the quizzes app API.
"""
from rest_framework import serializers
from ..models import Quiz, Question, Choice, QuizAttempt, Answer, SelectedChoiceclass ChoiceSerializer(serializers.ModelSerializer):"""Serializer for the Choice model."""class Meta:model = Choicefields = ['id', 'text', 'order']# Exclude is_correct to prevent cheatingclass QuestionSerializer(serializers.ModelSerializer):"""Serializer for the Question model."""choices = ChoiceSerializer(many=True, read_only=True)class Meta:model = Questionfields = ['id', 'text', 'question_type', 'points', 'order', 'choices']# Exclude explanation until after the quiz is completedclass QuizSerializer(serializers.ModelSerializer):"""Serializer for the Quiz model."""questions_count = serializers.SerializerMethodField()total_points = serializers.SerializerMethodField()class Meta:model = Quizfields = ['id', 'title', 'description', 'time_limit', 'passing_score', 'questions_count', 'total_points', 'created_at']def get_questions_count(self, obj):"""Get the number of questions in the quiz."""return obj.questions.count()def get_total_points(self, obj):"""Get the total points for the quiz."""return obj.total_points()class QuizDetailSerializer(QuizSerializer):"""Detailed serializer for the Quiz model including questions."""questions = QuestionSerializer(many=True, read_only=True)class Meta(QuizSerializer.Meta):fields = QuizSerializer.Meta.fields + ['questions']class SelectedChoiceSerializer(serializers.ModelSerializer):"""Serializer for the SelectedChoice model."""class Meta:model = SelectedChoicefields = ['choice']class AnswerSerializer(serializers.ModelSerializer):"""Serializer for the Answer model."""selected_choices = SelectedChoiceSerializer(many=True, required=False)class Meta:model = Answerfields = ['question', 'text_answer', 'selected_choices']def create(self, validated_data):"""Create an Answer with selected choices."""selected_choices_data = validated_data.pop('selected_choices', [])answer = Answer.objects.create(**validated_data)for choice_data in selected_choices_data:SelectedChoice.objects.create(answer=answer, **choice_data)return answerclass QuizAttemptSerializer(serializers.ModelSerializer):"""Serializer for the QuizAttempt model."""answers = AnswerSerializer(many=True, required=False)class Meta:model = QuizAttemptfields = ['id', 'quiz', 'started_at', 'completed_at', 'score', 'passed', 'answers']read_only_fields = ['started_at', 'completed_at', 'score', 'passed']def create(self, validated_data):"""Create a QuizAttempt with answers."""answers_data = validated_data.pop('answers', [])attempt = QuizAttempt.objects.create(**validated_data)for answer_data in answers_data:selected_choices_data = answer_data.pop('selected_choices', [])answer = Answer.objects.create(attempt=attempt, **answer_data)for choice_data in selected_choices_data:SelectedChoice.objects.create(answer=answer, **choice_data)return attemptclass QuizResultSerializer(serializers.ModelSerializer):"""Serializer for quiz results after completion."""class Meta:model = QuizAttemptfields = ['id', 'quiz', 'started_at', 'completed_at', 'score', 'passed']read_only_fields = ['id', 'quiz', 'started_at', 'completed_at', 'score', 'passed']class QuestionResultSerializer(serializers.ModelSerializer):"""Serializer for question results after quiz completion."""correct_choices = serializers.SerializerMethodField()explanation = serializers.CharField(source='question.explanation')class Meta:model = Answerfields = ['question', 'text_answer', 'earned_points', 'correct_choices', 'explanation']def get_correct_choices(self, obj):"""Get the correct choices for the question."""return Choice.objects.filter(question=obj.question, is_correct=True).values('id', 'text')

quizzes\api\urls.py

"""
URL configuration for the quizzes app API.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import viewsapp_name = 'quizzes'router = DefaultRouter()
router.register('quizzes', views.QuizViewSet, basename='quiz')
router.register('attempts', views.QuizAttemptViewSet, basename='quiz-attempt')urlpatterns = [path('', include(router.urls)),
]

quizzes\api\views.py

"""
Views for the quizzes app API.
"""
from django.utils import timezone
from django.db import transaction
from rest_framework import viewsets, status, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from ..models import Quiz, Question, QuizAttempt, Answer
from .serializers import (QuizSerializer, QuizDetailSerializer, QuizAttemptSerializer,AnswerSerializer, QuizResultSerializer, QuestionResultSerializer
)class IsTeacherOrReadOnly(permissions.BasePermission):"""Custom permission to only allow teachers to edit quizzes."""def has_permission(self, request, view):if request.method in permissions.SAFE_METHODS:return Truereturn request.user.is_authenticated and request.user.is_teacherclass QuizViewSet(viewsets.ModelViewSet):"""API endpoint for quizzes."""queryset = Quiz.objects.all()serializer_class = QuizSerializerpermission_classes = [IsTeacherOrReadOnly]def get_serializer_class(self):"""Return appropriate serializer class based on action."""if self.action == 'retrieve':return QuizDetailSerializerreturn super().get_serializer_class()def get_queryset(self):"""Filter quizzes by lesson if provided."""queryset = super().get_queryset()lesson_id = self.request.query_params.get('lesson')if lesson_id:queryset = queryset.filter(lesson_id=lesson_id)return queryset@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])def start(self, request, pk=None):"""Start a new quiz attempt."""quiz = self.get_object()# Check if there's an incomplete attemptexisting_attempt = QuizAttempt.objects.filter(quiz=quiz,user=request.user,completed_at__isnull=True).first()if existing_attempt:serializer = QuizAttemptSerializer(existing_attempt)return Response(serializer.data)# Create new attemptattempt = QuizAttempt.objects.create(quiz=quiz, user=request.user)serializer = QuizAttemptSerializer(attempt)return Response(serializer.data, status=status.HTTP_201_CREATED)class QuizAttemptViewSet(viewsets.ModelViewSet):"""API endpoint for quiz attempts."""serializer_class = QuizAttemptSerializerpermission_classes = [permissions.IsAuthenticated]def get_queryset(self):"""Return only the user's quiz attempts."""return QuizAttempt.objects.filter(user=self.request.user)@action(detail=True, methods=['post'])@transaction.atomicdef submit(self, request, pk=None):"""Submit answers for a quiz attempt."""attempt = self.get_object()# Check if the attempt is already completedif attempt.completed_at is not None:return Response({"detail": "This quiz attempt has already been submitted."},status=status.HTTP_400_BAD_REQUEST)# Process answersanswers_data = request.data.get('answers', [])for answer_data in answers_data:question_id = answer_data.get('question')text_answer = answer_data.get('text_answer')selected_choice_ids = answer_data.get('selected_choices', [])try:question = Question.objects.get(id=question_id, quiz=attempt.quiz)except Question.DoesNotExist:continue# Create or update answeranswer, created = Answer.objects.get_or_create(attempt=attempt,question=question,defaults={'text_answer': text_answer})if not created and text_answer:answer.text_answer = text_answeranswer.save()# Process selected choicesif question.question_type in [Question.SINGLE_CHOICE, Question.MULTIPLE_CHOICE, Question.TRUE_FALSE]:# Clear existing selectionsanswer.selected_choices.all().delete()# Add new selectionsfor choice_id in selected_choice_ids:try:choice = question.choices.get(id=choice_id)answer.selected_choices.create(choice=choice)except:pass# Calculate points for this answerself._calculate_points(answer)# Mark attempt as completedattempt.completed_at = timezone.now()attempt.calculate_score()attempt.save()# Return resultsreturn Response(QuizResultSerializer(attempt).data)def _calculate_points(self, answer):"""Calculate points for an answer based on question type."""question = answer.questionearned_points = 0if question.question_type == Question.SHORT_ANSWER:# For short answers, teacher will need to grade manually# We could implement AI grading here in the futureearned_points = 0elif question.question_type == Question.TRUE_FALSE or question.question_type == Question.SINGLE_CHOICE:# For true/false and single choice, all selected choices must be correctselected_choices = answer.selected_choices.all()if selected_choices.count() == 1 and selected_choices.first().choice.is_correct:earned_points = question.pointselif question.question_type == Question.MULTIPLE_CHOICE:# For multiple choice, calculate partial creditselected_choices = answer.selected_choices.all()correct_choices = question.choices.filter(is_correct=True)incorrect_choices = question.choices.filter(is_correct=False)# Count correct selectionscorrect_selected = sum(1 for sc in selected_choices if sc.choice.is_correct)# Count incorrect selectionsincorrect_selected = sum(1 for sc in selected_choices if not sc.choice.is_correct)if correct_choices.count() > 0:# Calculate score as: (correct selections - incorrect selections) / total correct choicesscore = max(0, (correct_selected - incorrect_selected) / correct_choices.count())earned_points = score * question.pointsanswer.earned_points = round(earned_points, 2)answer.save()return earned_points@action(detail=True, methods=['get'])def results(self, request, pk=None):"""Get detailed results for a completed quiz attempt."""attempt = self.get_object()# Check if the attempt is completedif attempt.completed_at is None:return Response({"detail": "This quiz attempt has not been completed yet."},status=status.HTTP_400_BAD_REQUEST)# Get quiz resultsquiz_result = QuizResultSerializer(attempt).data# Get question resultsanswers = Answer.objects.filter(attempt=attempt).select_related('question')question_results = QuestionResultSerializer(answers, many=True).datareturn Response({"quiz_result": quiz_result,"question_results": question_results})

quizzes\api_init_.py


static\css\quiz.css

/*** Quiz styling for the eduplatform project.*//* Question container styling */
.question-container {background-color: #fff;border-radius: 0.5rem;padding: 1.5rem;margin-bottom: 1.5rem;box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}.question-header {border-bottom: 1px solid #e9ecef;padding-bottom: 0.75rem;margin-bottom: 1rem;
}/* Question navigation styling */
.question-nav {display: flex;flex-wrap: wrap;gap: 0.5rem;margin-bottom: 1rem;
}.question-nav-btn {width: 2.5rem;height: 2.5rem;display: flex;align-items: center;justify-content: center;font-weight: bold;
}/* Timer styling */
#quiz-timer {font-size: 1.25rem;font-weight: bold;
}/* Form controls styling */
.form-check {margin-bottom: 0.75rem;padding: 0.5rem;border-radius: 0.25rem;transition: background-color 0.2s;
}.form-check:hover {background-color: #f8f9fa;
}.form-check-input {margin-top: 0.3rem;
}.form-check-label {margin-left: 0.5rem;font-size: 1rem;
}textarea.form-control {min-height: 120px;
}/* Quiz results styling */
.accordion-button:not(.collapsed) {background-color: #e7f5ff;color: #0d6efd;
}.accordion-button:focus {box-shadow: none;border-color: rgba(0, 0, 0, 0.125);
}.question-text {margin-bottom: 1rem;
}/* Correct/incorrect answer styling */
.list-group-item {transition: background-color 0.2s;
}.list-group-item:hover {background-color: #f8f9fa;
}/* Explanation box styling */
.explanation-box {background-color: #f8f9fa;border-left: 4px solid #0d6efd;padding: 1rem;margin-top: 1rem;
}/* Responsive adjustments */
@media (max-width: 768px) {.question-container {padding: 1rem;}.question-nav-btn {width: 2rem;height: 2rem;}
}/* Animation for timer warning */
@keyframes pulse {0% {opacity: 1;}50% {opacity: 0.5;}100% {opacity: 1;}
}.bg-danger#quiz-timer {animation: pulse 1s infinite;
}

static\js\quiz.js

/*** Quiz functionality for the eduplatform project.* Handles quiz navigation, timer, and submission.*/let quizTimer;
let timeLeft;
let currentQuestionId;
let questionStates = {};/*** Initialize the quiz functionality* @param {number} quizId - The ID of the quiz* @param {number} attemptId - The ID of the quiz attempt*/
function initQuiz(quizId, attemptId) {// Initialize question statesdocument.querySelectorAll('.question-container').forEach(question => {const questionId = question.dataset.questionId;questionStates[questionId] = {answered: false,visible: false};});// Show first question, hide othersconst questions = document.querySelectorAll('.question-container');if (questions.length > 0) {questions.forEach(q => q.style.display = 'none');questions[0].style.display = 'block';currentQuestionId = questions[0].dataset.questionId;questionStates[currentQuestionId].visible = true;// Update navigationupdateQuestionNavigation();}// Set up timer if time limit existsconst timerElement = document.getElementById('quiz-timer');if (timerElement && timerElement.dataset.timeLimit) {const timeLimit = parseInt(timerElement.dataset.timeLimit);timeLeft = timeLimit * 60; // Convert to secondsstartTimer();}// Set up event listenerssetupEventListeners(attemptId);// Track answer changestrackAnswerChanges();
}/*** Set up event listeners for quiz navigation and submission* @param {number} attemptId - The ID of the quiz attempt*/
function setupEventListeners(attemptId) {// Question navigation buttonsdocument.querySelectorAll('.next-question').forEach(button => {button.addEventListener('click', () => navigateToNextQuestion());});document.querySelectorAll('.prev-question').forEach(button => {button.addEventListener('click', () => navigateToPrevQuestion());});// Question navigation sidebardocument.querySelectorAll('.question-nav-btn').forEach(button => {button.addEventListener('click', () => {const questionId = button.dataset.questionId;showQuestion(questionId);});});// Submit buttonsdocument.getElementById('submit-quiz').addEventListener('click', () => confirmSubmit());document.getElementById('nav-submit-quiz').addEventListener('click', () => confirmSubmit());// Confirmation modal buttonsdocument.getElementById('final-submit').addEventListener('click', () => submitQuiz(attemptId));// Unanswered warning buttonsdocument.getElementById('confirm-submit').addEventListener('click', () => submitQuiz(attemptId));document.getElementById('cancel-submit').addEventListener('click', () => {document.getElementById('unanswered-warning').style.display = 'none';});
}/*** Track changes to answers and update question states*/
function trackAnswerChanges() {// Track radio buttons and checkboxesdocument.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach(input => {input.addEventListener('change', () => {const questionContainer = input.closest('.question-container');const questionId = questionContainer.dataset.questionId;questionStates[questionId].answered = true;updateQuestionNavigation();});});// Track text answersdocument.querySelectorAll('textarea').forEach(textarea => {textarea.addEventListener('input', () => {const questionContainer = textarea.closest('.question-container');const questionId = questionContainer.dataset.questionId;questionStates[questionId].answered = textarea.value.trim() !== '';updateQuestionNavigation();});});
}/*** Update the question navigation sidebar to reflect current state*/
function updateQuestionNavigation() {const navButtons = document.querySelectorAll('.question-nav-btn');navButtons.forEach((button, index) => {const questionId = button.dataset.questionId;// Remove all existing classes firstbutton.classList.remove('btn-outline-secondary', 'btn-primary', 'btn-warning');// Add appropriate class based on stateif (questionId === currentQuestionId) {button.classList.add('btn-warning'); // Current question} else if (questionStates[questionId].answered) {button.classList.add('btn-primary'); // Answered question} else {button.classList.add('btn-outline-secondary'); // Unanswered question}});
}/*** Navigate to the next question*/
function navigateToNextQuestion() {const questions = document.querySelectorAll('.question-container');let currentIndex = -1;// Find current question indexfor (let i = 0; i < questions.length; i++) {if (questions[i].dataset.questionId === currentQuestionId) {currentIndex = i;break;}}// Show next question if availableif (currentIndex < questions.length - 1) {const nextQuestion = questions[currentIndex + 1];showQuestion(nextQuestion.dataset.questionId);}
}/*** Navigate to the previous question*/
function navigateToPrevQuestion() {const questions = document.querySelectorAll('.question-container');let currentIndex = -1;// Find current question indexfor (let i = 0; i < questions.length; i++) {if (questions[i].dataset.questionId === currentQuestionId) {currentIndex = i;break;}}// Show previous question if availableif (currentIndex > 0) {const prevQuestion = questions[currentIndex - 1];showQuestion(prevQuestion.dataset.questionId);}
}/*** Show a specific question by ID* @param {string} questionId - The ID of the question to show*/
function showQuestion(questionId) {// Hide all questionsdocument.querySelectorAll('.question-container').forEach(q => {q.style.display = 'none';questionStates[q.dataset.questionId].visible = false;});// Show selected questionconst questionElement = document.getElementById(`question-${questionId}`);if (questionElement) {questionElement.style.display = 'block';currentQuestionId = questionId;questionStates[questionId].visible = true;// Update navigationupdateQuestionNavigation();}
}/*** Start the quiz timer*/
function startTimer() {const timerDisplay = document.getElementById('timer-display');quizTimer = setInterval(() => {timeLeft--;if (timeLeft <= 0) {clearInterval(quizTimer);alert('時間到!您的測驗(yàn)將自動提交。');submitQuiz();return;}// Update timer displayconst minutes = Math.floor(timeLeft / 60);const seconds = timeLeft % 60;timerDisplay.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;// Add warning class when time is running lowif (timeLeft <= 60) {timerDisplay.parentElement.classList.remove('bg-warning');timerDisplay.parentElement.classList.add('bg-danger');}}, 1000);
}/*** Show confirmation dialog before submitting the quiz*/
function confirmSubmit() {// Check for unanswered questionsconst unansweredCount = countUnansweredQuestions();if (unansweredCount > 0) {// Show warning in modaldocument.getElementById('modal-unanswered-warning').style.display = 'block';document.getElementById('unanswered-count').textContent = unansweredCount;} else {document.getElementById('modal-unanswered-warning').style.display = 'none';}// Show modalconst submitModal = new bootstrap.Modal(document.getElementById('submitConfirmModal'));submitModal.show();
}/*** Count the number of unanswered questions* @returns {number} The number of unanswered questions*/
function countUnansweredQuestions() {let count = 0;for (const questionId in questionStates) {if (!questionStates[questionId].answered) {count++;}}return count;
}/*** Submit the quiz* @param {number} attemptId - The ID of the quiz attempt*/
function submitQuiz(attemptId) {// Stop timer if runningif (quizTimer) {clearInterval(quizTimer);}// Collect all answersconst formData = collectAnswers();// Submit form via AJAXfetch(`/api/quizzes/attempts/${attemptId}/submit/`, {method: 'POST',headers: {'Content-Type': 'application/json','X-CSRFToken': getCookie('csrftoken')},body: JSON.stringify(formData)}).then(response => {if (!response.ok) {throw new Error('提交失敗');}return response.json();}).then(data => {// Redirect to results pagewindow.location.href = `/quizzes/results/${attemptId}/`;}).catch(error => {console.error('Error:', error);alert('提交測驗(yàn)時出錯:' + error.message);});
}/*** Collect all answers from the form* @returns {Object} The form data as a JSON object*/
function collectAnswers() {const answers = [];document.querySelectorAll('.question-container').forEach(questionContainer => {const questionId = questionContainer.dataset.questionId;const questionType = determineQuestionType(questionContainer);if (questionType === 'short_answer') {const textareaId = `question_${questionId}_text`;const textarea = document.getElementById(textareaId);if (textarea && textarea.value.trim() !== '') {answers.push({question: questionId,text_answer: textarea.value.trim()});}} else {// For single, multiple, and true/false questionsconst selectedChoices = [];const inputs = questionContainer.querySelectorAll(`input[name="question_${questionId}"]:checked`);inputs.forEach(input => {selectedChoices.push(input.value);});if (selectedChoices.length > 0) {answers.push({question: questionId,selected_choices: selectedChoices});}}});return { answers };
}/*** Determine the question type based on the input elements* @param {HTMLElement} questionContainer - The question container element* @returns {string} The question type*/
function determineQuestionType(questionContainer) {if (questionContainer.querySelector('textarea')) {return 'short_answer';} else if (questionContainer.querySelector('input[type="checkbox"]')) {return 'multiple';} else {return 'single'; // Includes true_false}
}/*** Get a cookie by name* @param {string} name - The name of the cookie* @returns {string} The cookie value*/
function getCookie(name) {let cookieValue = null;if (document.cookie && document.cookie !== '') {const cookies = document.cookie.split(';');for (let i = 0; i < cookies.length; i++) {const cookie = cookies[i].trim();if (cookie.substring(0, name.length + 1) === (name + '=')) {cookieValue = decodeURIComponent(cookie.substring(name.length + 1));break;}}}return cookieValue;
}

templates\quizzes\quiz_analytics.html

{% extends "base.html" %}
{% load static %}{% block title %}{{ quiz.title }} - 測驗(yàn)分析{% 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">測驗(yàn)分析</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 }} - 測驗(yàn)分析</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">總嘗試次數(shù)</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">通過次數(shù)</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">平均分?jǐn)?shù)</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">分?jǐn)?shù)分布</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><!-- 導(dǎo)出選項(xiàng) --><div class="card mb-4"><div class="card-header"><h5 class="mb-0">導(dǎo)出數(shù)據(jù)</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> 導(dǎo)出為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> 導(dǎo)出為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> 導(dǎo)出為CSV</a></div></div></div></div></div>
</div><!-- 問題詳情模態(tài)框 -->
{% 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>選項(xiàng):</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>選項(xiàng)選擇分布:</h6><div class="chart-container"><canvas id="choiceDistributionChart{{ stat.question.id }}"></canvas></div></div>{% endif %}<div class="mb-3"><h6>統(tǒng)計(jì)數(shù)據(jù):</h6><ul><li>總回答次數(shù): {{ stat.total_count }}</li><li>正確回答次數(shù): {{ stat.correct_count }} ({{ stat.correct_rate|floatformat:1 }}%)</li>{% if stat.question.question_type == 'multiple' or stat.question.question_type == 'short_answer' %}<li>部分正確次數(shù): {{ stat.partial_count }} ({{ stat.partial_rate|floatformat:1 }}%)</li>{% endif %}<li>錯誤回答次數(shù): {{ 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">關(guān)閉</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'}}}});// 分?jǐn)?shù)分布柱狀圖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: '學(xué)生數(shù)量',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}}}});// 為每個問題創(chuàng)建選項(xiàng)分布圖{% 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: '選擇次數(shù)',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">測驗(yàn)信息</h5><ul class="list-unstyled"><li><strong>題目數(shù)量:</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>及格分?jǐn)?shù):</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>分?jǐn)?shù)</th><th>狀態(tài)</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">查看結(jié)果</a>{% else %}<a href="{% url 'quizzes:quiz_take' attempt.id %}" class="btn btn-sm btn-warning">繼續(xù)</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">開始測驗(yàn)</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 %}課程測驗(yàn){% endblock %}{% block content %}
<div class="container mt-4"><h1 class="mb-4">課程測驗(yàn)</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">查看測驗(yàn)</a></div></div></div>{% endfor %}</div>{% include "pagination.html" with page=quizzes %}{% else %}<div class="alert alert-info">當(dāng)前沒有可用的測驗(yàn)。</div>{% endif %}
</div>
{% endblock %}

templates\quizzes\quiz_results.html

{% extends "base.html" %}
{% load static %}{% block title %}{{ attempt.quiz.title }} - 測驗(yàn)結(jié)果{% 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">測驗(yàn)結(jié)果</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 }} - 測驗(yàn)結(jié)果</h1></div><div class="card-body"><div class="row mb-4"><div class="col-md-6"><h5>測驗(yàn)信息</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 %}恭喜!您已通過此測驗(yàn)。{% else %}很遺憾,您未通過此測驗(yàn)。通過分?jǐn)?shù)為 {{ 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>選項(xiàng):</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> 返回測驗(yàn)</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">繼續(xù)學(xué)習(xí) <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 }} - 測驗(yàn){% 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="請?jiān)诖溯斎肽拇鸢?#34;></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">提交測驗(yàn)</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">確認(rèn)提交</button><button type="button" class="btn btn-sm btn-secondary" id="cancel-submit">繼續(xù)答題</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">問題導(dǎo)航</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">提交測驗(yàn)</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>當(dāng)前問題</span></div></div></div></div></div></div></div>
</div><!-- 確認(rèn)提交模態(tài)框 -->
<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">確認(rèn)提交</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div><div class="modal-body"><p>您確定要提交此測驗(yàn)嗎?提交后將無法更改答案。</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">確認(rèn)提交</button></div></div></div>
</div>{% endblock %}{% block extra_js %}
<script src="{% static 'js/quiz.js' %}"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initQuiz({{ quiz.id }}, {{ attempt.id }});});
</script>
{% endblock %}

users\admin.py

"""
Admin configuration for the users app.
"""
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User@admin.register(User)
class CustomUserAdmin(UserAdmin):"""Custom admin configuration for the User model."""list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_teacher')fieldsets = UserAdmin.fieldsets + (('Additional Info', {'fields': ('avatar', 'bio', 'is_teacher')}),)

users\apps.py

"""
Application configuration for the users app.
"""
from django.apps import AppConfigclass UsersConfig(AppConfig):"""Configuration for the users app."""default_auto_field = 'django.db.models.BigAutoField'name = 'users'

users\models.py

"""
User models for the eduplatform project.
"""
from django.db import models
from django.contrib.auth.models import AbstractUserclass User(AbstractUser):"""Custom user model that extends Django's AbstractUser."""avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)bio = models.TextField(blank=True)is_teacher = models.BooleanField(default=False)def __str__(self):return self.username
http://m.aloenet.com.cn/news/37387.html

相關(guān)文章:

  • 桂林旅游網(wǎng)seo關(guān)鍵詞布局
  • 賭粉在哪個平臺引流南昌seo
  • 南京網(wǎng)站制作百家號恢復(fù)正常百度
  • 經(jīng)營性網(wǎng)站可以進(jìn)行非經(jīng)營行網(wǎng)站備案嗎代刷網(wǎng)站推廣快速
  • 站點(diǎn)推廣是什么意思關(guān)鍵詞密度
  • 怎么搭建網(wǎng)站后臺日本比分算1:1
  • 做印刷網(wǎng)站公司哪家好熱詞搜索排行榜
  • 做動漫的網(wǎng)站長沙網(wǎng)絡(luò)營銷公司
  • 南京建設(shè)工程交易中心網(wǎng)站seo的內(nèi)容主要有哪些方面
  • 做茶葉網(wǎng)站的素材上海網(wǎng)絡(luò)公司seo
  • 做公司網(wǎng)站需要多少錢杭州百度首頁優(yōu)化
  • 廊坊網(wǎng)站關(guān)鍵詞優(yōu)化seo關(guān)鍵詞是什么
  • 如何在騰訊云做網(wǎng)站福州排名seo公司
  • 制作網(wǎng)站的知識免費(fèi)推廣方式有哪些
  • 做網(wǎng)站前端網(wǎng)絡(luò)營銷試題庫及答案
  • 做噯噯的網(wǎng)站科學(xué)新概念外鏈平臺
  • 第三方網(wǎng)站開發(fā)優(yōu)缺點(diǎn)銷售渠道及方式
  • 廣告圖文制作用哪個軟件seoul是啥意思
  • 網(wǎng)站開發(fā)和app的區(qū)別優(yōu)化大師使用方法
  • 不用編程做APP和響應(yīng)式網(wǎng)站百度手機(jī)助手下載安卓版
  • 買公司的網(wǎng)站建設(shè)軟文寫作營銷
  • 銅川網(wǎng)站建設(shè)報(bào)價網(wǎng)絡(luò)推廣的主要工作內(nèi)容
  • 專門做網(wǎng)站需要敲代碼么旺道seo優(yōu)化軟件怎么用
  • 做網(wǎng)站宣傳圖片google全球推廣
  • 珠海建網(wǎng)站專業(yè)公司專業(yè)seo優(yōu)化推廣
  • 如何做網(wǎng)站條幅閃圖搜索關(guān)鍵詞怎么讓排名靠前
  • wordpress頭像網(wǎng)站百度ai人工智能平臺
  • 該怎么給做網(wǎng)站的提頁面需求深圳網(wǎng)絡(luò)推廣網(wǎng)絡(luò)
  • 廣東省廣州市白云區(qū)太和鎮(zhèn)名風(fēng)seo軟件
  • 中新生態(tài)城建設(shè)局門戶網(wǎng)站昆明seo案例