第十章 创建在线教育平台

在上一章,我们为电商网站项目添加了国际化功能,还创建了优惠码和商品推荐系统。在本章,会建立一个新的项目:一个在线教育平台,并创内容管理系统CMS(Content Management System)。

本章的具体内容有

1创建在线教育平台项目

我们最后一个项目就是这个在线教育平台。在这个项目中,我们将建立一个灵活的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',
]

之后的第一步工作,依然是定义数据模型。

2创建课程模型

我们的在线教育平台会提供很多不同主题(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

这是初始的SubjectCourseModule模型。Course模型的字段如下:

  1. owner: 课程讲师,也是课程创建者
  2. subject: 课程的主体,外键关联到Subject模型
  3. title: 课程名称
  4. slug: 课程slug名称,将来用在生成URL
  5. overview: 课程简介
  6. created: 课程建立时间,生成数据行时候自动填充

Module从属于一个具体的课程,所以Module模型中有一个外键连接到Course模型。

之后进行数据迁移,不再赘述。

2.1在管理后台注册上述模型

编辑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()用于将模型注册到管理后台中。

2.2使用fixture为模型提供初始化数据

有些时候,需要使用原始数据来直接填充数据库,这比每次建立项目之后手工录入原始数据要方便很多。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

3创建不同类型内容的模型

在课程中会向用户提供不同类型的内容,包括文字,图片,文件和视频等。我们必须采用一个能够存储各种文件类型的通用模型。在第六章中,我们学会了使用通用关系来创建与项目内任何一个数据模型的关系。这里我们建立一个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模型的通用关联关系,可以从获取任意模型的内容。复习一下创建通用关系的所需的三个字的:

  1. content_type:一个外键用于关联到ContentType模型。
  2. object_id: 对象的id,使用PositiveIntegerField字段。
  3. item: 通用关联关系字段,通过合并上两个字段来进行关联。

content_type, object_id两个字段会实际生成在数据库中,item字段的关系是ORM引擎构建的,不真正被写进数据库中。

下一步的工作是建立每种具体内容类型的数据库,这些数据库有一些相同的字段用于标识基本信息,也有不同的字段存放该模型独特的信息。

3.1模型的继承

Django支持数据模型之间的继承关系,这和Python程序的类继承关系很相似,Django提供了以下三种继承的方式:

  1. Abstarct model: 接口模型继承,用于方便的向不同的数据模型中添加相同的信息,这种继承方式中的基类不会在数据库中建立数据表,子类会建立数据表。
  2. Multi-table model inheritance: 多表模型继承,在继承关系中的每个表都被认为是一个完整的模型时采用此方法,继承关系中的每一个表都会实际在数据库中创建数据表。
  3. Proxy models:代理模型继承,在继承的时候需要改变模型的行为时使用,例如加入额外的方法,修改默认的模型管理器或使用新的Meta类设置,此种继承不会在数据库中创建数据表。

让我们详细看一下这三种方式。

3.1.1Abstract models 抽象基类继承

接口模型本质上是一个基类类,其中定义了所有需要包含在子模型中的字段。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类对应的数据表,包含titlecreatedbody字段。

3.1.2Multi-table model inheritance 多表继承

多表继承关系中的每一个表都是完整的数据模型。对于继承关系,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表。

3.1.3Proxy models 代理模型

代理模型用于改变类的行为,例如增加额外的方法或者不同的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()OrderedContentBaseContent都是操作由BaseContent模型生成的数据表,但新增的排序和方法,只有通过OrderedContent对象才能使用。

这种方法就类似于经典的Python类继承方式。

3.2创建内容的模型

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_relatedvideo_retaled

我们定义了四种类型的内容模型,均继承ItemBase抽象基类:

每个子模型中都包含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'来检索具体某个模型的对象。

建立好所有模型之后,执行数据迁移程序,不再赘述。

现在就已经建立了本项目所需要的基本数据表及其结构。然而我们的模型中还缺少一些内容:课程和课程的内容是按照一定顺序排列的,但用户建立课程和上传内容的时候未必是线性的,我们需要一个排序字段,通过字段可以把课程,章节和内容进行排序。

3.3创建自定义字段

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()方法,这个方法是在将字段的值实际存入到数据库之前执行的。在这个方法里,执行了如下逻辑:

  1. 检查当前字段是否已经存在值,self.attname表示该字段对应的属性名,也就是字段属性。如果属性名是None,说明用户没有设置序号。则按照以下逻辑进行计算:
    1. 建立一个QuerySet,查询这个字段所在的模型的全部数据行。访问字段所在的模型使用了self.model
    2. 通过用户给出的for_fields参数,把上一步的QuerySet用其中的字段拆解之后过滤,这样就可以取得具体的用于计算序号的参考数据行。
    3. 然后从过滤过的QuerySet中使用last_item = qs.latest(self.attname)方法取出最新一行数据对应的序号。如果取不到,说明自己是第一行。就将临时变量设置为0
    4. 如果能够取到,就把取到的序号+1然后赋给value临时变量
    5. 然后通过setattr()将临时变量value添加为字段名属性对应的值
  2. 如果当前的字段已经有值,说明用户传入了序号,不需要做任何工作。

在自定义字段时,一定不要硬编码将内容写死,也需要像内置字段一样注意通用性。

关于自定义字段可以看https://docs.djangoproject.com/en/2.0/howto/custom-model-fields/

3.4将自定义字段加入到模型中

建立好自定义的字段类之后,需要在各个模型中设置该字段,编辑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']

最终的ModuleContent模型应该是这样:

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']所致。

祝贺你成功创建了第一个自定义字段。

4创建内容管理系统CMS

在创建好了完整的数据模型之后,需要创建内容管理系统。内容管理系统能够让讲师创建课程然后管理课程资源。

我们的内容管理系统需要如下几个功能:

4.1为站点增加用户验证系统

这里我们使用Django内置验证模块为项目增加用户验证功能、所有的讲师和学生都是User模型的实例,都可以通过django.contrib.auth来管理用户。

编辑educa项目的根urls.py文件,添加连接到内置验证函数loginlogout的路由:

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),
]

