Django2實戰示例 第十章 創建在線教育平台


目錄

Django2實戰示例 第一章 創建博客應用
Django2實戰示例 第二章 增強博客功能
Django2實戰示例 第三章 擴展博客功能
Django2實戰示例 第四章 創建社交網站
Django2實戰示例 第五章 內容分享功能
Django2實戰示例 第六章 追蹤用戶行為
Django2實戰示例 第七章 創建電商網站
Django2實戰示例 第八章 管理支付與訂單
Django2實戰示例 第九章 擴展商店功能
Django2實戰示例 第十章 創建在線教育平台
Django2實戰示例 第十一章 渲染和緩存課程內容
Django2實戰示例 第十二章 創建API
Django2實戰示例 第十三章 上線

第十章 創建在線教育平台

在上一章,我們為電商網站項目添加了國際化功能,還創建了優惠碼和商品推薦系統。在本章,會建立一個新的項目:一個在線教育平台,並創內容管理系統CMS(Content Management System)。

本章的具體內容有

  • 為模型建立fixtures
  • 使用模型的繼承關系
  • 創建自定義模型字段
  • 使用CBV和mixin
  • 建立表單集formsets
  • 管理用戶組與權限
  • 創建CMS

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/可以看到如下界面(需要先輸入一些數據):

image

在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_typeobject_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以使該類為接口類。該類中定義了ownertitlecreatedupdated四個字段,將在所有的內容模型中使用。owner是關聯到用戶的外鍵,存放當前內容的創建者。由於這是一個基類,必須要為不同的模型指定不同的related_name。Django允許在related_name屬性中使用類似%(class)s之類的占位符。設置之后,related_name就會動態生成。這里我們使用了'%(class)s_related',最后實際的名稱是text_relatedfile_relatedimage_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'來檢索具體某個模型的對象。

建立好所有模型之后,執行數據遷移程序,不再贅述。

現在就已經建立了本項目所需要的基本數據表及其結構。然而我們的模型中還缺少一些內容:課程和課程的內容是按照一定順序排列的,但用戶建立課程和上傳內容的時候未必是線性的,我們需要一個排序字段,通過字段可以把課程,章節和內容進行排序。

3.3創建自定義字段

Django內置了很完善的模型字段供方便快捷的建立數據模型。然而依然有無法滿足用戶需求的地方,我們也可以自定義模型字段,來存儲個性化的內容,或者修改內置字段的行為。

我們需要一個字段存儲課程和內容組織的順序。通常用於確定順序可以方便的采用內置的PositiveIntegerField字段,采用一個正整數就可以方便的標記數據的順序。這里我們繼承PositiveIntegerField字段,然后增加額外的行為來完成我們的自定義排序。

我們要給自定義字段增加增加如下兩個功能:

  • 如果序號沒有給出,則自動分配一個序號。當內容和課程表中存進一個新的數據對象的時候,如果用戶給出了具體的序號,就將該序號存入到排序字段中。如果用戶沒有給出序號,應該自動按照最大的序號再加1。例如如果已經存在兩個數據對象的序號是1和2,如果用戶存入第三個數據但未給出序號,則應該自動給新數據對象分配序號3。
  • 根據其他相關的內容排序:章節應該按照課程排序,而內容應該按照章節排序

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/ 查看,頁面如下:

image

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實現了下列方法:

  • get_queryset():這個方法是內置視圖用於獲取QuerySet的方法,我們的mixin重寫了該方法,讓該方法只返回與當前用戶request.user關聯的查詢結果。

OwnerEditMixin實現下列方法:

  • form_valid():所有使用了Django內置的ModelFormMixin的視圖,都具有該方法。這個方法具體工作機制是:如CreateViewUpdateView這種需要處理表單數據的視圖,當表單驗證通過時,就會執行form_valid()方法。該方法的默認行為是保存數據對象,然后重定向到一個保存成功的URL。這里重寫了該方法,自動給當前的數據對象設置上owner屬性對應的用戶對象,這樣我們就在保存過程中自動附加上用戶信息。

OwnerMixin可以用於任何帶有owner字段的模型。

我們還定義了繼承自OwnerMixinOwnerCourseMixin,然后指定了下列參數:

  • model:進行查詢的模型,可以被所有CBV使用。

定義了OwnerCourseEditMixin,具有下列屬性:

  • fields:指定CreateViewUpdateView等處理表單的視圖在建立表單對象的時候使用的字段。
  • success_urlCreateViewUpdateView視圖在表單提交成功后的跳轉地址,這里定義了一個URL名稱manage_course_list,稍后會在路由中配置該名稱

最后我們創建了如下幾個OwnerCourseMixin的子類

  • ManageCourseListView:展示當前用戶創建的課程,繼承OwnerCourseMixinListView
  • CourseCreateView:使用一個模型表單創建一個新的Course對象,使用OwnerCourseEditMixin定義的字段,並且繼承內置的CreateView
  • CourseUpdateView:允許編輯和修改已經存在的Course對象,繼承OwnerCourseEditMixinUpdateView
  • CourseDeleteView:繼承OwnerCourseMixin和內置的DeleteView,定義了成功刪除對象之后跳轉的success_url

譯者注:使用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應用相關的權限。如下圖所示:

image

可以看到,對於每個應用中的每個模型,都有三個權限can addcan changecan 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用戶組的成員,如下圖所示:

image

默認情況下,用戶會繼承其用戶組設置的權限,也可以自行選擇任意的其他單獨權限。如果用戶的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用戶組內的用戶登錄,可以看到如下界面:

image

這個頁面會顯示當前用戶創建的所有課程。

現在來創建新增和修改課程需要的模板,編輯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按鈕,可以看到如下界面:

image

填寫表單后后點擊SAVE COURSE進行保存,課程會被保存,然后重定向到課程列表頁,可以看到如下界面:

image

點擊其中的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鏈接,跳轉到如下確認頁面:

image

點擊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模型創建表單集。

對這個表單集我們應用了如下字段:

  • 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。

在這個視圖中,實現了如下的方法:

  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鏈接,可以看到頁面中的表單集如下:

image

這個表單集合包含了該課程中的每個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屬性:
    • module_id:章節的id
    • model_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_idmodel_name兩個參數,第一個是用來取得對應的module對象,第二個用來取得對應的內容數據模型。
  • module_content_update:用於修改原有內容的URL,除了帶有module_idmodel_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",因為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對象的頁面:

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對象究竟是textvideoimage或者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鏈接,點擊該鏈接后如下圖所示:

image

在左側邊欄點擊一個章節時,該章節的內容就顯示在右側。這個頁面還帶了鏈接到添加四種類型內容的頁面。實際添加一些內容然后看一下頁面效果,內容也會展示出來:

image

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:

  • 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方法,為內容也定義了一個同樣功能的方法。這段代碼做了下列工作:

  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元素與上述這個相似。啟動站點,重新加載編輯內容的頁面,現在可以通過拖動重新排列章節和內容的順序,如下圖所示:

image

現在我們就實現了拖動排序功能。

總結

這一章學習了如何建立一個CMS。使用了模型繼承和創建自定義字段,同時使用了基於類的視圖和mixins。還使用了表單集和實現了一個管理不同的內容的系統。

在下一章,將學習創建一個學生注冊系統,以及在頁面內渲染各種課程內容,以及學習Django緩存框架的使用。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM