1. 程式人生 > >第十章 構建一個線上學習平臺(上)

第十章 構建一個線上學習平臺(上)

10 構建一個線上學習平臺

在上一章中,你為線上商店專案添加了國際化。你還構建了一個優惠券系統和一個商品推薦引擎。在本章中,你會建立一個新的專案。你會構建一個線上學習平臺,這個平臺會建立一個自定義的內容管理系統。

在本章中,你會學習如何:

  • 為模型建立fixtures
  • 使用模型繼承
  • 建立自定義O型字典
  • 使用基於類的檢視和mixins
  • 構建表單集
  • 管理組和許可權
  • 建立一個內容管理系統

10.1 建立一個線上學習平臺

我們最後一個實戰專案是一個線上學習平臺。在本章中,我們會構建一個靈活的內容管理系統(CMS),允許教師建立課程和管理課程內容。

首先,我們用以下命令為新專案建立一個虛擬環境,並激活它:

mkdir env
virtualenv env/educa
source env/educa/bin/activate

用以下命令在虛擬環境中安裝Django:

pip install Django

我們將在專案中管理圖片上傳,所以我們還需要用以下命令安裝Pillow:

pip install Pillow

使用以下命令建立一個新專案:

django-admin startproject educa

進入新的educa目錄,並用以下命令建立一個新應用:

cd educa
django-admin startapp courses

編輯educa專案的settings.py

檔案,把courses新增到INSTALLED_APPS設定中:

INSTALLED_APPS = [
    'courses',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

現在courses應用已經在專案激活了。讓我們為課程和課程內容定義模型。

10.2 構建課程模型

我們的線上學習平臺會提供多種主題的課程。每個課程會劃分為可配置的單元數量,而每個單元會包括可配置的內容數量。會有各種型別的內容:文字,檔案,圖片或者視訊。下面這個例子展示了我們的課程目錄的資料結構:

Subject 1
    Course 1
        Module 1
            Content 1 (image)
            Content 3 (text)
        Module 2
            Content 4 (text)
            Content 5 (file)
            Content 6 (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='courses_created')
    subject = models.ForeignKey(Subject, related_name='courses')
    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')
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)

    def __str__(self):
        return self.title

這些是初始的SubjectCourseModule模型。Course模型有以下欄位:

  • owner:建立給課程的教師
  • subject:這個課程所屬的主題。一個指向Subject模型的ForeignKey欄位。
  • title:課程標題.
  • slug:課程別名,之後在URL中使用。
  • overview:一個TextField列,表示課程概述。
  • created:課程建立的日期和時間。因為設定了auto_now_add=True,所以建立新物件時,Django會自動設定這個欄位。

每個課程劃分為數個單元。因此,Module模型包含一個指向Course模型的ForeignKey欄位。

開啟終端執行以下命令,為應用建立初始的資料庫遷移:

python manage.py makemigrations

你會看到以下輸出:

Migrations for 'courses':
  courses/migrations/0001_initial.py
    - Create model Course
    - Create model Module
    - Create model Subject
    - Add field subject to course

然後執行以下命令,同步遷移到資料庫中:

python manage.py migrate

你會看到一個輸出,其中包括所有已經生效的資料庫遷移,包括Django的資料庫遷移。輸出會包括這一行:

Applying courses.0001_initial... OK

這個告訴我們,courses應用的模型已經同步到資料庫中。

10.2.1 在管理站點註冊模型

我們將把課程模型新增到管理站點。編輯courses應用目錄中的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]

現在courses應用的模型已經在管理站點註冊。我們用@admin.register()裝飾器代替admin.site.register()函式。它們的功能是一樣的。

10.2.2 為模型提供初始資料

有時你可能希望用硬編碼資料預填充資料庫。這在專案建立時自動包括初始資料很有用,來替代手工新增資料。Django自帶一種簡單的方式,可以從資料庫中載入和轉儲(dump)資料到fixtures檔案中。

Django支援JSON,XML或者YAML格式的fixtures。我們將建立一個fixture,其中包括一些專案的初始Subject物件。

首先使用以下命令建立一個超級使用者:

python manage.py createsuperuser

然後用以下命令啟動開發伺服器:

python manage.py runserver

現在在瀏覽器中開啟http://127.0.0.1:8000/admin/courses/subject/。使用管理站點建立幾個主題。列表顯示頁面如下圖所示:

在終端執行以下命令:

python manage.py dumpdata courses --indent=2

你會看到類似這樣的輸出:

[
{
  "model": "courses.subject",
  "pk": 1,
  "fields": {
    "title": "Programming",
    "slug": "programming"
  }
},
{
  "model": "courses.subject",
  "pk": 2,
  "fields": {
    "title": "Physics",
    "slug": "physics"
  }
},
{
  "model": "courses.subject",
  "pk": 3,
  "fields": {
    "title": "Music",
    "slug": "music"
  }
},
{
  "model": "courses.subject",
  "pk": 4,
  "fields": {
    "title": "Mathematics",
    "slug": "mathematics"
  }
}
]

dumpdata命令從資料庫中轉儲資料到標準輸出,預設用JSON序列化。返回的資料結構包括模型和它的欄位資訊,Django可以把它載入到資料庫中。

你可以給這個命令提供應用的名稱,或者用app.Model格式指定輸出資料的模型。你還可以使用--format標籤指定格式。預設情況下,dumpdata輸出序列化的資料到標準輸出。但是,你可以使用--output標籤指定一個輸出檔案。--indent標籤允許你指定縮排。關於更多dumpdata的引數資訊,請執行python manage.py dumpdata --help命令。

使用以下命令,把這個轉儲儲存到courses應用的fixtures/目錄中:

mkdir courses/fixtures
python manage.py dumpdata courses --indent=2 --output=courses/fixtures/subjects.json

使用管理站點移除你建立的主題。然後使用以下命令把fixture載入到資料庫中:

python manage.py loaddata subjects.json

fixture中包括的所有Subject物件已經載入到資料庫中。

預設情況下,Django在每個應用的fixtures/目錄中查詢檔案,但你也可以為loaddata命令指定fixture檔案的完整路徑。你還可以使用FIXTURE_DIRS設定告訴Django查詢fixtures的額外目錄。

Fixtures不僅對初始資料有用,還可以為應用提供簡單的資料,或者測試必需的資料。

你可以在這裡閱讀如何在測試中使用fixtures。

如果你想在模型遷移中載入fixtures,請閱讀Django文件的資料遷移部分。記住,我們在第九章建立了自定義遷移,用於修改模型後遷移已存在的資料。你可以在這裡閱讀資料庫遷移的文件。

10.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')
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()
    item = GenericForeignKey('content_type', 'object_id')

這是Content模型。一個單元包括多個內容,所以我們定義了一個指向Module模型的外來鍵。我們還建立了一個通用關係,從代表不同內容型別的不同模型關聯到物件。記住,我們需要三個不同欄位來設定一個通用關係。在Content模型中,它們分別是:

  • content_type:一個指向ContentType模型的ForeignKey欄位。
  • object_id:這是一個PositiveIntegerField,儲存關聯物件的主鍵。
  • item:通過組合上面兩個欄位,指向關聯物件的GenericForeignKey欄位。

在這個模型的資料庫表中,只有content_typeobject_id欄位有對應的列。item欄位允許你直接檢索或設定關聯物件,它的功能建立在另外兩個欄位之上。

我們將為每種內容型別使用不同的模型。我們的內容模型會有通用欄位,但它們儲存的實際內容會不同。

10.3.1 使用模型繼承

Django支援模型繼承,類似Python中標準類的繼承。Django為使用模型繼承提供了以下三個選擇:

  • 抽象模型:當你想把一些通用資訊放在幾個模型時很有用。不會為抽象模型建立資料庫表。
  • 多表模型繼承:可用於層次中每個模型本身被認為是一個完整模型的情況下。為每個模型建立一張資料庫表。
  • 代理模型:當你需要修改一個模型的行為時很有用。例如,包括額外的方法,修改預設管理器,或者使用不同的元選項。不會為代理模型建立資料庫表。

讓我們近一步瞭解它們。

10.3.1.1 抽象模型

一個抽象模型是一個基類,其中定義了你想在所有子模型中包括的欄位。Django不會為抽象模型建立任何資料庫表。會為每個子模型建立一張資料庫表,其中包括從抽象類繼承的欄位,和子模型中定義的欄位。

要標記一個抽象模型,你需要在它的Meta類中包括abstract=True。Django會認為它是一個抽象模型,並且不會為它建立資料庫表。要建立子模型,你只需要從抽象模型繼承。以下是一個Content抽象模型和Text子模型的例子:

from django.db import models

class BaseContent(models.Model):
    title = models.CharField(max_length=200)
    created = models.DateTimeField(auto_now_add=True)

    class Meta:
        abstract = True

class Text(BaseContent):
    body = models.TextField()

在這個例子中,Django只會為Text模型建立資料庫表,其中包括titlecreatedbody欄位。

10.3.1.2 多表模型繼承

在多表繼承中,每個模型都有一張相應的資料庫表。Django會在子模型中建立指向父模型的OneToOneField欄位。

要使用多表繼承,你必須從已存在模型中繼承。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模型中包括一個自動生成的OneToOneField欄位,併為每個模型建立一張資料庫表。

10.3.1.3 代理模型

代理模型用於修改模型的行為,比如包括額外的方法或者不同的元選項。這兩個模型都在原模型的資料庫表上進行操作。在模型的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 create_delta(self):
        return timezone.now() - self.created