4.2创建用户验证模板

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的地址。下边很多地方都作类似处理。

在母版中,定义了几个块:

  1. title: 用于HEAD标签的TITLE标签使用
  2. content: 页面主体内容
  3. 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/ 查看,页面如下:

4.3创建CBV

我们将来创建增加,编辑和删除课程的功能。这次使用基于类的视图进行编写,编辑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

4.4在CBV中使用mixin

对类来说,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类OwnerMixinOwnerEditMixin,将这些mixins和Django内置的ListViewCreateViewUpdateViewDeleteView一起使用。

这里创建的mixin类解释如下:

OwnerMixin实现了下列方法:

OwnerEditMixin实现下列方法:

OwnerMixin可以用于任何带有owner字段的模型。

我们还定义了继承自OwnerMixinOwnerCourseMixin,然后指定了下列参数:

定义了OwnerCourseEditMixin,具有下列属性:

最后我们创建了如下几个OwnerCourseMixin的子类

译者注:使用mixin时必须了解Python 3对于类继承的MRO查找顺序,想要确保mixin中重写的方法生效,必须在继承时把mixin放在内置CBV的左侧。对于刚开始使用mixin的读者,可以使用Pycharm 专业版点击右键--Diagrams--Show Diagrams--Python Class Diagram查看当前文件的类图来了解继承关系。

4.5使用用户组和权限

我们已经创建好了所有管理课程的视图。目前任何已登录用户都可以访问这些视图。但是我们要限制课程相关的内容只能由创建者进行操作,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,则自动具有全部权限。

4.5.1限制访问CBV

我们将限制用户对于视图的访问,使具有对应权限的用户才能进行增删改Course对象的操作。这里使用两个django.contrib.auth提供的mixins来限制对视图的访问:

  1. LoginRequiredMixin: 与@login_required装饰器功能一样
  2. 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 %}

这个模板由CourseCreateViewCourseUpdateView进行操作。在模板内先检查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属性后边漏了一个"="号

这个模板由继承了DeleteViewCourseDeleteView视图操作,负责删除课程。

打开浏览器,点击刚才页面中的Delete链接,跳转到如下确认页面:

点击CONFIRM按钮,课程就会被删除,然后重定向至课程列表页。

讲师组用户现在可以增删改课程了。下边要做的是通过CMS让讲师组用户为课程添加章节和内容。

5管理章节与内容

这一节里来建立一个管理课程中章节和内容的系统,将为同时管理课程中的多个章节及其中不同的内容建立表单。章节和内容都需要按照特定的顺序记录在我们的CMS中。

5.1在课程模型中使用表单集(formsets)

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模型创建表单集。

对这个表单集我们应用了如下字段:

编辑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和视图:

