在线教育平台已成为现代教育的重要组成部分,特别是在后疫情时代,远程学习的需求显著增加。本文将详细介绍如何使用Python的Django框架开发一个功能完善的在线教育平台,包括系统设计、核心功能实现以及部署上线等关键环节。
本项目旨在创建一个集课程管理、视频播放、在线测验、学习进度跟踪和社区互动于一体的综合性教育平台,为教育机构和个人讲师提供一站式在线教学解决方案。
系统采用前后端分离架构:
核心数据模型包括:
# 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)
使用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)
实现课程的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)
使用Video.js实现视频播放,并通过WebSocket实时更新学习进度:
# frontend/src/components/VideoPlayer.vue
后端处理进度更新:
# 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
实现测验创建和评分功能:
# 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)
集成支付宝/微信支付接口:
# 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
)
使用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']
}))
使用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
})
实现课程讨论区:
# 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')
创建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:
实现缓存和数据库优化:
# 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):
# ...处理逻辑
实现安全性最佳实践:
# 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')
在开发这个在线教育平台的过程中,我们积累了以下经验:
平台未来可以考虑添加以下功能:
平台可以通过以下方式实现商业化:
Source Directory: ./eduplatform
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/
#!/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()
"""
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')
"""
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'
"""
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}"
"""
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()
"""
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,
}
"""
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)
"""
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()
"""
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]
"""
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'
"""
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}"
"""
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('/', views.quiz_detail, name='quiz_detail'),
path('/start/', views.quiz_start, name='quiz_start'),
path('take//', views.quiz_take, name='quiz_take'),
path('results//', views.quiz_results, name='quiz_results'),
path('/analytics/', views.quiz_analytics, name='quiz_analytics'),
]
"""
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)
"""
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')
"""
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)),
]
"""
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
})
/**
* 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;
}
/**
* 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;
}
{% extends "base.html" %}
{% load static %}
{% block title %}{{ quiz.title }} - 测验分析{% endblock %}
{% block extra_css %}
{% endblock %}
{% block content %}
{{ quiz.title }} - 测验分析
{{ total_attempts }}
总尝试次数
{{ passing_attempts }}
通过次数
{{ passing_rate|floatformat:1 }}%
通过率
{{ avg_score|floatformat:1 }}%
平均分数
通过率分布
分数分布
问题分析
问题
类型
分值
正确率
部分正确
错误率
详情
{% for stat in question_stats %}
{{ stat.question.text|truncatechars:50 }}
{{ stat.question.get_question_type_display }}
{{ stat.question.points }}
{% if stat.question.question_type == 'multiple' or stat.question.question_type == 'short_answer' %}
{% else %}
不适用
{% endif %}
{% endfor %}
{% for stat in question_stats %}
{% endfor %}
{% endblock %}
{% block extra_js %}
{% endblock %}
{% extends "base.html" %}
{% load static %}
{% block title %}{{ quiz.title }}{% endblock %}
{% block extra_css %}
{% endblock %}
{% block content %}
{{ quiz.title }}
{{ quiz.description }}
测验信息
- 题目数量: {{ quiz.questions_count }}
- 总分值: {{ quiz.total_points }}
{% if quiz.time_limit %}
- 时间限制: {{ quiz.time_limit }} 分钟
{% endif %}
- 及格分数: {{ quiz.passing_score }}%
{% if previous_attempts %}
历史尝试
尝试时间
完成时间
分数
状态
操作
{% for attempt in previous_attempts %}
{{ attempt.started_at|date:"Y-m-d H:i" }}
{{ attempt.completed_at|date:"Y-m-d H:i"|default:"-" }}
{% if attempt.score %}{{ attempt.score }}%{% else %}-{% endif %}
{% if attempt.completed_at %}
{% if attempt.passed %}
通过
{% else %}
未通过
{% endif %}
{% else %}
未完成
{% endif %}
{% if attempt.completed_at %}
查看结果
{% else %}
继续
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}
{% extends "base.html" %}
{% load static %}
{% block title %}课程测验{% endblock %}
{% block content %}
课程测验
{% if quizzes %}
{% for quiz in quizzes %}
{{ quiz.title }}
{{ quiz.description|truncatewords:20 }}
{{ quiz.questions_count }} 题
{{ quiz.total_points }} 分
{% if quiz.time_limit %}
{{ quiz.time_limit }} 分钟
{% endif %}
{% endfor %}
{% include "pagination.html" with page=quizzes %}
{% else %}
当前没有可用的测验。
{% endif %}
{% endblock %}
{% extends "base.html" %}
{% load static %}
{% block title %}{{ attempt.quiz.title }} - 测验结果{% endblock %}
{% block extra_css %}
{% endblock %}
{% block content %}
{{ attempt.quiz.title }} - 测验结果
测验信息
- 开始时间: {{ attempt.started_at|date:"Y-m-d H:i:s" }}
- 完成时间: {{ attempt.completed_at|date:"Y-m-d H:i:s" }}
- 用时: {{ completion_time }}
得分: {{ attempt.score }}%
{% if attempt.passed %}
恭喜!您已通过此测验。
{% else %}
很遗憾,您未通过此测验。通过分数为 {{ attempt.quiz.passing_score }}%。
{% endif %}
问题详情
{% for answer in answers %}
{{ answer.question.text }}
{{ answer.question.get_question_type_display }}
{% if answer.question.question_type == 'short_answer' %}
您的回答:
{{ answer.text_answer|linebreaks|default:"未作答" }}
{% else %}
选项:
{% for choice in answer.question.choices.all %}
-
{% if choice in answer.selected_choices.all|map:'choice' %}
{% elif choice.is_correct %}
{% else %}
{% endif %}
{{ choice.text }}
{% if choice.is_correct %}
正确答案
{% endif %}
{% endfor %}
{% endif %}
{% if answer.question.explanation %}
解析:
{{ answer.question.explanation|linebreaks }}
{% endif %}
{% endfor %}
{% endblock %}
{% extends "base.html" %}
{% load static %}
{% block title %}{{ quiz.title }} - 测验{% endblock %}
{% block extra_css %}
{% endblock %}
{% block content %}
{{ quiz.title }}
{% if quiz.time_limit %}
{{ quiz.time_limit }}:00
{% endif %}
问题导航
{% for question in quiz.questions.all %}
{% endfor %}
未回答
已回答
当前问题
{% endblock %}
{% block extra_js %}
{% endblock %}
"""
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')}),
)
"""
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'
"""
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