我們在這裡定義了一個OrderedContent模型,它是Content模型的代理模型。這個模型為QuerySet提供了預設排序和一個額外的created_delta()方法。ContentOrderedContent模型都在同一張資料庫表上操作,並且可以用ORM通過任何一個模型訪問物件。

10.3.2 建立內容模型

courses應用的Content模型包含一個通用關係來關聯不同的內容型別。我們將為每種內容模型建立不用的模型。所有內容模型會有一些通用的欄位,和一些額外欄位儲存自定義資料。我們將建立一個抽象模型,它會為所有內容模型提供通用欄位。

編輯courses應用的models.py檔案,並新增以下程式碼:

class ItemBase(models.Model):
    owner = models.ForeignKey(User, related_name='%(class)s_related')
    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作為related_name,所以每個子模型對應的反向關係是text_relatedfile_relatedimage_relatedvideo_related

我們定義了四個從ItemBase抽象模型繼承的內容模型。分別是:

  • Text:儲存文字內容。
  • File:儲存檔案,比如PDF。
  • Image:儲存圖片檔案。
  • Video:儲存視訊。我們使用URLField欄位來提供一個視訊的URL,從而可以嵌入視訊。

除了自身的欄位,每個子模型還包括ItemBase類中定義的欄位。會為TextFileImageVideo模型建立對應的資料庫表。因為ItemBase是一個抽象模型,所以它不會關聯到資料庫表。

編輯你之前建立的Content模型,修改它的content_type欄位:

content_type = models.ForeignKey(
    ContentType,
    limit_choices_to = {
        'model__in': ('text', 'video', 'image', 'file')
    }
)

我們添加了limit_choices_to引數來限制ContentType物件可用於的通用關係。我們使用了model__in欄位查詢,來過濾ContentType物件的model屬性為textvideoimage或者file

讓我們建立包括新模型的資料庫遷移。在命令列中執行以下命令:

python manage.py makemigrations

你會看到以下輸出:

Migrations for 'courses':
  courses/migrations/0002_content_file_image_text_video.py
    - Create model Content
    - Create model File
    - Create model Image
    - Create model Text
    - Create model Video

然後執行以下命令應用新的資料庫遷移:

python manage.py migrate

你看到的輸出的結尾是:

Running migrations:
  Applying courses.0002_content_file_image_text_video... OK

我們已經建立了模型,可以新增不同內容到課程單元中。但是我們的模型仍然缺少了一些東西。課程單元和內容應用遵循特定的順序。我們需要一個欄位對它們進行排序。

10.4 建立自定義模板欄位

Django自帶一組完整的模組欄位,你可以用它們構建自己的模型。但是,你也可以建立自己的模型欄位來儲存自定義資料,或者修改已存在欄位的行為。

我們需要一個欄位指定物件的順序。如果你想用Django提供的欄位,用一種簡單的方式實現這個功能,你可能會想在模型中新增一個PositiveIntegerField。這是一個好的開始。我們可以建立一個從PositiveIntegerField繼承的自定義欄位,並提供額外的方法。

我們會在排序欄位中新增以下兩個功能:

  • 沒有提供特定序號時,自動分配一個序號。如果儲存物件時沒有提供序號,我們的欄位會基於最後一個已存在的排序物件,自動分配下一個序號。如果兩個物件的序號分別是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().__init__(*args, **kwargs)

    def pre_save(self, model_instance, add):
        if getattr(model_instance, self.attname) is None:
            # no current value
            try:
                qs = self.model.objects.all()
                if self.for_fields:
                    # filter by objects with the same field values
                    # for the fields in "for_fields"
                    query = {field: getattr(model_instance, field) for field in self.for_fields}
                    qs = qs.filter(**query)
                # get the order of the last item
                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().pre_save(model_instance, add)

這是我們自定義的OrderField。它從Django提供的PositiveIntegerField欄位繼承。我們的OrderField欄位有一個可選的for_fields引數,允許我們指定序號相對於哪些欄位計算。

我們的欄位覆寫了PositiveIntegerField欄位的pre_save()方法,它會在該欄位儲存到資料庫中之前執行。我們在這個方法中執行以下操作:

  1. 我們檢查模型例項中是否已經存在這個欄位的值。我們使用self.attname,這是模型中指定的這個欄位的屬性名。如果屬性的值不是None,我們如下計算序號:

    • 我們構建一個QuerySet檢索這個欄位模型所有物件。我們通過訪問self.model檢索欄位所屬的模型類。
    • 我們用定義在欄位的for_fields引數中的模型欄位(如果有的話)的當前值過濾QuerySet。這樣,我們就能相對於給定欄位計算序號。
    • 我們用last_item = qs.lastest(self.attname)從資料庫中檢索序號最大的物件。如果沒有找到物件,我們假設它是第一個物件,並分配序號0。
    • 如果找到一個物件,我們在找到的最大序號上加1。
    • 我們用setattr()把計算的序號分配給模型例項中的欄位值,並返回這個值。
  2. 如果模型例項有當前欄位的值,則什麼都不做。