在这个视图中,实现了如下的方法:

  1. get_formset():这个方法是创建formset对象的过程,为了避免重复编写所以写了一个方法。功能是根据获得的Course对象和可选的data参数来构建一个ModuleFormSet对象。
  2. dispatch():这个方法是View视图的方法,是一个分发器,HTTP请求进来之后,最先执行的是dispatch()方法。该方法把小写的HTTP请求的种类分发给同名方法:例如GET请求会被发送到get()方法进行处理,POST请求会被发送到post()方法进行处理。在这个方法里。使用get_object_or_404()加一个id参数,从Course类中获取对象。把这段代码包含在dispatch()方法中是因为无论GET还是POST请求,都会使用Course对象。在请求一进来的时候,就把Course对象存入self.course,供其他方法使用。
  3. get():处理GET请求。创建一个ModuleFormSet然后使用当前的Course对象渲染模板,使用了TemplateResponseMixin提供的render_to_response()方法
  4. post():处理POST请求,在这个方法中执行了如下动作:
    1. 使用请求附带的数据建立ModuleFormSet对象
    2. 执行is_valid()方法验证所有表单
    3. 验证通过则使用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。输入两个新的章节内容,然后保存表单,再进编辑页面,可以看到又多出来了两个空白表单。

5.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视图的第一部分。这个类用于建立和更新章节中的内容,这个类定义了如下方法:

  1. get_model():检查给出的名字是否在指定的四个类名中,然后用Django的apps模块,从courses应用中取出对应的模块,如果没有找到,就返回None
  2. get_form():使用内置的modelform_factory()方法建立表单集,去掉了四个指定的字段,使用剩下的字段建立。这么做,我们可以不考虑具体是哪个模型,只去掉通用的字段保留剩下的字段。
  3. dispatch():这个方法接收下列的URL参数,然后为当前对象设置modulemodel属性:

然后来编写该视图的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})

这两个方法解释如下:

编辑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'),

这两条路由解释如下:

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",因为FileImage模型中有文件字段。

启动站点,到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对象,然后删除相关的TextVideoImage、或File对象,再把Content对象删除,之后重定向到module_content_list URL。

在就在courses应用的urls.py文件中设置该URL:

    path('content/<int:id>/delete/', views.ContentDeleteView.as_view(), name='module_content_delete'),

现在讲师用户就可以增删改内容了。

5.3管理章节与内容

在上一节里编写好了增删改的视图,现在需要一个视图将一个课程的全部章节和其中的内容展示出来的视图。

编辑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链接,点击该链接后如下图所示:

在左侧边栏点击一个章节时,该章节的内容就显示在右侧。这个页面还带了链接到添加四种类型内容的页面。实际添加一些内容然后看一下页面效果,内容也会展示出来:

5.4重新排列章节和内容的顺序

我们需要给用户提供一个简单的可以重新排序的方法。通过JavaScrip的拖动插件,让用户通过拖动就可以重新排列章节和内容的顺序。在用户结束拖动的时候,我们使用AJAX来记录当前的新顺序。

5.4.1使用django-braces模块中的mixins

django-braces是一个第三方模块,包含了一系列通用的Mixin,为CBV提供额外的功能。可以查看其官方文档:https://django-braces.readthedocs.io/en/latest/来获得完整的mixin列表。

我们要使用django-braces中下列mixin:

通过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方法,为内容也定义了一个同样功能的方法。这段代码做了下列工作:

  1. 使用#modules选择器,modules的HTML元素定义了sortable元素
  2. 定义了一个stop事件处理函数,用户停止拖动后触发该事件
  3. 建立了一个空字典modules_order(JS里叫做对象),其中的键是module的ID(LI元素的data-id属性的值),值是重新排列后的顺序。
  4. 遍历拖动后的#module的子元素,取得此时每个元素的data-id和此时在列表中的索引,用此时的id作为键,其顺序作为值,更新modules_order字典。
  5. 通过AJAX发送POST请求到content_order URL进行处理,请求中带有modules_order JSON字符串,交给ModuleOrderView进行处理。

用于排序内容部分的sortable元素与上述这个相似。启动站点,重新加载编辑内容的页面,现在可以通过拖动重新排列章节和内容的顺序,如下图所示:

现在我们就实现了拖动排序功能。

总结

这一章学习了如何建立一个CMS。使用了模型继承和创建自定义字段,同时使用了基于类的视图和mixins。还使用了表单集和实现了一个管理不同的内容的系统。

在下一章,将学习创建一个学生注册系统,以及在页面内渲染各种课程内容,以及学习Django缓存框架的使用。