在上一章,我们为电商网站项目添加了国际化功能,还创建了优惠码和商品推荐系统。在本章,会建立一个新的项目:一个在线教育平台,并创内容管理系统CMS(Content Management System)。
本章的具体内容有
我们最后一个项目就是这个在线教育平台。在这个项目中,我们将建立一个灵活的CMS系统,让讲师可以创建课程并且管理课程的内容。
为本项目建立一个虚拟环境,在终端输入如下命令:
mkdir env virtualenv env/educa source env/educa/bin/activate
在虚拟环境中安装Django与Pillow:
pip install Django==2.0.5 pip install Pillow==5.1.0
之后新建项目educa
:
django-admin startproject educa
进入educa
目录然后新建名为courses
的应用:
cd educa django-admin startapp courses
编辑settings.py
,将应用激活并且放在最上边一行:
INSTALLED_APPS = [ 'courses.apps.CoursesConfig', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', ]
之后的第一步工作,依然是定义数据模型。
我们的在线教育平台会提供很多不同主题(subject)的课程,每一个课程会被划分为一定数量的课程章节(module),每个章节里边又有一定数量的内容(content)。对于一个课程来说,里边使用到的内容类型很多,包含文本,文件,图片甚至视频,下边的是一个课程的例子:
Subject 1 Course 1 Module 1 Content 1 (image) Content 2 (text) Module 2 Content 3 (text) Content 4 (file) Content 5 (video) ......
来建立课程的数据模型,编辑courses
应用下的models.py
文件:
from django.db import models from django.contrib.auth.models import User class Subject(models.Model): title = models.CharField(max_length=200) slug = models.SlugField(max_length=200, unique=True) class Meta: ordering = ['title'] def __str__(self): return self.title class Course(models.Model): owner = models.ForeignKey(User, related_name='course_created', on_delete=models.CASCADE) subject = models.ForeignKey(Subject, related_name='courses', on_delete=models.CASCADE) title = models.CharField(max_length=200) slug = models.SlugField(max_length=200, unique=True) overview = models.TextField() created = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created'] def __str__(self): return self.title class Module(models.Model): course = models.ForeignKey(Course,related_name='modules',on_delete=models.CASCADE) title = models.CharField(max_length=200) description = models.TextField(blank=True) def __str__(self): return self.title
这是初始的Subject
,Course
和Module
模型。Course
模型的字段如下:
owner
: 课程讲师,也是课程创建者subject
: 课程的主体,外键关联到Subject
模型title
: 课程名称slug
: 课程slug名称,将来用在生成URLoverview
: 课程简介created
: 课程建立时间,生成数据行时候自动填充Module
从属于一个具体的课程,所以Module
模型中有一个外键连接到Course
模型。
之后进行数据迁移,不再赘述。
编辑course
应用的admin.py
文件,添加如下代码:
from django.contrib import admin from .models import Subject, Course, Module @admin.register(Subject) class SubjectAdmin(admin.ModelAdmin): list_display = ['title', 'slug'] prepopulated_fields = {'slug': ('title',)} class ModuleInline(admin.StackedInline): model = Module @admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display = ['title', 'subject', 'created'] list_filter = ['created', 'subject'] search_fields = ['title', 'overview'] prepopulated_fields = {'slug': ('title',)} inlines = [ModuleInline]
这就注册好了应用里的全部模型,记住@admin.register()
用于将模型注册到管理后台中。
有些时候,需要使用原始数据来直接填充数据库,这比每次建立项目之后手工录入原始数据要方便很多。DJango提供了fixtures(可以理解为一个预先格式化好的数据文件)功能,可以方便的从数据库中读取数据到fixture中,或者把fixture中的数据导入至数据库。
Django支持使用JSON,XML或YAML等格式来使用fixture。来建立一个包含一些初始化的Subject
对象的fixture:
首先创建超级用户:
python manage.py createsuperuser
之后运行站点:
python manage.py runserver
进入http://127.0.0.1:8000/admin/courses/subject/可以看到如下界面(需要先输入一些数据):
在shell中执行如下命令:
python manage.py dumpdata courses --indent=2
可以看到如下输出:
[ { "model": "courses.subject", "pk": 1, "fields": { "title": "Mathematics", "slug": "mathematics" } }, { "model": "courses.subject", "pk": 2, "fields": { "title": "Music", "slug": "music" } }, { "model": "courses.subject", "pk": 3, "fields": { "title": "Physics", "slug": "physics" } }, { "model": "courses.subject", "pk": 4, "fields": { "title": "Programming", "slug": "programming" } } ]
dumpdata
命令采取默认的JSON格式,将Course
类中的数据序列化并且输出。JSON中包含了模型的名称,主键,字段与对应的值。设置了indent=2是表示每行的缩进。
可以通过向命令行提供应用名和模块名,例如app.Model
,让数据直接输出到这个模型中;还可以通过--format
参数控制输出的数据格式,默认是使用JSON格式。还可以通过--output
参数指定输出到具体文件。
对于dumpdata
的详细参数,可以使用命令python manage.py dumpdata --help
查看。
使用如下命令把这个dump结果保存到courses
应用的一个fixture/
目录中:
mkdir courses/fixtures python manage.py dumpdata courses --indent=2 --output=courses/fixtures/subjects.json
译者注,原书写成了在orders
应用下的fixture/
目录,显然是将应用名写错了。
现在进入管理后台,将Subject
表中的数据全部删除,之后执行下列语句,从fixture中加载数据:
python manage.py loaddata subjects.json
可以发现,所有删除的数据都都回来了。
默认情况下Django会到每个应用里的fixtures/
目录内寻找指定的文件名,也可以在settings.py
中设置 FIXTURE_DIRS
来告诉Django到哪里寻找fixture。
fixture除了初始化数据库之外,还可以方便的为应用提供测试数据。
有关fixture的详情可以查看https://docs.djangoproject.com/en/2.0/topics/testing/tools/#fixture-loading。
如果在进行数据模型移植的时候就加载fixture生成初始数据,可以查看https://docs.djangoproject.com/en/2.0/topics/migrations/#data-migrations。
在课程中会向用户提供不同类型的内容,包括文字,图片,文件和视频等。我们必须采用一个能够存储各种文件类型的通用模型。在第六章中,我们学会了使用通用关系来创建与项目内任何一个数据模型的关系。这里我们建立一个Content模型,用于存放章节中的内容,定义一个通用关系来连接任何类型的内容。
编辑courses
应用的models.py
文件,增加下列内容:
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey
之后在文件末尾添加下列内容:
class Content(models.Model): module = models.ForeignKey(Module, related_name='contents', on_delete=models.CASCADE) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() item = GenericForeignKey('content_type', 'object_id')
这就是Content
模型,设置外键关联到了Module
模型,同时设置了与ContentType
模型的通用关联关系,可以从获取任意模型的内容。复习一下创建通用关系的所需的三个字的:
content_type
:一个外键用于关联到ContentType
模型。object_id
: 对象的id,使用PositiveIntegerField
字段。item
: 通用关联关系字段,通过合并上两个字段来进行关联。content_type
, object_id
两个字段会实际生成在数据库中,item
字段的关系是ORM引擎构建的,不真正被写进数据库中。
下一步的工作是建立每种具体内容类型的数据库,这些数据库有一些相同的字段用于标识基本信息,也有不同的字段存放该模型独特的信息。
Django支持数据模型之间的继承关系,这和Python程序的类继承关系很相似,Django提供了以下三种继承的方式:
让我们详细看一下这三种方式。
接口模型本质上是一个基类类,其中定义了所有需要包含在子模型中的字段。Django不会为接口模型创建任何数据库中的数据表。继承接口模型的子模型必须将这些字段完善,每一个子模型会创建数据表,表中的字段包括继承自接口模型的字段和子模型中自定义的字段。
为了标记一个模型为接口模型,在其Meta设置中,必须设置abstract = True
,django就会认为该模型是一个接口模型,不会创建数据表。子模型只需要继承该模型即可。
下边的例子是如何建立一个接口模型Content
和子模型Text
:
from django.db import models class BaseContent(models.Model): title = models.CharField(max_length=100) created = models.DateTimeField(auto_now_add=True) class Meta: abstract = True class Text(BaseContent): body = models.TextField()
在这个例子中,实际在数据库中创建的是Text
类对应的数据表,包含title
,created
和body
字段。
多表继承关系中的每一个表都是完整的数据模型。对于继承关系,Django会自动在子模型中创建一个一对一关系的外键连接到父模型。
要使用该种继承方式,必须继承一个已经存在的模型,django会把父模型和子模型都写入数据库,下边是一个例子:
from django.db import models class BaseContent(models.Model): title = models.CharField(max_length=100) created = models.DateTimeField(auto_now_add=True) class Text(BaseContent): body = models.TextField()
Django会将两张表都写入数据库,Text
表中除了body
字段,还有一个一对一的外键关联到BaseContent
表。
代理模型用于改变类的行为,例如增加额外的方法或者不同的Meta设置。父模型和子模型操作一张相同的数据表。Meta
类中指定proxy=True
就可以建立一个代理模型。
下边是一个创建代理模型的例子:
from django.db import models from django.utils import timezone class BaseContent(models.Model): title = models.CharField(max_length=100) created = models.DateTimeField(auto_now_add=True) class OrderedContent(BaseContent): class Meta: proxy = True ordering = ['created'] def created_delta(self): return timezone.now() - self.created
这里我们定义了一个OrderedContent
模型,作为BaseContent
模型的一个代理模型。这个代理模型提供了排序设置和一个新方法created_delta()
。OrderedContent
和BaseContent
都是操作由BaseContent
模型生成的数据表,但新增的排序和方法,只有通过OrderedContent
对象才能使用。
这种方法就类似于经典的Python类继承方式。
courses
应用中的Content
模型现在有着通用关系,可以取得任何模型的数据。我们要为每种内容建立不同的模型。所有的内容模型都有相同的字段也有不同的字段,这里就采取接口模型继承的方式来建立内容模型:
编辑courses
应用中的models.py
文件,添加下列代码:
class ItemBase(models.Model): owner = models.ForeignKey(User, related_name='%(class)s_related', on_delete=models.CASCADE) title = models.CharField(max_length=250) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) class Meta: abstract = True def __str__(self): return self.title class Text(ItemBase): content = models.TextField() class File(ItemBase): file = models.FileField(upload_to='files') class Image(ItemBase): file = models.FileField(upload_to='images') class Video(ItemBase): url = models.URLField()
在这段代码中,首先建立了一个接口模型ItemBase
,其中有四个字段,然后在Meta
中设置了abstract=True
以使该类为接口类。该类中定义了owner
, title
,
created
, updated
四个字段,将在所有的内容模型中使用。owner
是关联到用户的外键,存放当前内容的创建者。由于这是一个基类,必须要为不同的模型指定不同的related_name
。Django允许在related_name
属性中使用类似%(class)s
之类的占位符。设置之后,related_name
就会动态生成。这里我们使用了'%(class)s_related'
,最后实际的名称是text_related
,
file_related
, image_related
和 video_retaled
。
我们定义了四种类型的内容模型,均继承ItemBase
抽象基类:
Text
: 存储教学文本File
: 存储分发给用户的文件,比如PDF文件等教学资料Image
: 存储图片Video
:存储视频,定义了一个URLField
字段存储视频的路径。每个子模型中都包含ItemBase
中定义的字段。Django会针对四个子模型分别在数据库中创建数据表,但ItemBase
类不会被写入数据库。
继续编辑courses
应用的models.py
文件,由于四个子模型的类名已经确定了,需要修改Content
模型让其对应到这四个模型上,修改content_type
字段如下:
class Content(models.Model): content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, limit_choices_to={'model__in': ('text', 'file', 'image', 'video')})
这里使用了limit_choices_to
属性,以使ContentType
对象限于这四个模型中。如此定义之后,在查询数据库的时候还能够使用filter的参数例如model__in='text'
来检索具体某个模型的对象。
建立好所有模型之后,执行数据迁移程序,不再赘述。
现在就已经建立了本项目所需要的基本数据表及其结构。然而我们的模型中还缺少一些内容:课程和课程的内容是按照一定顺序排列的,但用户建立课程和上传内容的时候未必是线性的,我们需要一个排序字段,通过字段可以把课程,章节和内容进行排序。
Django内置了很完善的模型字段供方便快捷的建立数据模型。然而依然有无法满足用户需求的地方,我们也可以自定义模型字段,来存储个性化的内容,或者修改内置字段的行为。
我们需要一个字段存储课程和内容组织的顺序。通常用于确定顺序可以方便的采用内置的PositiveIntegerField
字段,采用一个正整数就可以方便的标记数据的顺序。这里我们继承PositiveIntegerField
字段,然后增加额外的行为来完成我们的自定义排序。
我们要给自定义字段增加增加如下两个功能:
在courses
应用下建立fields.py
文件,添加如下代码:
from django.db import models from django.core.exceptions import ObjectDoesNotExist class OrderField(models.PositiveIntegerField): def __init__(self, for_fields=None, *args, **kwargs): self.for_fields = for_fields super(OrderField, self).__init__(*args, **kwargs) def pre_save(self, model_instance, add): if getattr(model_instance, self.attname) is None: # 如果没有值,查询自己所在表的全部内容,找到最后一条字段,设置临时变量value = 最后字段的序号+1 try: qs = self.model.objects.all() if self.for_fields: # 存在for_fields参数,通过该参数取对应的数据行 query = {field: getattr(model_instance, field) for field in self.for_fields} qs = qs.filter(**query) # 取最后一个数据对象的序号 last_item = qs.latest(self.attname) value = last_item.order + 1 except ObjectDoesNotExist: value = 0 setattr(model_instance, self.attname, value) return value else: return super(OrderField, self).pre_save(model_instance, add)
这是自定义的字段类OrderField
,继承了内置的PositiveIntegerField
类,还增加了额外的参数for_fields
指定按照哪一个字段的顺序进行计算。
我们重写了pre_save()
方法,这个方法是在将字段的值实际存入到数据库之前执行的。在这个方法里,执行了如下逻辑:
self.attname
表示该字段对应的属性名,也就是字段属性。如果属性名是None
,说明用户没有设置序号。则按照以下逻辑进行计算:
self.model
for_fields
参数,把上一步的QuerySet用其中的字段拆解之后过滤,这样就可以取得具体的用于计算序号的参考数据行。last_item = qs.latest(self.attname)
方法取出最新一行数据对应的序号。如果取不到,说明自己是第一行。就将临时变量设置为0value
临时变量setattr()
将临时变量value
添加为字段名属性对应的值在自定义字段时,一定不要硬编码将内容写死,也需要像内置字段一样注意通用性。
关于自定义字段可以看https://docs.djangoproject.com/en/2.0/howto/custom-model-fields/。
建立好自定义的字段类之后,需要在各个模型中设置该字段,编辑courses
应用的models.py
文件,添加如下内容:
from .fields import OrderField class Module(models.Model): # ...... order = OrderField(for_fields=['course'], blank=True)
我们给自定义的排序字段起名叫order
,然后通过设置for_fields=['course']
,让该字段按照课程来排序。这意味着如果最新的某个Course
对象关联的module
对象的序号是3,为该Course
对象其新增一个关联的module
对象的序号就是4。
然后编辑Module
模型的__str__()
方法:
class Module(models.Model): def __str__(self): return '{}. {}'.format(self.order, self.title)
章节对应的内容也必须有序号,现在为Content
模型也增加上OrderField
类型的字段:
class Content(models.Model): # ... order = OrderField(blank=True, for_fields=['module'])
这样就指定了Content
对象的序号根据其对应的module
字段来排序,最后为两个模型添加默认的排序,为两个模型添加如下Meta
类:
class Module(models.Model): # ... class Meta: ordering = ['order'] class Content(models.Model): # ... class Meta: ordering = ['order']
最终的Module
和Content
模型应该是这样:
class Module(models.Model): course = models.ForeignKey(Course, related_name='modules', on_delete=models.CASCADE) title = models.CharField(max_length=200) description = models.TextField(blank=True) order = OrderField(for_fields=['course'], blank=True) def __str__(self): return '{}. {}'.format(self.order, self.title) class Meta: ordering = ['order'] class Content(models.Model): module = models.ForeignKey(Module, related_name='contents', on_delete=models.CASCADE) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, limit_choices_to={'model__in': ('text', 'video', 'image', 'file')}) object_id = models.PositiveIntegerField() item = GenericForeignKey('content_type', 'object_id') order = OrderField(for_fields=['module'], blank=True) class Meta: ordering = ['order']
模型修改好了,执行迁移命令 python manage.py makemigrations courses
,可以发现提示如下:
Tracking file by folder pattern: migrations You are trying to add a non-nullable field 'order' to content without a default; we can't do that (the database needs something to populate existing rows). Please select a fix: 1) Provide a one-off default now (will be set on all existing rows with a null value for this column) 2) Quit, and let me add a default in models.py Select an option:
这个提示的意思是说不能添加值为null
的新字段order
到数据表中,必须提供一个默认值。如果字段有null=True
属性,就不会提示此问题。我们有两个选择,选项1是输入一个默认值,作为所有已经存在的数据行该字段的值,选项2是放弃这次操作,在模型中为该字段添加default=xx
属性来设置默认值。
这里我们输入1并按回车键,看到如下提示:
Please enter the default value now, as valid Python The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now Type 'exit' to exit this prompt
系统提示我们输入值,输入0然后按回车,之后Django又会对Module
模型询问同样的问题,依然选择第一项然后输入0。之后可以看到:
Migrations for 'courses': courses\migrations\0003_auto_20181001_1344.py - Change Meta options on content - Change Meta options on module - Add field order to content - Add field order to module
表示成功,之后执行python manage.py migrate
。然后我们来测试一下排序,打开系统命令行窗口:
python manage.py shell
创建一个新课程:
>>> from django.contrib.auth.models import User >>> from courses.models import Subject, Course, Module >>> user = User.objects.last() >>> subject = Subject.objects.last() >>> c1 = Course.objects.create(subject=subject, owner=user, title='Course 1', slug='course1')
添加了一个新课程,现在我们来为新课程添加对应的章节,来看看是如何自动排序的。
>>> m1 = Module.objects.create(course=c1, title='Module 1') >>> m1.order 0
可以看到m1
对象的序号字段的值被设置为0,因为这是针对课程的第一个Module
对象,下边再增加一个Module
对象:
>>> m2 = Module.objects.create(course=c1, title='Module 2') >>> m2.order 1
可以看到随后增加的Module
对象的序号自动被设置成了1,这次我们创建第三个对象,指定序号为5:
>>> m3 = Module.objects.create(course=c1, title='Module 3', order=5) >>> m3.order 5
如果指定了序号,则序号就会是指定的数字。为了继续试验,再增加一个对象,不给出序号参数:
>>> m4 = Module.objects.create(course=c1, title='Module 4') >>> m4.order 6
可以看到,序号会根据最后保存的数据继续增加1。OrderField
字段无法保证序号一定连续,但可以保证添加的内容的序号一定是从小到大排列的。
继续试验,我们再增加第二个课程,然后第二个课程添加一个Module
对象:
>>> c2 = Course.objects.create(subject=subject, title='Course 2', slug='course2', owner=user) >>> m5 = Module.objects.create(course=c2, title='Module 1') >>> m5.order 0
可以看到序号又从0开始,该字段在生成序号的时候只会考虑同属于同一个外键字段下边的对象,第二个课程的第一个Module
对象的序号又从0开始,正是由于order
字段设置了for_fields=['course']
所致。
祝贺你成功创建了第一个自定义字段。
在创建好了完整的数据模型之后,需要创建内容管理系统。内容管理系统能够让讲师创建课程然后管理课程资源。
我们的内容管理系统需要如下几个功能:
这里我们使用Django内置验证模块为项目增加用户验证功能、所有的讲师和学生都是User
模型的实例,都可以通过django.contrib.auth
来管理用户。
编辑educa
项目的根urls.py
文件,添加连接到内置验证函数login
和logout
的路由:
from django.contrib import admin from django.urls import path from django.contrib.auth import views as auth_views urlpatterns = [ path('accounts/login/', auth_views.LoginView.as_view(), name='login'), path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'), path('admin/', admin.site.urls), ]
在courses
应用下建立如下目录和文件:
templates/ base.html registration/ login.html logged_out.html
在编写登录登出和其他模板之前,先来编辑base.html
作为母版,在其中添加如下内容:
{% load staticfiles %} <!DOCTYPE html> <html> <head> <meta charset="utf-8"/> <title>{% block title %}Educa{% endblock %}</title> <link href="{% static "css/base.css" %}" rel="stylesheet"> </head> <body> <div id="header"> <a href="/" class="logo">Educa</a> <ul class="menu"> {% if request.user.is_authenticated %} <li><a href="{% url "logout" %}">Sign out</a></li> {% else %} <li><a href="{% url "login" %}">Sign in</a></li> {% endif %} </ul> </div> <div id="content"> {% block content %} {% endblock %} </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script> <script> $(document).ready(function () { {% block domready %} {% endblock %} }); </script> </body> </html>
译者注:为了使用方便,这里将作者原书存放jQuery文件的的Google CDN换成了国内BootCDN的地址。下边很多地方都作类似处理。
在母版中,定义了几个块:
title
: 用于HEAD标签的TITLE标签使用content
: 页面主体内容domready
:包含jQuery的$document.ready()
代码,为页面DOM加载完成后执行的JS代码这里还用到了CSS文件,在courses
应用中建立static/css/
目录并将随书源代码中的CSS文件复制过来。
有了母版之后,编辑registration/login.html
:
{% extends "base.html" %} {% block title %}Log-in{% endblock %} {% block content %} <h1>Log-in</h1> <div class="module"> {% if form.errors %} <p>Your username and password didn't match. Please try again.</p> {% else %} <p>Please, use the following form to log-in:</p> {% endif %} <div class="login-form"> <form action="{% url 'login' %}" method="post"> {{ form.as_p }} {% csrf_token %} <input type="hidden" name="next" value="{{ next }}"/> <p><input type="submit" value="Log-in"></p> </form> </div> </div> {% endblock %}
这是Django标准的用于内置login
视图的模板。继续编写同目录下的logged_out.html
:
{% extends "base.html" %} {% block title %}Logged out{% endblock %} {% block content %} <h1>Logged out</h1> <div class="module"> <p>You have been successfully logged out. You can <a href="{% url "login" %}">log-in again</a>.</p> </div> {% endblock %}
这是用户登出之后展示的页面。启动站点,到http://127.0.0.1:8000/accounts/login/ 查看,页面如下:
我们将来创建增加,编辑和删除课程的功能。这次使用基于类的视图进行编写,编辑courses
应用的views.py
文件:
from django.views.generic.list import ListView from .models import Course class ManageCourseListView(ListView): model = Course template_name = 'courses/manage/course/list.html' def get_queryset(self): qs = super(ManageCourseListView, self).get_queryset() return qs.filter(owner=self.request.user)
这是ManageCourseListView
视图,继承自内置的ListView
视图。为了避免用户操作不属于该用户的内容,重写了get_queryset()
方法以取得当前用户相关的课程,在其他增删改内容的视图中,我们同样需要重写get_queryset()
方法。
如果想为一些CBV提供特定的功能和行为(而不是在每个类内重写某个方法),可以使用mixins。
对类来说,Mixin是一种特殊的多继承方式。通过Mixin可以给类附加一系列功能,自定义类的行为。有两种情况一般都会使用mixins:
Django为CBV提供了一系列mixins用来增强CBV的功能,具体可以看https://docs.djangoproject.com/en/2.0/topics/class-based-views/mixins/。
我们准备创建一个mixin,包含一个通用的方法,用于我们与课程相关的CBV中。修改courses
应用的views.py
文件,修改成下面这样:
from django.urls import reverse_lazy from django.views.generic.list import ListView from django.views.generic.edit import CreateView, UpdateView, DeleteView from .models import Course class OwnerMixin: def get_queryset(self): qs = super(OwnerMixin, self).get_queryset() return qs.filter(owner=self.request.user) class OwnerEditMixin: def form_valid(self, form): form.instance.owner = self.request.user return super(OwnerEditMixin, self).form_valid(form) class OwnerCourseMixin(OwnerMixin): model = Course class OwnerCourseEditMixin(OwnerCourseMixin, OwnerEditMixin): fields = ['subject', 'title', 'slug', 'overview'] success_url = reverse_lazy('manage_course_list') template_name = 'courses/manage/course/form.html' class ManageCourseListView(OwnerCourseMixin, ListView): template_name = 'courses/manage/course/list.html' class CourseCreateView(OwnerCourseEditMixin, CreateView): pass class CourseUpdateView(OwnerCourseEditMixin, UpdateView): pass class CourseDeleteView(OwnerCourseMixin, DeleteView): template_name = 'courses/manage/course/delete.html' success_url = reverse_lazy('manage_course_list')
在上述代码中,创建了两个mixin类OwnerMixin
和OwnerEditMixin
,将这些mixins和Django内置的ListView
,CreateView
,UpdateView
,DeleteView
一起使用。
这里创建的mixin类解释如下:
OwnerMixin
实现了下列方法:
get_queryset()
:这个方法是内置视图用于获取QuerySet的方法,我们的mixin重写了该方法,让该方法只返回与当前用户request.user
关联的查询结果。OwnerEditMixin
实现下列方法:
form_valid()
:所有使用了Django内置的ModelFormMixin
的视图,都具有该方法。这个方法具体工作机制是:如CreateView
和UpdateView
这种需要处理表单数据的视图,当表单验证通过时,就会执行form_valid()
方法。该方法的默认行为是保存数据对象,然后重定向到一个保存成功的URL。这里重写了该方法,自动给当前的数据对象设置上owner
属性对应的用户对象,这样我们就在保存过程中自动附加上用户信息。OwnerMixin
可以用于任何带有owner字段的模型。
我们还定义了继承自OwnerMixin
的OwnerCourseMixin
,然后指定了下列参数:
model
:进行查询的模型,可以被所有CBV使用。定义了OwnerCourseEditMixin
,具有下列属性:
fields
:指定CreateView
和UpdateView
等处理表单的视图在建立表单对象的时候使用的字段。success_url
:CreateView
和UpdateView
视图在表单提交成功后的跳转地址,这里定义了一个URL名称manage_course_list
,稍后会在路由中配置该名称最后我们创建了如下几个OwnerCourseMixin
的子类
ManageCourseListView
:展示当前用户创建的课程,继承OwnerCourseMixin
和ListView
CourseCreateView
:使用一个模型表单创建一个新的Course对象,使用OwnerCourseEditMixin
定义的字段,并且继承内置的CreateView
CourseUpdateView
:允许编辑和修改已经存在的Course对象,继承OwnerCourseEditMixin
和UpdateView
CourseDeleteView
:继承OwnerCourseMixin
和内置的DeleteView
,定义了成功删除对象之后跳转的success_url
译者注:使用mixin时必须了解Python 3对于类继承的MRO查找顺序,想要确保mixin中重写的方法生效,必须在继承时把mixin放在内置CBV的左侧。对于刚开始使用mixin的读者,可以使用Pycharm 专业版点击右键--Diagrams--Show Diagrams--Python Class Diagram查看当前文件的类图来了解继承关系。
我们已经创建好了所有管理课程的视图。目前任何已登录用户都可以访问这些视图。但是我们要限制课程相关的内容只能由创建者进行操作,Django的内置用户验证模块提供了权限系统,用于向用户和用户组分派权限。我们准备针对讲师建立一个用户组,然后给这个用户组内用户授予增删改课程的权限。
启动站点,进入http://127.0.0.1:8000/admin/auth/group/add/ ,然后创建一个新的Group
,名字叫做Instructors
,然后为其选择除了Subject
模型之外,所有与courses
应用相关的权限。如下图所示:
可以看到,对于每个应用中的每个模型,都有三个权限can add, can change, can delete。选好之后,点击SAVE按钮保存。
译者住:如果读者使用2.1或者更新版本的Django,权限还包括can view。
Django会为项目内的模型自动设置权限,如果需要的话,也可以编写自定义权限。具体可以查看https://docs.djangoproject.com/en/2.0/topics/auth/customizing/#custom-permissions。
打开http://127.0.0.1:8000/admin/auth/user/add/添加一个新用户,然后设置其为Instructors
用户组的成员,如下图所示:
默认情况下,用户会继承其用户组设置的权限,也可以自行选择任意的其他单独权限。如果用户的is_superuser
属性被设置为True
,则自动具有全部权限。
我们将限制用户对于视图的访问,使具有对应权限的用户才能进行增删改Course
对象的操作。这里使用两个django.contrib.auth
提供的mixins来限制对视图的访问:
LoginRequiredMixin
: 与@login_required
装饰器功能一样PermissionRequiredMixin
: 允许具有特定权限的用户访问该视图,超级用户具备所有权限。编辑courses
应用的views.py
文件,新增如下导入代码:
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
让OwnerCourseMixin
类继承LoginRequiredMixin
类,然后添加属性:
class OwnerCourseMixin(OwnerMixin, LoginRequiredMixin): model = Course fields = ['subject', 'title', 'slug', 'overview'] success_url = reverse_lazy('manage_course_list')
然后为几个视图都配置一个permission_required
属性:
class CourseCreateView(PermissionRequiredMixin, OwnerCourseEditMixin, CreateView): permission_required = 'courses.add_course' class CourseUpdateView(PermissionRequiredMixin, OwnerCourseEditMixin, UpdateView): permission_required = 'courses.change_course' class CourseDeleteView(PermissionRequiredMixin, OwnerCourseMixin, DeleteView): template_name = 'courses/manage/course/delete.html' success_url = reverse_lazy('manage_course_list') permission_required = 'courses.delete_course'
PermissionRequiredMixin
会检查用户是否具备在permission_required
参数里指定的权限。现在视图就只能供指定权限的用户使用了。
视图编写完毕之后,为视图配置路由,先在courses
应用中新建urls.py
文件,添加下列代码:
from django.urls import path from . import views urlpatterns = [ path('mine/', views.ManageCourseListView.as_view(), name='manage_course_list'), path('create/', views.CourseCreateView.as_view(), name='course_create'), path('<pk>/edit/', views.CourseUpdateView.as_view(), name='course_edit'), path('<pk>/delete/', views.CourseDeleteView.as_view(), name='course_delete'), ]
再来配置项目的根路由,将courses
应用的路由作为二级路由:
from django.urls import path, include from django.contrib.auth import views as auth_views urlpatterns = [ path('accounts/login/', auth_views.LoginView.as_view(), name='login'), path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'), path('admin/', admin.site.urls), path('course/', include('courses.urls')), ]
然后需要为视图创建模板,在courses
应用的templates/
目录下新建如下目录和文件:
courses/ manage/ course/ list.html form.html delete.html
编辑其中的courses/manage/course/list.html
,添加下列代码:
{% extends "base.html" %} {% block title %}My courses{% endblock %} {% block content %} <h1>My courses</h1> <div class="module"> {% for course in object_list %} <div class="course-info"> <h3>{{ course.title }}</h3> <p> <a href="{% url "course_edit" course.id %}">Edit</a> <a href="{% url "course_delete" course.id %}">Delete</a> </p> </div> {% empty %} <p>You haven't created any courses yet.</p> {% endfor %} <p> <a href="{% url "course_create" %}" class="button">Create new course</a> </p> </div> {% endblock %}
这是供ManageCourseListView
使用的视图。在这个视图里列出了所有的课程,然后生成对应的编辑和删除功能链接。
启动站点,到http://127.0.0.1:8000/accounts/login/?next=/course/mine/,用一个在Instructors
用户组内的用户登录,可以看到如下界面:
这个页面会显示当前用户创建的所有课程。
现在来创建新增和修改课程需要的模板,编辑courses/manage/course/form.html
,添加下列代码:
{% extends "base.html" %} {% block title %} {% if object %} Edit course "{{ object.title }}" {% else %} Create a new course {% endif %} {% endblock %} {% block content %} <h1> {% if object %} Edit course "{{ object.title }}" {% else %} Create a new course {% endif %} </h1> <div class="module"> <h2>Course info</h2> <form action="." method="post"> {{ form.as_p }} {% csrf_token %} <p><input type="submit" value="Save course"></p> </form> </div> {% endblock %}
这个模板由CourseCreateView
和CourseUpdateView
进行操作。在模板内先检查object
变量是否存在,如果存在则显示针对该对象的修改功能。如果不存在就建立一个新的Course
对象。
浏览器中打开http://127.0.0.1:8000/course/mine/,点击CREATE NEW COURSE按钮,可以看到如下界面:
填写表单后后点击SAVE COURSE进行保存,课程会被保存,然后重定向到课程列表页,可以看到如下界面:
点击其中的Edit链接,可以在看到这个表单页面,但这次是修改已经存在的Course
对象。
最后来编写courses/manage/course/delete.html
,添加下列代码:
{% extends "base.html" %} {% block title %}Delete course{% endblock %} {% block content %} <h1>Delete course "{{ object.title }}"</h1> <div class="module"> <form action="" method="post"> {% csrf_token %} <p>Are you sure you want to delete "{{ object }}"?</p> <input type="submit" class="button" value="Confirm"> </form> </div> {% endblock %}
注意原书的代码在<input>
元素的的class
属性后边漏了一个"="号
这个模板由继承了DeleteView
的CourseDeleteView
视图操作,负责删除课程。
打开浏览器,点击刚才页面中的Delete链接,跳转到如下确认页面:
点击CONFIRM按钮,课程就会被删除,然后重定向至课程列表页。
讲师组用户现在可以增删改课程了。下边要做的是通过CMS让讲师组用户为课程添加章节和内容。
这一节里来建立一个管理课程中章节和内容的系统,将为同时管理课程中的多个章节及其中不同的内容建立表单。章节和内容都需要按照特定的顺序记录在我们的CMS中。
Django通过一个抽象层控制页面中的所有表单对象。一组表单对象被称为表单集。表单集由多个Form
类或者ModelForm
类的实例组成。表单集内的所有表单在提交的时候会一并提交,表单集可以控制显示的表单数量,对提交的最大表单数量做限制,同时对其中的全部表单进行验证。
表单集包含一个is_valid()
方法用于一次验证所有表单。可以给表单集初始数据,也可以控制表单集显示的空白表单数量。普通的表单集官方文档可以看https://docs.djangoproject.com/en/2.0/topics/forms/formsets/,由模型表单构成的model formset可以看https://docs.djangoproject.com/en/2.0/topics/forms/modelforms/#model-formsets。
由于一个课程由多个章节组成,方便运用表单集进行管理。在courses
应用中建立forms.py
文件,添加如下代码:
from django import forms from django.forms.models import inlineformset_factory from .models import Course, Module ModuleFormSet = inlineformset_factory(Course, Module, fields=['title', 'description'], extra=2, can_delete=True)
我们使用内置的inlineformset_factory()方法
构建了表单集ModuleFormSet
。内联表单工厂函数是在普通的表单集之上的一个抽象。这个函数允许我们动态的通过与Course
模型关联的Module
模型创建表单集。
对这个表单集我们应用了如下字段:
fields
:表示表单集中每个表单的字段extra
:设置每次显示表单集时候的表单数量can_delete
:该项如果设置True
,Django会在每个表单内包含一个布尔字段(被渲染成为一个CHECKBOX类型的INPUT元素),供用户选中需要删除的表单编辑courses
应用的views.py
文件,增加下列代码:
from django.shortcuts import redirect, get_object_or_404 from django.views.generic.base import TemplateResponseMixin, View from .forms import ModuleFormSet class CourseModuleUpdateView(TemplateResponseMixin, View): template_name = 'courses/manage/module/formset.html' course = None def get_formset(self, data=None): return ModuleFormSet(instance=self.course, data=data) def dispatch(self, request, pk): self.course = get_object_or_404(Course, id=pk, owner=request.user) return super(CourseModuleUpdateView, self).dispatch(request, pk) def get(self, request, *args, **kwargs): formset = self.get_formset() return self.render_to_response({'course': self.course, 'formset': formset}) def post(self, request, *args, **kwargs): formset = self.get_formset(data=request.POST) if formset.is_valid(): formset.save() return redirect('manage_course_list') return self.render_to_response({'course': self.course, 'formset': formset})
CourseModuleUpdateView
用于对一个课程的章节进行增删改。这个视图继承了以下的mixins和视图:
TemplateResponseMixin
:这个mixin提供的功能是渲染模块并且返回HTTP响应,需要一个template_name
属性用于指定模板位置,提供了一个render_to_response()
方法给模板传入上下文并且渲染模板View
:基础的CBV视图,由Django内置提供。简单继承该类就可以得到一个基本的CBV。在这个视图中,实现了如下的方法:
get_formset()
:这个方法是创建formset对象的过程,为了避免重复编写所以写了一个方法。功能是根据获得的Course
对象和可选的data参数来构建一个ModuleFormSet
对象。dispatch()
:这个方法是View
视图的方法,是一个分发器,HTTP请求进来之后,最先执行的是dispatch()
方法。该方法把小写的HTTP请求的种类分发给同名方法:例如GET
请求会被发送到get()
方法进行处理,POST
请求会被发送到post()
方法进行处理。在这个方法里。使用get_object_or_404()
加一个id
参数,从Course
类中获取对象。把这段代码包含在dispatch()
方法中是因为无论GET
还是POST
请求,都会使用Course
对象。在请求一进来的时候,就把Course
对象存入self.course
,供其他方法使用。
get()
:处理GET
请求。创建一个ModuleFormSet
然后使用当前的Course
对象渲染模板,使用了TemplateResponseMixin
提供的render_to_response()
方法post()
:处理POST
请求,在这个方法中执行了如下动作:
ModuleFormSet
对象is_valid()
方法验证所有表单save()
方法保存,这时增删改都会写入数据库。然后重定向到manage_course_list
URL。如果未通过验证,就返回当前表单对象以显示错误信息。编辑courses
应用中的urls.py
文件,为刚写的视图配置URL:
path('<pk>/module/', views.CourseModuleUpdateView.as_view(), name='course_module_update'),
在模板目录courses/templates/
下创建一个新目录,叫做module
,然后创建templates/courses/manage/module/formset.html
文件,添加下列代码:
{% extends "base.html" %} {% block title %} Edit "{{ course.title }}" {% endblock %} {% block content %} <h1>Edit "{{ course.title }}"</h1> <div class="module"> <h2>Course modules</h2> <form action="" method="post"> {{ formset }} {{ formset.management_form }} {% csrf_token %} <input type="submit" class="button" value="Save modules"> </form> </div> {% endblock %}
在这个模板中,创建了一个表单元素<form>
,其中包含了formset
表单集,还包含了一个管理表单{{ formset.management_form }}
。这个管理表单包含隐藏的字段用于控制显示起始,总计,最小和最大编号的表单。可以看到创建表单集很简单。
编辑courses/templates/course/list.html
,把course_module_update
的链接加在编辑和删除链接之下:
<a href="{% url "course_edit" course.id %}">Edit</a> <a href="{% url "course_delete" course.id %}">Delete</a> <a href="{% url "course_module_update" course.id %}">Edit modules</a>
现在模板中有了编辑课程中章节的链接,启动站点,到http://127.0.0.1:8000/course/mine/创建一个课程然后点击Edit modules链接,可以看到页面中的表单集如下:
这个表单集合包含了该课程中的每个Module
对象,然后还多出来2个空白的表单可供填写,这是因为我们为ModuleFormSet
设置了extra=2
。输入两个新的章节内容,然后保存表单,再进编辑页面,可以看到又多出来了两个空白表单。
现在要为章节添加具体的内容。在之前我们定义了四种内容对应四个模型:文字,图片,文件和视频。可能会考虑建立四个不同的视图操作这四个不同的类,但这里我们采用更加通用的方式:建立一个视图来对这四个类进行增删改。
编辑courses
应用中的views.py
文件,添加如下代码:
from django.forms.models import modelform_factory from django.apps import apps from .models import Module, Content class ContentCreateUpdateView(TemplateResponseMixin, View): module = None model = None obj = None template_name = 'courses/manage/content/form.html' def get_model(self, model_name): if model_name in ['text', 'video', 'image', 'file']: return apps.get_model(app_label='courses', model_name=model_name) return None def get_form(self, model, *args, **kwargs): Form = modelform_factory(model, exclude=['owner', 'order', 'created', 'updated']) return Form(*args, **kwargs) def dispatch(self, request, module_id, model_name, id=None): self.module = get_object_or_404(Module, id=module_id, course__owner=request.user) self.model = self.get_model(model_name) if id: self.obj = get_object_or_404(self.model, id=id, owner=request.user) return super(ContentCreateUpdateView, self).dispatch(request, module_id, model_name, id)
这是ContentCreateUpdateView
视图的第一部分。这个类用于建立和更新章节中的内容,这个类定义了如下方法:
get_model()
:检查给出的名字是否在指定的四个类名中,然后用Django的apps
模块,从courses
应用中取出对应的模块,如果没有找到,就返回None
get_form()
:使用内置的modelform_factory()
方法建立表单集,去掉了四个指定的字段,使用剩下的字段建立。这么做,我们可以不考虑具体是哪个模型,只去掉通用的字段保留剩下的字段。dispatch()
:这个方法接收下列的URL参数,然后为当前对象设置module
和model
属性:
module_id
:章节的idmodel_name
:内容模型的名称id
:要更新的内容的id,默认值为None
表示新建。然后来编写该视图的get()
和post()
方法:
def get(self, request, module_id, model_name, id=None): form = self.get_form(self.model, instance=self.obj) return self.render_to_response({'form': form, 'object': self.obj}) def post(self, request, module_id, model_name, id=None): form = self.get_form(self.model, instance=self.obj, data=request.POST, files=request.FILES) if form.is_valid(): obj = form.save(commit=False) obj.owner = request.user obj.save() if not id: # 新内容 Content.objects.create(module=self.module, item=obj) return redirect('module_content_list', self.module.id) return self.render_to_response({'form': form, 'object': self.obj})
这两个方法解释如下:
get()
:处理GET
请求。通过get_form()
方法获取需要修改的四种内容之一生成的表单。如果没有id
,前置的dispatch
方法里不设置self.obj
,所以instance=None
,表示新建post()
:处理POST
请求。通过传入的所有数据创建表单集对象,然后进行验证。如果验证通过,给当前对象设置上user
属性,然后保存。如果没有传入id
,说明是新建内容,需要在Content
中追加一条记录关联到module
对象和新建的内容对象。
编辑courses
应用的urls.py
文件,为新视图配置URL:
path('module/<int:module_id>/content/<model_name>/create/', views.ContentCreateUpdateView.as_view(), name='module_content_create'), path('module/<int:module_id>/content/<model_name>/<id>/', views.ContentCreateUpdateView.as_view(), name='module_content_update'),
这两条路由解释如下:
module_content_create
:用于建立新内容的URL,带有module_id
和model_name
两个参数,第一个是用来取得对应的module
对象,第二个用来取得对应的内容数据模型。module_content_update
:用于修改原有内容的URL,除了带有module_id
和model_name
两个参数之外,还带有id
用于确定具体修改哪一个内容对象。在courses/manage/
目录下创建一个新目录叫content
,再创建courses/manage/content/form.html
,添加下列代码:
{% extends "base.html" %} {% block title %} {% if object %} Edit content "{{ object.title }}" {% else %} Add a new content {% endif %} {% endblock %} {% block content %} <h1> {% if object %} Edit content "{{ object.title }}" {% else %} Add a new content {% endif %} </h1> <div class="module"> <h2>Course info</h2> <form action="" method="post" enctype="multipart/form-data"> {{ form.as_p }} {% csrf_token %} <p><input type="submit" value="Save content"></p> </form> </div> {% endblock %}
这是视图ContentCreateUpdateView
控制的模板。在这个模板里,使用了一个object
变量,如果object
变量不为None
,说明在修改一个已经存在的内容,否则就是新建一个内容。
<form>
标签中设置了属性enctype="multipart/form-data"
,因为File
和Image
模型中有文件字段。
启动站点,到http://127.0.0.1:8000/course/mine/,点击任何一个已经存在的课程的Edit modules链接,之后新建一个module。
然后打开带有当前Django环境的Python命令行,来进行一些测试,首先取到最后一个建立的module对象:
>>> from courses.models import Module >>> Module.objects.latest('id').id 6
取到了这个id之后,打开http://127.0.0.1:8000/course/module/6/content/image/create/ ,把6替换成你实际取到的结果,可以看到创建Image
对象的页面:
现在还不要提交表单,如果提交会报错,因为我们还没有定义module_content_list
URL。
现在还需要一个视图用来删除内容。编辑courses
应用的views.py
文件:
class ContentDeleteView(View): def post(self, request, id): content = get_object_or_404(Content, id=id, module__course__owner=request.user) module = content.module content.item.delete() content.delete() return redirect('module_content_list', module.id)
这个ContentDeleteView
视图通过ID参数获取Content
对象,然后删除相关的Text
、Video
、Image
、或File
对象,再把Content
对象删除,之后重定向到module_content_list
URL。
在就在courses
应用的urls.py
文件中设置该URL:
path('content/<int:id>/delete/', views.ContentDeleteView.as_view(), name='module_content_delete'),
现在讲师用户就可以增删改内容了。
在上一节里编写好了增删改的视图,现在需要一个视图将一个课程的全部章节和其中的内容展示出来的视图。
编辑courses
应用的views.py
文件,添加下列代码:
class ModuleContentListView(TemplateResponseMixin, View): template_name = 'courses/manage/module/content_list.html' def get(self, request, module_id): module = get_object_or_404(Module, id=module_id, course__owner=request.user) return self.render_to_response({'module': module})
这个ModuleContentListView
视图通过一个指定的Module
对象的ID和当前用户,来获取Module对象,然后使用该对象渲染模板。
在courses
应用的urls.py
内加入该视图的路由:
path('module/<int:module_id>/', views.ModuleContentListView.as_view(), name='module_content_list'),
在templates/courses/manage/module/
目录中新建content_list.html
,添加下列代码:
{% extends "base.html" %} {% block title %} Module {{ module.order|add:1 }}: {{ module.title }} {% endblock %} {% block content %} {% with course=module.course %} <h1>Course "{{ course.title }}"</h1> <div class="contents"> <h3>Modules</h3> <ul id="modules"> {% for m in course.modules.all %} <li data-id="{{ m.id }}" {% if m == module %} class="selected"{% endif %}> <a href="{% url "module_content_list" m.id %}"> <span> Module <span class="order">{{ m.order|add:1 }}</span> </span> <br> {{ m.title }} </a> </li> {% empty %} <li>No modules yet.</li> {% endfor %} </ul> <p><a href="{% url "course_module_update" course.id %}"> Edit modules</a></p> </div> <div class="module"> <h2>Module {{ module.order|add:1 }}: {{ module.title }}</h2> <h3>Module contents:</h3> <div id="module-contents"> {% for content in module.contents.all %} <div data-id="{{ content.id }}"> {% with item=content.item %} <p>{{ item }}</p> <a href="#">Edit</a> <form action="{% url "module_content_delete" content.id %}" method="post"> <input type="submit" value="Delete"> {% csrf_token %} </form> {% endwith %} </div> {% empty %} <p>This module has no contents yet.</p> {% endfor %} </div> <h3>Add new content:</h3> <ul class="content-types"> <li><a href="{% url "module_content_create" module.id "text" %}"> Text</a></li> <li><a href="{% url "module_content_create" module.id "image" %}"> Image</a></li> <li><a href="{% url "module_content_create" module.id "video" %}"> Video</a></li> <li><a href="{% url "module_content_create" module.id "file" %}"> File</a></li> </ul> </div> {% endwith %} {% endblock %}
这是用来展示该课程中全部章节和内容的模板。迭代全部的章节显示在侧边栏中,然后针对每个章节的内容,通过content.item
迭代其中的相关的所有内容进行展示,然后配上对应的链接。
我们想知道每个item
对象究竟是text
, video
, image
或者file
的哪一种,因为我们需要模型的名称来创建修改数据的URL。此外还需要在模板中按照类别单独把每个内容展示出来。对于一个数据对象,可以通过_meta_属性获取该数据所属的模型类,但Django不允许在视图中使用以下划线开头的模板变量或者属性,以防访问到私有属性或方法。可以通过编写一个自定义的模板过滤器来解决。
在courses
应用中建立如下目录和文件:
templatetags/ __init__.py course.py
在其中的course.py
中编写:
from django import template register = template.Library() @register.filter def model_name(obj): try: return obj._meta.model_name except AttributeError: return None
这是model_name
模板过滤器,在模板里可以通过object|model_name
来获得一个数据对象所属的模型名称。
编辑刚才的templates/courses/manage/module/content_list.html
,在{% extend %}
的下一行添加:
{% load course %}
然后找到下边两行:
<p>{{ item }}</p> <a href="#">Edit</a>
替换成:
<p>{{ item }} ({{ item|model_name }})</p> <a href="{% url "module_content_update" module.id item|model_name item.id %}">Edit</a>
使用了自定义模板过滤器之后,我们在模板中显示内容对象时,就可以通过对象所属模型的名称来生成URL链接了。编辑courses/manage/course/list.html
,添加一个列表页的链接:
<a href="{% url "course_module_update" course.id %}">Edit modules</a> {% if course.modules.count > 0 %} <a href="{% url "module_content_list" course.modules.first.id %}">Manage contents</a> {% endif %}
这个新连接跳转到显示第一个章节的内容的页面。
打开http://127.0.0.1:8000/course/mine/,可以看到页面中多出来了Manage contents链接,点击该链接后如下图所示:
在左侧边栏点击一个章节时,该章节的内容就显示在右侧。这个页面还带了链接到添加四种类型内容的页面。实际添加一些内容然后看一下页面效果,内容也会展示出来:
我们需要给用户提供一个简单的可以重新排序的方法。通过JavaScrip的拖动插件,让用户通过拖动就可以重新排列章节和内容的顺序。在用户结束拖动的时候,我们使用AJAX来记录当前的新顺序。
django-braces
是一个第三方模块,包含了一系列通用的Mixin,为CBV提供额外的功能。可以查看其官方文档:https://django-braces.readthedocs.io/en/latest/来获得完整的mixin列表。
我们要使用django-braces
中下列mixin:
CsrfExemptMixin
:在POST
请求中不检查CSRF,无需生成csrf_token
JsonRequestResponseMixin
:以JSON字符串形式解析请求中的数据,并且序列化响应数据为JSON格式,带有application/json
头部信息通过pip
安装django-braces
:
pip install django-braces==1.13.0
我们需要一个视图,能够接受JSON格式的新的模块顺序。编辑courses
应用的views.py
文件,添加下列代码:
from braces.views import CsrfExemptMixin, JsonRequestResponseMixin class ModuleOrderView(CsrfExemptMixin, JsonRequestResponseMixin, View): def post(self, request): for id, order in self.request_json.items(): Module.objects.filter(id=id, course__owner=request.user).update(order=order) return self.render_json_response({'saved': 'OK'})
这个ModuleOrderView
视图的逻辑是拿到JSON数据后,对于其中的每一条记录,更新module
对象的order
字段。
基于类似的逻辑,来编写章节内容的重新排列视图,继续在views.py
中追加代码:
class ContentOrderView(CsrfExemptMixin, JsonRequestResponseMixin, View): def post(self, request): for id, order in self.request_json.items(): Content.objects.filter(id=id, module__course__owner=request.user).update(order=order) return self.render_json_response({'saved': 'OK'})
然后编辑courses
应用的urls.py
,为这两个视图配置URL:
path('module/order/', views.ModuleOrderView.as_view(), name='module_order'), path('content/order/', views.ContentOrderView.as_view(), name='content_order'),
最后,需要在模板中实现拖动功能。使用jQuery UI库来完成这个功能。jQuery UI基于jQuery,提个了一系列的界面互动操作,效果和插件。我们使用其中的sortable
元素。首先,需要把jQuery加载到母版中。打开base.html
,在加载jQuery的script标签之后加入jQuery UI。
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
这里使用了国内的CDN。由于jQueryUI依赖于jQuery,所以要在其后载入。之后编辑courses/manage/module/content_list.html
,在底部添加如下代码:
{% block domready %} $('#modules').sortable({ stop: function (event, ui) { let modules_order = {}; $('#modules').children().each(function () { $(this).find('.order').text($(this).index() + 1); modules_order[$(this).data('id')] = $(this).index(); }); $.ajax({ type: 'POST', url: '{% url "module_order" %}', contentType: 'application/json; charset=utf-8', dataType: 'json', data: JSON.stringify(modules_order) }); } }); $('#module-contents').sortable({ stop: function (event, ui) { let contents_order = {}; $('#module-contents').children().each(function () { contents_order[$(this).data('id')] = $(this).index(); }); $.ajax({ type: 'POST', url: '{% url "content_order" %}', contentType: 'application/json; charset=utf-8', dataType: 'json', data: JSON.stringify(contents_order), }); } }); {% endblock %}
译者注:这里对原书的代码增加了let声明。
这段代码加载在{% domready %}
块中,会在页面DOM加载完成后立刻执行。在代码中为所有的侧边栏中的章节列表定义了一个sortable
方法,为内容也定义了一个同样功能的方法。这段代码做了下列工作:
#modules选择器,
为modules
的HTML元素定义了sortable
元素stop
事件处理函数,用户停止拖动后触发该事件modules_order
(JS里叫做对象),其中的键是module
的ID(LI元素的data-id属性的值),值是重新排列后的顺序。#module
的子元素,取得此时每个元素的data-id
和此时在列表中的索引,用此时的id作为键,其顺序作为值,更新modules_order
字典。POST
请求到content_order
URL进行处理,请求中带有modules_order
JSON字符串,交给ModuleOrderView
进行处理。用于排序内容部分的sortable
元素与上述这个相似。启动站点,重新加载编辑内容的页面,现在可以通过拖动重新排列章节和内容的顺序,如下图所示:
现在我们就实现了拖动排序功能。
这一章学习了如何建立一个CMS。使用了模型继承和创建自定义字段,同时使用了基于类的视图和mixins。还使用了表单集和实现了一个管理不同的内容的系统。
在下一章,将学习创建一个学生注册系统,以及在页面内渲染各种课程内容,以及学习Django缓存框架的使用。