當你建立自定義模型欄位時,讓它們是通用的。避免分局特定模型或欄位硬編碼資料。你的欄位應該可以用於所有模型。

你可以在這裡閱讀更多關於編寫自定義模型欄位的資訊。

讓我們在模型中新增新欄位。編輯courses應用的models.py檔案,並匯入新的欄位:

from .fields import OrderField

然後在Module模型中新增OrderField欄位:

order = OrderField(blank=True, for_fields=['course'])

我們命名新欄位為order,並通過設定for_fields=['course'],指定相對於課程計算序號。這意味著一個新單元會分配給同一個Course物件中最新的單元加1。現在編輯Module模型的__str__()方法,並如下引入它的序號:

def __str__(self):
    return '{}. {}'.format(self.order, self.title)

單元內容也需要遵循特定序號。在Content模型中新增一個OrderField欄位:

order = OrderField(blank=True, for_fields=['module'])

這次我們指定序號相對於module欄位計算。最後,讓我們為兩個模型新增預設排序。在ModuleContent模型中新增以下Meta類:

class Meta:
    ordering = ['order']

現在ModuleContent模型看起來是這樣的:

class Module(models.Model):
    course = models.ForeignKey(Course, related_name='modules')
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    order = OrderField(blank=True, for_fields=['course'])

    class Meta:
        ordering = ['order']

    def __str__(self):
        return '{}. {}'.format(self.order, self.title)

class Content(models.Model):
    module = models.ForeignKey(Module, related_name='contents')
    content_type = models.ForeignKey(
        ContentType,
        limit_choices_to = {
            'model__in': ('text', 'video', 'image', 'file')
        }
    )
    object_id = models.PositiveIntegerField()
    item = GenericForeignKey('content_type', 'object_id')
    order = OrderField(blank=True, for_fields=['module'])

    class Meta:
        ordering = ['order']

讓我們建立反映新序號欄位的模型遷移。開啟終端,並執行以下命令:

python manage.py makemigrations courses

你會看到以下輸出:

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:

Django告訴我們,因為我們在已存在的模型中添加了新欄位,所以必須為資料庫中已存在的行提供預設值。如果欄位有null=True,則可以接受空值,並且Django建立遷移時不要求提供預設值。我們可以指定一個預設值,或者取消資料庫遷移,並在建立遷移之前在models.py檔案的order欄位中新增default屬性。

輸入1,然後按下Enter,為已存在的記錄提供一個預設值。你會看到以下輸出:

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作為已存在記錄的預設值,然後按下Enter。Django還會要求你為Module模型提供預設值。選擇第一個選項,然後再次輸入0作為預設值。最後,你會看到類似這樣的輸出:

Migrations for 'courses':
  courses/migrations/0003_auto_20170518_0743.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

這個命令的輸出會告訴你遷移已經應用成功:

Applying courses.0003_auto_20170518_0743... OK

讓我們測試新欄位。使用python manage.py shell命令開啟終端,並如下建立一個新課程:

>>> from django.contrib.auth.models import User
>>> from courses.models import Subject, Course, Module
>>> user = User.objects.latest('id')
>>> subject = Subject.objects.latest('id')
>>> 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

OrderField設定它的值為0,因為這是給定課程的第一個Module物件。現在我們建立同一個課程的第二個單元:

>>> m2 = Module.objects.create(course=c1, title='Module 2')
>>> m2.order
1

OrderField在已存在物件的最大序號上加1來計算下一個序號。讓我們指定一個特定序號來建立第三個單元:

>>> m3 = Module.objects.create(course=c1, title='Module 3', order=5)
>>> m3.order
5

如果我們指定了自定義序號,則OrderField欄位不會介入,並且使用給定的order值。

讓我們新增第四個單元:

>>> m4 = Module.objects.create(course=c1, title='Module 4')
>>> m4.order
6

這個單元的序號已經自動設定了。我們的OrderField欄位不能保證連續的序號。但是它關注已存在的序號值,總是根據已存在的最大序號值分配下一個序號。

讓我們建立第二個課程,並新增一個單元:

>>> c2 = Course.objects.create(subject=subject, owner=user, title='Course 2', slug='course2')
>>> m5 = Module.objects.create(course=c2, title='Module 1')
>>> m5.order
0

要計算新的單元序號,該欄位只考慮屬於同一個課程的已存在單元。因為這個第二個課程的第一個單元,所以序號為0。這是因為我們在Module模型的order欄位中指定了for_fields=['course']

恭喜你!你已經成功的建立了第一個自定義模型欄位。