1. 程式人生 > 其它 >Django2實戰示例 第五章 內容分享功能

Django2實戰示例 第五章 內容分享功能

第五章 內容分享功能

在上一章我們使用內建驗證框架迅速的建立了整個網站的使用者相關功能,還學習瞭如何通過一對一欄位擴充套件使用者資訊,以及為網站新增第三方認證登入功能。

這一章會學習使用JavaScript小書籤程式,將其他網站的圖片內容分享到本站,還將學習使用jQuery在Django中使用AJAX技術。本章包含如下要點

  • 建立多對多關係
  • 自定義表單行為
  • 在Django中使用jQuery
  • 建立jQuery小書籤程式
  • 使用sorl-thumbnail建立縮圖
  • 使用jQuery傳送AJAX請求和建立AJAX檢視
  • 建立檢視的自定義裝飾器
  • AJAX動態載入頁面

1建立圖片分享功能

我們的站點將讓使用者可以收藏然後分享他們在網際網路上看到的圖片到本站來,為此將要做以下工作:

  • 用一個數據類存放圖片和相關資訊
  • 建立表單和檢視用於處理圖片上傳
  • 需要建立一個系統,讓使用者將外站圖片貼到本站來。

這是一個獨立與使用者驗證系統的新功能,為此新建一個應用images

django-admin startapp images

然後在settings.py中啟用該應用:

INSTALLED_APPS = [
    # ...
    'images.apps.ImagesConfig',
]

1.1建立圖片模型

編輯images應用的models.py檔案,新增如下程式碼:

from django.db import models
from django.conf import settings

class Image(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='images_created', on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200,blank=True)
    url = models.URLField()
    image = models.ImageField(upload_to='images/%Y/%m/%d')
    description = models.TextField(blank=True)
    created = models.DateField(auto_now_add=True,db_index=True)

    def __str__(self):
        return self.title

這是我們用於儲存圖片的模型,來看一下具體的欄位:

  • user:這是一個連線到User模型的外來鍵,體現了使用者與圖片的一對多關係,即一個使用者可以上傳多個圖片。
  • title:圖片的名稱
  • slug:該圖片的簡稱,用於動態建立該圖片的URL
  • image:圖片檔案欄位,用於存放圖片
  • description:可選的關於圖片的描述
  • created:圖片分享到本站來的時間,使用了auto_now_add自動生成建立時間,並且使用了db_index=True建立索引

資料庫索引可以有效的提高資料庫查詢效率。對於頻繁使用filter()exclude()或者order_by()等方法的欄位推薦建立欄位。ForeignKey

和設定了unique=True的欄位預設會被建立索引。還可以使用Meta.index_together建立聯合索引。

譯者注:為created欄位建立索引是常用做法。

這裡我們需要自定義該模型的行為,重寫Image模型的save()方法,使圖片在儲存到資料庫時,自動根據title欄位生成slug欄位的內容。匯入slugify()然後為Image模型新增一個save()方法:

from django.utils.text import slugify

class Image(models.Model):
    # ......

    def save(self, *args, kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super(Image, self).save(*args, kwargs)

譯者注:原書程式碼縮排有誤,此處已經修改為正確版本。

在這段程式碼裡,使用了Django內建的slugify()自動生成了slug欄位的內容。之後呼叫超類的方法儲存圖片,這樣使用者無需手工輸入。

1.2建立多對多關係

我們將在Image模型中再新增一個外來鍵,用於儲存哪些使用者喜歡該圖片。由於一個使用者可能喜歡多個圖片,一個圖片也可能被多個使用者喜歡,因此圖片和使用者之間多對多的關係,需要修改Image模型新增如下欄位:

users_like = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='images_liked', blank=True)

當定義了ManyToManyField多對多外來鍵欄位時,Django會建立一張中間表,中間表分別通過外來鍵關聯到當前的模型和ManyToManyField()的第一個引數對應的模型,多對多關係可以用於任意兩個有關係的模型。

ForeignKey一樣,related_name屬性定義了多對多欄位反向查詢的名稱,多對多欄位提供了一個多對多模型管理器用來進行查詢,類似image.users_like.all(),如果是從user物件查詢,則類似user.images_liked.all()

之後進行Image類的資料遷移。

1.3新增圖片模型至管理後臺

編輯images應用的admin.py檔案,將Image類新增至管理後臺:

from django.contrib import admin
from .models import Image

@admin.register(Image)
class ImageAdmin(admin.ModelAdmin):
    list_display = ['title', 'slug', 'image', 'created']
    list_filter = ['created']

啟動站點,開啟http://127.0.0.1:8000/admin/,可以看到Image已經被加入管理後臺,如圖所示:

2從外站分享內容至本站

我們實現使用者將外站圖片分享到本站的方式是:使用者提供圖片的URL,一個標題和可選的秒數,我們的站點會將該圖片下載下來,建立一個對應的新Image物件,然後儲存進資料庫。

已經建立完了圖片模型,這裡我們需要建立一個表單供使用者提交圖片資訊。在Images應用下建立forms.py檔案,然後新增如下程式碼:

from django import forms
from .models import Image

class ImageCreateForm(forms.ModelForm):
    class Meta:
        model = Image
        fields = ('title', 'url', 'description',)
        widgets = {
            'url': forms.HiddenInput,
        }

這裡使用了ModelForm類,基於Image模型建立了表單,僅包含titleurldescription欄位。使用者無需直接在表單中輸入圖片URL,我們將使用一個JavaScript小書籤程式來從外站選擇一個圖片並將其URL作為Get請求的引數,然後訪問我們的站點。所以我們使用了HiddenInput小外掛替代了預設的url欄位的設定。我們這麼做是希望這個欄位不被使用者看到。

2.1驗證表單欄位

為了驗證這個URL是一個圖片,需要檢查URL中的檔名是否以.jpg.jpeg副檔名結尾。像在之前章節那樣,我們將針對url欄位編寫一個自定義驗證器clean_url(),這樣表單物件呼叫is_valid()時,我們的驗證器就可以修改資料或者報錯。新增如下方法到ImageCreateForm

def clean_url(self):
    url = self.cleaned_data['url']
    valid_extensions = ['jpg', 'jpeg']
    extension = url.rsplit('.', 1)[1].lower()
    if extension not in valid_extensions:
        raise forms.ValidationError('The given URL does not match valid image extensions.')
    return url

在上邊的程式碼中,定義了clean_URL()方法來驗證url欄位,該方法解釋如下:

  1. cleaned_data中獲取url欄位的值
  2. 將URL通過從右邊開始的第一個.進行切分,然後取切分結果的第二個元素,也就是副檔名進行比較。如果驗證失敗,則丟擲一個ValidationError錯誤。這裡我們採用的驗證方式比較簡陋,而且僅支援jpg型別圖片,你可以採用正則表示式或者其他高階方法來驗證URL是否是一個有效的圖片檔案地址。

除了驗證URL之外,我們還必須在驗證成功的時候將圖片下載並儲存到資料庫中。我們可以使用處理該表單的檢視來完成這個操作,但更常用的方式是重寫表單的save()來實現此功能。

2.2重寫表單的save()方法

在之前已經知道,ModelForm有一個save()方法,將當前的模型資料儲存到資料庫中並且返回該物件。這個方法還接受一個commit布林值引數,用於確定是否實際將資料持久化到資料庫中。如果commit=False,則save()方法僅返回當前的資料物件,但不執行資料庫寫入操作。因此我們可以重寫save()方法,讓其下載圖片之後,再將資料物件寫入資料庫。

新增如下匯入語句到forms.py檔案:

from urllib import request
from django.core.files.base import ContentFile
from django.utils.text import slugify

之後新增下列save()方法至ImageCreateForm類中:

def save(self, force_insert=False, force_update=False, commit=True):
    image = super(ImageCreateForm, self).save(commit=False)
    image_url = self.cleaned_data['url']
    image_name = '{}.{}'.format(slugify(image.title), image_url.rsplit('.', 1)[1].lower())

    # 根據URL下載圖片
    response = request.urlopen(image_url)
    image.image.save(image_name, ContentFile(response.read()), save=False)

    if commit:
        image.save()
    return image

我們重寫了save()方法,保持與原來方法一樣的預設引數設定。重寫的方法工作邏輯如下:

  1. 先呼叫父類的save()方法,使用現有表單資料建立一個新的image資料物件但不儲存
  2. cleaned_data中獲取URL
  3. image.slug與副檔名拼成新的檔名
  4. 使用Python的urllib模組下載圖片,然後使用image欄位的save()方法儲存到MEDIA目錄中。image欄位的save()方法的引數之一ContentFile是下載的圖片內容,這裡使用了save=False防止直接將欄位寫入資料庫。
  5. 為了和原save()方法的行為保持一致,僅當commit=True的時候寫入資料庫。

譯者注:本章到現在為止出現了模型的save()方法,表單的save()方法和image欄位的save()方法,讀者不要混淆。

之後來編寫處理表單的檢視,編輯images應用的views.py檔案,新增如下程式碼:

from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .forms import ImageCreateForm

@login_required
def image_create(request):
    if request.method == "POST":
        # 表單被提交
        form = ImageCreateForm(request.POST)
        if form.is_valid():
            # 表單驗證通過
            cd = form.cleaned_data
            new_item = form.save(commit=False)
            # 將當前使用者附加到資料物件上
            new_item.user = request.user
            new_item.save()
            messages.success(request, 'Image added successfully')
            # 重定向到新建立的資料物件的詳情檢視
            return redirect(new_item.get_absolute_url())
    else:
        # 根據GET請求傳入的引數建立表單物件
        form = ImageCreateForm(data=request.GET)

    return render(request, 'images/image/create.html', {'section': 'images', 'form': form})

使用@login_required裝飾器令image_create檢視僅供登入後的使用者使用,這個檢視工作邏輯如下::

  1. 我們通過一個Get請求附加的引數建立表單物件,引數會帶著urltitle欄位對應的內容。這個Get請求是由之後我們建立的JavaScript小書籤程式發起的,現在,我們就假設該表單已經被初始化而且被使用者確認並提交。
  2. 表單提交後,如果驗證通過,那麼建立一個新的Image物件,但是不存入資料庫。
  3. 取得當前的使用者,賦給Image物件的外來鍵後進行儲存,這樣就可以知道該圖片由哪個使用者上傳。
  4. 將圖片寫入資料庫。
  5. 建立一個成功儲存圖片的訊息,然後將使用者重定向到規範化的圖片物件的URL,現在還沒有為Image模型建立get_absolute_url()方法,稍後會進行建立。

images應用中建立urls.py檔案,新增如下程式碼:

from django.urls import path
from . import views

app_name = 'images'

urlpatterns = [
    path('create/', views.image_create, name='create'),
]

然後編輯bookmarks專案的根urls.py檔案,為images應用增加一條二級路由匹配:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('account/', include('account.urls')),
    path('social-auth/', include('social_django.urls', namespace='social')),
    path('images/', include('images.urls', namespace='images')),
]

最後來建立對應的模板,在images應用的目錄下建立如下目錄和檔案結構:

templates/
    images/
        image/
            create.html

然後編輯剛剛建立的create.html檔案,新增如下程式碼:

{# create.html #}
{% extends "base.html" %}
{% block title %}Bookmark an image{% endblock %}
{% block content %}
    <h1>Bookmark an image</h1>
    <img src="{{ request.GET.url }}" class="image-preview">
    <form action="." method="post">
        {{ form.as_p }}
        {% csrf_token %}
        <input type="submit" value="Bookmark it!">
    </form>
{% endblock %}

現在啟動站點,輸入類似http://127.0.0.1:8000/images/create/?title=...&url=...的連結,其中包含titleurl兩個引數,分別表示圖片的名稱和URL地址。可以使用下邊這個測試地址:

http://127.0.0.1:8000/images/create/?title=%20Django%20and%20Duke&url=http://upload.wikimedia.org/wikipedia/commons/8/85/Django_Reinhardt_and_Duke_Ellington_%28Gottlieb%29.jpg

應該可以看到下面的頁面:

在description內輸入一些內容,然後點選BOOKMARK IT!按鈕,一個新的Image物件會被存入資料庫。由於此時get_absolute_url()方法還未編寫,所以會報錯如下:

此時不用擔心這個錯誤資訊,通過剛才編寫的檢視可以知道,執行到這裡報錯說明圖片已經成功存入資料庫,開啟http://127.0.0.1:8000/admin/images/image/即可看到該圖片的資訊,如下圖所示:

2.3使用jQuery建立小書籤程式

小書籤程式是一段JavaScript程式碼,可以被瀏覽器儲存為書籤,在點選該小書籤時,其中的JavaScript程式碼被執行,從而實現一些功能。

一些比較知名的站點,如Pinterest,使用小書籤程式讓使用者可以從其他網站將內容分享到其網站上。我們建立的程式和這個小書籤程式類似,讓使用者將圖片分享到我們的站點來。

我們將使用jQuery建立小書籤程式,jQuery是一個得到廣泛使用的JavaScript庫,可以快速開發基於JavaScript的程式,可以訪問其官方站點https://jquery.com/瞭解更多資訊。

使用者將會這樣使用我們的小書籤:

  1. 使用者將我們網站上的一個連結拖到瀏覽器的書籤欄中,這個連結的href屬性中儲存著JS程式碼,這個連結被儲存到瀏覽器書籤成為一個可點選的書籤
  2. 使用者在其他網站上看到想分享的圖片,點選這個小書籤,小書籤裡邊的程式被執行,讓使用者選擇要分享的圖片然後自動以GET請求訪問我們的網站。

由於小書籤程式儲存在使用者的瀏覽器上,在使用者第一次儲存後,想要更新該程式就很困難,所以一般小書籤程式實際上是一個程式啟動器,實際執行的程式位於我們的網站上。這就是我們建立小書籤的方法解說,現在來實現:

images/templates/目錄下建立一個檔案,叫做bookmarklet_launcher.js,新增如下JavaScript程式碼:

(function () {
    if (window.myBookmarklet !== undefined) {
        myBookmarklet()
    }
    else {
        document.body.appendChild(document.createElement('script')).src = 'http://127.0.0.1:8000/static/js/bookmarklet.js?r=' + Math.floor(Math.random() * 99999999999999999999);
    }
})();

這段JavaScript程式碼首先檢查myBookmarklet這個名稱是否存在於當前環境,這樣使用者反覆點選小書籤程式也不會多次執行相同程式。如果名稱不存在,就在當前的頁面中增加一個<script>標籤,也就是匯入了我們網站的一段JavaScript程式並且執行。之後的r引數生成了一段隨機數,目的是讓瀏覽器每次都去請求實際的JavaScript檔案,而不從快取中直接讀取

新增的<script>標籤的src屬性為"http://127.0.0.1:8000/static/js/bookmarklet.js?r=xxxxxxxxxxxxxxxxxxxx",指向我們網站自己的JavaScript程式檔案,這樣小程式每次執行的時候,都會將我們網站上的JavaScript程式在當前頁面執行。下邊我們把小程式連結加入到使用者登入首頁,以讓使用者可以將其儲存成書籤。

這就是一個啟動器,用於載入實際上位於我們站點上的bookmarklet.js然後在當前頁面執行。

編輯account應用的模板目錄中的account/dashboard.html,讓其看起來像下邊這樣:

{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
    <h1>Dashboard</h1>

    {% with total_images_created=request.user.images_created.count %}
        <p>Welcome to your dashboard. You have bookmarked {{ total_images_created }} image{{ total_images_created|pluralize }}.</p>
    {% endwith %}

    <p>Drag the following button to your bookmarks toolbar to bookmark images from other websites <a href="javascript:{% include "bookmarklet_launcher.js" %}" class="button">Bookmark it</a></p>

    <p>You can also <a href="{% url "edit" %}">edit your profile</a> or <a href="{% url "password_change" %}">change your password</a>.<p>
{% endblock %}

現在首頁已經當前使用者已經分享了多少圖片到本站,使用了{% with %}標籤用於設定一個變數名給圖片總數,可以避免反覆查詢資料庫。然後包含了一個href屬性是小標籤啟動器程式的連結,供使用者將其拖動到瀏覽器的書籤欄上。這裡使用了include將JavaScript檔案的內容匯入。

譯者注:這裡靈活使用了include標籤,可見引入的模板檔案不需要是HTML檔案,只要是文字檔案即可,這裡就通過該標籤將bookmarklet_launcher.js檔案引入,避免了在此處硬編碼JavaScript程式碼。

在瀏覽器中開啟http://127.0.0.1:8000/account/,可以看到如下頁面:

現在開始來編寫實際執行的JavaScript程式,在images應用下建立如下目錄和檔案結構:

static/
    js/
        bookmarklet.js

在隨書程式碼中可以看到images應用目錄下有static/css/目錄,將其中的css/目錄拷貝到你的應用的static/目錄下,小書籤程式將要使用其中的bookmarklet.css檔案。

開啟剛建立的bookmarklet.js檔案,新增如下程式碼:

(function () {
    let jquery_version = '3.3.1';
    let site_url='http://127.0.0.1:8000/';
    let static_url = site_url + 'static/';
    let min_width = 100;
    let min_height = 100;
    function bookmarklet(msg){
        //這裡是分享圖片的程式碼
    }

    // 檢查頁面是否載入了jQuery,如果沒有就進行載入,嘗試15次
    if(typeof window.jQuery !== 'undefined'){
        bookmarklet();
    }
    else {
        let conflict = typeof window.$ !== 'undefined';
        let script = document.createElement('script');
        script.src = '//ajax.googleapis.com/ajax/libs/jquery/' + jquery_version + '/jquery.min.js';
        document.head.appendChild(script);
        let attempts = 15;
        (function(){
            if(typeof window.jQuery === 'undefined'){
                if(--attempts>0){
                    window.setTimeout(arguments.callee, 250)
                }else {
                    alert("An error ocurred while loading jQuery")
                }
            }else {
                bookmarklet()
            }
        })();
    }
})();

這是載入jQuery的程式碼。如果jQuery已經在當前頁面載入,則會使用當前頁面的jQuery,如果沒有載入,則將jQuery位於google的CDN地址加入到頁面中。當jQuery被成功載入的時候,就去執行bookmarklet()函式,該函式含有實際的分享圖片程式碼。在檔案開始的地方還定義瞭如下幾個全域性變數:

  • jquery_version:jQuery的版本號
  • site_urlstatic_url:我們網站的地址和靜態檔案地址
  • min_widthmin_height:用於控制程式尋找的最小圖片寬高,小於這個寬或高的圖片不會出現在供分享的清單中。

現在來編寫bookmarklet()函式,編輯檔案裡的bookmarklet()函式的程式碼如下:

function bookmarklet(msg){
    // 載入CSS檔案
    let css = jQuery('<link>');
    css.attr({
        rel:'stylesheet',
        type:'text/css',
        href:static_url + 'css/bookmarklet.css?r=' + Math.floor(Math.random()*99999999999999999999)
    });
    jQuery('head').append(css);

    // 載入HTML結構
    box_html = '<div id="bookmarklet"><a href="#" id="close">×</a><h1>Select an image to bookmark:</h1><div class="images"></div></div>';
    jQuery('body').append(box_html);

    // 關閉事件
    jQuery('#boorkmarklet #close').click(function () {
        jQuery("#bookmarklet").remove();
    });
};

這段程式碼的邏輯如下:

  1. 載入bookmarklet.css,使用隨機數確保瀏覽器不從快取中讀取
  2. 加入一塊HTML結構程式碼到當前頁面的<body>標籤中,在頁面的右上方顯示一個浮動的圖片列表區域
  3. 加入了一個事件,使用者點選新增的區域的關閉按鈕時,將我們新增的HTML結構程式碼從當前頁面中刪除。使用jQuery,通過父元素ID為bookmarklet#bookmarklet#close選擇器定位我們的HTML元素。關於jQuery的選擇器,可以參考https://api.jquery.com/category/selectors/

在載入了HTML結構和對應的CSS樣式後,接下來要新增分享功能,將如下程式碼追加在bookmarklet()函式的內部:

    // 尋找頁面內所有圖片然後顯示在新增的HTML結構中
    jQuery.each(jQuery('img[src$="jpg"]'), function(index, image) {
    if (jQuery(image).width() >= min_width && jQuery(image).height() >= min_height)
    {
        image_url = jQuery(image).attr('src');
        jQuery('#bookmarklet .images').append('<a href="#"><img src="'+ image_url +'" /></a>');
    }
});

這段程式碼使用了img[src$="jpg"]選擇器來選擇所有jpg格式的<img>元素,然後使用each()方法,對其中每個圖片檢查是否大於最小寬高,如果大於就將其加入到我們HTML結構的<div class="images">標籤中。

在開始試驗編寫的功能之前,還必須進行最後的設定。現在HTTPS協議使用的很廣泛,為了安全起見,瀏覽器一般不會允許HTTP協議的小書籤程式執行,因此必須給我們自己的網站一個HTTPS地址,但是Django的測試伺服器無法自動支援HTTPS,為了測試小書籤的功能,使用Ngrok可以建立一個隧道將自己的本機通過HTTP和HTTPS地址向外提供服務。

https://ngrok.com/download下載Ngrok,之後在系統命令列裡執行如下命令:

./ngrok http 8000

Ngrok建立一個隧道連線到本機的8000埠,然後為其分配一個域名,可以看到窗口裡顯示:

ngrok by @inconshreveable                                                                               (Ctrl+C to quit)

Session Status                online
Session Expires               7 hours, 58 minutes
Version                       2.2.8
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://d0de3ca5.ngrok.io -> localhost:8000
Forwarding                    https://d0de3ca5.ngrok.io -> localhost:8000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

其中的https://d0de3ca5.ngrok.io就是可以訪問到本機Django服務的HTTPS地址,把這個地址加入到settings.py檔案的的ALLOWED_HOSTS裡:

ALLOWED_HOSTS = [
    'mysite.com',
    'localhost',
    '127.0.0.1',
    `'d0de3ca5.ngrok.io'`
]

譯者注:最好按照Ngrok官網的教程註冊一個使用者再使用,否則HTTPS的域名很快過期,需要重新啟動Ngrok並進行相關配置。

啟動站點,然後訪問這個HTTPS地址,應該可以看到站點的登入頁面,說明HTTPS服務正常。

獲得HTTPS地址之後,編輯bookmarklet_launcher.js檔案,將其中的http://127.0.0.1:8000/替換為新獲得的HTTPS地址:

(function () {
    if (window.myBookmarklet !== undefined) {
        myBookmarklet()
    }
    else {
        document.body.appendChild(document.createElement('script')).src = 'https://d0de3ca5.ngrok.io/static/js/bookmarklet.js?r=' + Math.floor(Math.random() * 99999999999999999999);
    }
})();

再將js/bookmarklet.js檔案中的這一行:

let site_url='http://127.0.0.1:8000/';

修改為:

let site_url='https://d0de3ca5.ngrok.io/';

然後開啟https://d0de3ca5.ngrok.io/account/,將頁面上的BOOKMART IT的綠色按鈕拖到瀏覽器的書籤欄上,如圖所示:

開啟任意一個圖片比較多的網站,點選小書籤,應該可以看到螢幕右上方顯示一塊新區域,裡邊列出了當前站點可供分享的圖片,如下所示:

我們希望使用者點選一張圖片,就可以將該圖片分享到我們的網站,進入之前編寫的檢視對應的表單填寫頁面上,編輯js/bookmarklet.js檔案,在bookmarklet()函式底部追加:

    // 點選圖片時按照指定URL訪問我們的網站
    jQuery('#bookmarklet .images a').click(function(e){
      let selected_image = jQuery(this).children('img').attr('src');
      // hide bookmarklet
      jQuery('#bookmarklet').hide();
      // open new window to submit the image
      window.open(site_url +'images/create/?url='
                  + encodeURIComponent(selected_image)
                  + '&title='
                  + encodeURIComponent(jQuery('title').text()),
                  '_blank');
    });

這個函式的邏輯如下:

  1. 為每個圖片元素繫結一個click()事件
  2. 當用戶點選一個圖片時,設定一個變數selected_image,是這個圖片的URL地址。
  3. 之後隱藏新增的HTML結構,使用selected_image和網站的的<title>的內容外加我們的網站地址,生成一個連結然後在新視窗中開啟連結,實現GET請求附帶引數訪問我們自己的網站。

開啟一個網站,然後點選小書籤,在右上方出現的視窗中點選一張圖片,會被重定向到我們網站的圖片建立頁面,如下所示:

撒花慶祝,我們實現了第一個小書籤程式,然後將其整合到了我們的Django專案中。

3建立圖片詳情檢視

完成了圖片分享並儲存的功能之後,現在需要建立一個詳情檢視用來展示具體圖片,編輯images應用的views.py檔案,新增如下程式碼:

from django.shortcuts import get_object_or_404
from .models import Image

def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id,slug=slug)
    return render(request, 'images/image/detail.html', {'section':'images','image':image})

這是一個簡單的用於展示某個圖片詳情的檢視,編輯images應用的urls.py檔案為該檢視新增一行URL:

path('detail/<int:id>/<slug:slug>/', views.image_detail, name='detail'),

有過上個專案的經驗,此時可以知道必須編寫Image類的get_absolute_url()方法用於生成規範化連結,開啟images應用的models.py檔案,新增get_absolute_url()方法如下:

from django.urls import reverse

class Image(models.Model):
    # ...
    def get_absolute_url(self):
        return reverse('images:detail', args=[self.id, self.slug])

記住在每個編寫的模型中加入該方法,以快捷的生成對應的URL。

譯者注:在django 2裡,urls.py檔案中使用include()方法並通過namespace引數指定名稱空間,還需要在對應的下一級urls.py裡寫上app_name = 'namespace'來設定名稱空間。如果include()方法中設定了名稱空間,其對應的urls.py檔案中的app_name必須一致,否則會報錯。如果include()方法未設定名稱空間,則以app_name的設定為準。

最後就是建立模板了,在images應用的模板目錄中的/images/image/路徑下建立detail.html檔案並新增如下程式碼:

{#/templates/images/image/detail.html#}
{% extends 'base.html' %}

{% block title %}
    {{ image.title }}
{% endblock %}

{% block content %}
    <h1>{{ image.title }}</h1>
    <img src="{{ image.image.url }}" class="image-detail">
    {% with total_likes=image.users_like.count %}
        <div class="image-info">
            <div>
        <span class="count">
            {{ total_likes }} like{{ total_likes|pluralize }}
        </span>
            </div>
            {{ image.description|linebreaks }}
        </div>
        <div class="image-likes">
            {% for user in image.users_like.all %}
                <div>
                    <img src="{{ user.profile.photo.url }}">
                    <p>{{ user.first_name }}</p>
                </div>
            {% empty %}
                Nobody likes this image yet.
            {% endfor %}
        </div>
    {% endwith %}
{% endblock %}

這是展示具體某個圖片的模板,其中使用{% with %}儲存查詢結果到total_likes變數中避免了查詢兩次資料庫。然後展示圖片的discription欄位,之後迭代image.users_like.all,顯示出所有喜歡該圖片的使用者。

在一個模板中反覆使用某一個QuerySet時,可以通過{% with %}將其查詢結果儲存到一個變數中,避免重複查詢。

譯者注:image.image.urluser.profile.photo.url:這兩個欄位不是Image類中的url欄位,而是在定義Imagefield欄位時upload_to的路徑名稱。

現在可以通過小書籤程式再匯入一個新圖片,儲存成功之後,會被重定向到圖片的詳情頁,如下所示:

4建立圖片縮圖

現在我們的圖片詳情頁展示的是原始的圖片,但是圖片的尺寸可能差異很大,而且原始圖片的大小可能會很大,載入時間較長。一般網站需要大量展示圖片的通用做法是生成圖片的縮圖然後展示縮圖。我們使用一個第三方應用sorl-thumbnail來生成縮圖。

在系統命令列中輸入以下命令安裝sorl-thumbnail

pip install sorl-thumbnail==12.4.1

然後在settings.py檔案中啟用該應用:

INSTALLED_APPS = [
    # ...
    'sorl.thumbnail',
]

之後按照慣例執行資料遷移程式,可以看到資料庫中增添了該應用的一個數據表。

這個模組採用了兩種方法顯示縮圖:一是提供了新的模板標籤{% thumbnail %}直接在模板內顯示縮圖,二是基於Imagefield自定義的圖片欄位,用於在模型內設定縮圖欄位。這兩種方式都可以顯示縮圖。

我們採用模板標籤的方式。編輯images/image/detail.html,找到如下這行:

<img src="{{ image.image.url }}" class="image-detail">

將其替換成下列程式碼:

{% load thumbnail %}
{% thumbnail image.image "300" as im %}
    <a href="{{ image.image.url }}">
        <img src="{{ im.url }}" class="image-detail">
    </a>
{% endthumbnail %}

這裡我們定義了個固定寬度為300畫素的縮圖,當用戶第一次開啟圖片詳情頁時,一個縮圖會被建立在靜態資料夾下,頁面的原圖片連結會被縮圖連結所代替。啟動站點然後開啟某個圖片詳情頁,可以在專案根目錄的media/cache/找到該圖片對應的縮圖。

sorl-thumbnail可以使用很多演算法生成各種縮圖。如果生成不了縮圖,在settings.py裡增加一行THUMBNAIL_DEBUG=True,之後在命令列視窗中可以看到debug資訊。具體文件可以看https://sorl-thumbnail.readthedocs.io/

5使用jQuery傳送AJAX請求

現在要給站點增加AJAX相關的功能,AJAX是Asynchronous JavaScript and XML的簡稱,這個技術使用一系列方式實現非同步HTTP請求,可以從伺服器非同步取得資料並無需過載全部頁面。不像名字裡邊必須採取XML格式,傳送和收取資料可以採用JSON,HTML甚至純文字。

AJAX的相關內容可以參考在Django中使用jQuery傳送AJAX請求使用原生JS傳送AJAX請求的方法

我們將要給圖片詳情頁面增加一個按鈕,讓使用者可以點選該按鈕表示喜歡該圖片,之後再點選該按鈕可以取消喜歡該圖片。首先我們先為這個功能建立檢視函式,編寫images應用的views.py檔案,新增如下程式碼:

from django.http import JsonResponse
from django.views.decorators.http import require_POST

@login_required
@require_POST
def image_like(request):
    image_id = request.POST.get('id')
    action = request.POST.get('action')
    if image_id and action:
        try:
            image = Image.objects.get(id=image_id)
            if action == "like":
                image.users_like.add(request.user)
            else:
                image.users_like.remove(request.user)
            return JsonResponse({'status': 'ok'})
        except:
            pass
    return JsonResponse({'status': 'ko'})

這個檢視使用了兩個裝飾器,@login_required的作用是僅供已登入使用者使用,@require_POST的作用是讓該檢視僅接受POST請求,否則返回一個HttpResponseNotAllowed物件,即HTTP 405錯誤。Django還提供了一個@require_GET裝飾器用於只接受GET請求,還提供了一個@require_http_methods裝飾器,可以指定允許哪些型別的HTTP請求。

在這個檢視中,我們還是用了兩個Post.get取得資料:

  • image_id:使用者正在喜歡/不喜歡的圖片的ID
  • action:使用者執行的動作,用字串like表示喜歡,unlike表示不喜歡

這裡還使用了多對多欄位的管理器users_like查詢圖片與喜歡使用者之間的關係,然後使用add()remove()方法用於新增和去除多對多關係。add()方法即使傳入已經存在的資料物件,也不會重複建立關係,remove()即使傳入不存在的物件,也不會報錯。還有一個clear()方法可以快速的從關聯表中全部清除多對多關係。

最後,使用了JsonResponse類,這個類的作用是將一個HTTP請求附加上application/json請求頭,並將其中的內容序列化為JSON格式的字串

編輯images應用的urls.py,為該檢視配置URL:

    path('like/', views.image_like, name='like'),

5.1載入jQuery

我們將使用jQuery來發送AJAX請求,為此需要在頁面內載入jQuery,為了可以讓jQuery在所有的模板內都生效,將其載入程式碼放入base.html檔案中,編輯account應用的base.html檔案,在之前增加下列程式碼:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script>
    $(document).ready(function () {
        {% block domready %}
        {% endblock %}
    });
</script>

我們從Google CDN中載入了jQuery,可以直接在https://jquery.com/下載jQuery並將其放入本應用的static資料夾內。

在引入jQuery之後,增加了一個<script>標籤,定義了一個$(document).ready(),這是一個jQuery方法,在DOM載入完畢後會執行該方法。DOM是Document Object Model的簡稱,由瀏覽器在載入頁面時生成,以樹形結構儲存當前頁面的所有節點資料。這樣保證了JS程式碼執行時,其要操作的物件已經全部生成。

domready塊,用於存放在DOM載入完畢後執行的JS程式碼,我們將在需要執行JS程式碼的具體模板中編寫該塊內容。

注意不要混淆JavaScript程式碼和Djanog模板標籤。Django的模板語言在服務端進行處理,轉換最終的HTML位元組流,瀏覽器取得HTML位元組流建立頁面和DOM物件,並執行JavaScript程式碼。有時候動態的生成JavaScript程式碼非常方便。

在這一章裡,我們直接將JS程式碼通過模板內塊的形式編寫進來,這是為了教學方便。最好的方式是從靜態檔案中匯入.js檔案,以做到有效解耦HTML與JS。

5.2AJAX中使用CSRF

在第二章中已經瞭解到POST請求中需要包含{% csrf_token %}生成的token資料,以防止跨站偽造請求攻擊。不過在AJAX中傳送CRSF token有點不方便,所以Django允許在AJAX請求中設定一個X-CSRFToken請求頭,其中包含CSRF token的資料。jQuery在傳送AJAX請求的時候設定上該請求頭,就可以完成CRSF的傳送了。

為了在AJAX請求中設定CSRF token,需要做如下事情:

  1. csrftokencookie中取得CSRF token,如果開啟了CSRF中介軟體,cookie中一直會有CSRF token資料
  2. 將CSRF token資料設定在AJAX請求的X-CSRFToken請求頭中

可以在https://docs.djangoproject.com/en/2.0/ref/csrf/#ajax閱讀更多關於Django中CSRF與AJAX的資訊。

修改剛剛在base.html中增加的JS程式碼部分,修改成下邊這樣:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
<script>
    let csrftoken = Cookies.get('csrftoken');

    function csrfSafeMethon(method) {
        // 如下的HTTP請求不需要設定CRSF資訊
        return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
    }

    $.ajaxSetup({
        beforeSend: function (xhr, settings) {
            if (!csrfSafeMethon(settings.type) && !this.crossDomain) {
                xhr.setRequestHeader("X-CSRFToken", csrftoken);
            }
        }
    });
        $(document).ready(function () {
        {% block domready %}
        {% endblock %}
    });
</script>

以上程式碼解釋如下::

  1. 通過外部CDN匯入了一個JS庫js-cookie--一個輕量級的操作cookie的第三方庫,可以在https://github.com/js-cookie/js-cookie找到該庫的詳細資訊。
  2. 通過Cookies.get()方法拿到csrftoken的值
  3. 建立csrfSafeMethod()函式,使用正則驗證HTTP請求種類,GET,HEAD,OPTIONS和TRACE型別的請求無需新增CSRF資訊
  4. 呼叫$.ajaxSetup()方法,在AJAX請求傳送之前,為請求設定X-CSRFToken請求頭資訊,這個設定會影響到所有jQuery傳送的AJAX請求。

這樣所有的不安全的HTTP請求,比如GETPUT,都會被新增上CRSF資訊。

5.3jQuery傳送AJAX請求

編輯images應用的images/image/detail.html檔案,找到下邊這行:

{% with total_likes=image.users_like.count %}

將其修改成:

{% with total_likes=image.users_like.count users_like=image.users_like.all %}

然後修改<div class="image-info">其中的內容,如下:

<div class="image-info">
    <div>
        <span class="count">
             <span class="total">{{ total_likes }}</span>
             like{{ total_likes|pluralize }}
        </span>
        <a href="#" data-id="{{ image.id }}" data-action="{% if request.user in users_like %}un{% endif %}like" class="like button">
            {% if request.user not in users_like %}
                Like
            {% else %}
                Unlike
            {% endif %}
        </a>
    </div>
    {{ image.description|linebreaks }}
</div>

模板內首先通過{% with %}指定了新的變數users_like,用於存放所有喜歡該圖片的使用者,可以避免反覆查詢。然後顯示總的喜歡該圖片的人數,還包含一個按鈕樣式的<a>標籤。這個按鈕根據當前使用者是否在users_like中,顯示likeunlike,還為<a>標籤設定了兩個HTML5自定義屬性:

  1. data-id:當前頁面顯示圖片的ID
  2. data-action:使用者的動作,喜歡或者不喜歡,值是likeunlike

我們將把這兩個HTML5自定義屬性的值通過AJAX傳送給image_like檢視,當用戶點選喜歡/不喜歡按鈕的時候,我們需要在客戶端做如下操作:

  1. 呼叫AJAX檢視,傳入兩個引數:idaction
  2. 如果AJAX請求成功返回,更新按鈕的data-action屬性為相反的操作(原來是like則更新為unlike,反之亦反)
  3. 更新喜歡當前圖片的使用者總數

為此來編寫頁面所需的JS程式碼,在images/image/detail.html中新增domready塊的內容:

{% block domready %}
$('a.like').click(function (e) {
    e.preventDefault();
    $.post('{% url 'images:like' %}',
        {
            id: $(this).data('id'),
            action: $(this).data('action'),
        },
        function (data) {
            if (data['status'] === 'ok') {
                let previous_action = $('a.like').data('action');
                //切換 data-action 屬性
                $('a.like').data('action', previous_action === 'like' ? 'unlike' : 'like');
                //切換按鈕文字
                $('a.like').text(previous_action === 'like' ? 'Unlike' : 'Like');
                //更新總的喜歡人數
                let previous_likes = parseInt($('span.count.total').text());
                $('span.count.total').text(previous_action === 'like' ? previous_likes + 1 : previous_likes - 1);
            }
        }
    );
});
{% endblock %}

這段程式碼的邏輯解釋如下:

  1. 使用$('a.like')選擇所有屬於like類的<a>標籤
  2. <a>標籤繫結click事件,每次點選就傳送AJAX請求。
  3. 在事件處理函式內,使用e.preventDefault()阻止<a>的預設功能,即阻止開啟新的超連結
  4. 使用$.post()傳送非同步的POST請求。jQuery還提供了$.get()用於傳送非同步的GET請求,和一個更底層的$.ajax()方法。
  5. 使用{% url %}反向解析出AJAX的請求目標地址
  6. 建立要傳送的資料字典,通過<a>標籤的data-iddata-action設定idaction鍵值對。
  7. 設定回撥函式,當成功收到AJAX響應時執行,響應資料被包含在物件data中。
  8. 根據data中的status判斷值是否為ok,如果是則切換data-action和按鈕文字。
  9. 根據剛才執行的結果,對總喜歡人數增加1或者減少1

譯者注:原書這裡的邏輯是為了讓讀者可以迅速看出操作結果。在多使用者的環境中,不能如此簡單的增減1,因為每次執行動作後,該人數的變化未必是1。

開啟任意圖片詳情頁,可以看到新增的總人數和按鈕,如下所示:

點選一下LIKE按鈕,可以看到如下所示:

如果再點選UNLIKE按鈕,可以看到按鈕變回LIKE,人數也減少1

如果提示The 'photo' attribute has no file associated with it錯誤,原書作者在這裡沒有講清楚,錯誤原因是detail.html頁面用了user.profile.photo.url,但沒有上傳使用者頭像。在管理後臺給每個使用者上傳頭像,再訪問任意詳情圖片頁,就不會報錯了。直接修改多對多的關係再檢視這張表,就能發現顯示出同樣喜歡了這張圖的使用者頭像和名稱。這裡如果要完善的話,應該判斷使用者是否上傳頭像,如果沒有就用預設頭像代替。

當編寫JavaScript程式碼傳送AJAX請求時,為了方便除錯,推薦使用開發工具而不是在Django中編寫程式碼。現代瀏覽器都帶有開發工具用於除錯頁面和JavaScript程式碼,通常可以按F12或者在頁面上右擊選“檢查”來啟動開發工具。

6建立自定義裝飾器

在AJAX檢視中使用了@require_POST裝飾器以限制檢視僅接受POST請求,這顯然還不夠,需要讓這個檢視僅接受AJAX請求才行。Django對於HTTP請求物件提供了一個is_ajax()方法,通過HTTP請求頭部欄位HTTP_X_REQUESTED_WITH HTTP判斷該請求是否是一個XMLHttpRequest物件,即一個AJAX請求。

我們準備自行編寫一個裝飾器,用於檢查HTTP請求的HTTP_X_REQUESTED_WITH頭部資訊,從而限制我們的檢視僅接受AJAX請求。Python中的裝飾器是接受一個函式為引數的函式,為引數函式附加執行額外功能而不改變原函式的功能。 如果對裝飾器不太瞭解,可以參考Python官方文件:https://www.python.org/dev/peps/pep-0318/

我們準備編寫的裝飾器是通用的,所以在bookmarks專案根目錄下建立一個common包,其中的檔案如下:

common/
    __init__.py
    decorators.py

編輯decorators.py檔案,新增下列程式碼:

from django.http import HttpResponseBadRequest

def ajax_required(func):
    def wrap(request, *args, kwargs):
        if not request.is_ajax():
            return HttpResponseBadRequest()
        else:
            return func(request, *args, kwargs)
    wrap.__doc__ = func.__doc__
    wrap.__name__ = func.__name__
    return wrap

這段程式碼就是自定義的ajax_required裝飾器函式。其中定義了一個wrap函式,如果請求不是AJAX請求,就返回HttpResponseBadRequest即HTTP 400錯誤。如果是AJAX請求,則原來檢視的功能正常執行。

然後編輯images應用的views.py檔案,匯入新的包然後為檢視新增自定義裝飾器:

from common.decorators import ajax_required

@ajax_required
@login_required
@require_POST
def image_like(request):
    # ......

如果用瀏覽器直接訪問http://127.0.0.1:8000/images/like/,會得到400錯誤。(未新增該裝飾器之前,得到的是由@require_POST返回的405錯誤)。

如果你發現專案中的很多檢視對同一個條件做判斷,可以考慮將該判斷邏輯編寫為一個自定義裝飾器。

7AJAX分頁

我們將製作一個圖片列表頁,用於列出我們網站所有的圖片。這裡將使用AJAX動態的傳送圖片資料,即當頁面滾動到底部的時候,就會繼續顯示新的圖片,直到全部圖片都顯示完畢。

為此我們將編寫一個圖片列表檢視,同時處理普通的HTTP請求和AJAX請求。當用戶一開始以GET請求方式訪問圖片列表頁時,會顯示第一頁圖片。當用戶滾動到頁面底部時,通過AJAX傳送請求給該檢視,返回下一頁圖片顯示在頁面底部;如此反覆直到所有圖片都顯示完畢。

編輯images應用的views.py檔案,建立一個新的檢視:

from django.http import HttpResponse
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger

@login_required
def image_list(request):
    images = Image.objects.all()
    paginator = Paginator(images, 8)
    page = request.GET.get('page')
    try:
        images = paginator.page(page)
    except PageNotAnInteger:
        # 如果頁數不是整數,就返回第一頁
        images = paginator.page(1)
    except EmptyPage:
        # 如果是不存在的頁數,而且請求是AJAX請求,返回空字串
        if request.is_ajax():
            return HttpResponse('')
        # 如果頁數超範圍,顯示最後一頁
        images = paginator.page(paginator.num_pages)
    if request.is_ajax():
        return render(request, 'images/image/list_ajax.html', {'section': 'images', 'images': images})
    return render(request, 'images/image/list.html', {'section': 'images', 'images': images})

在這個檢視中,先查詢所有圖片,然後使用內建的分頁功能建立Paginator物件,按照8個圖片一頁進行分組。當HTTP請求的頁面不存在的時候捕捉EmptyPage異常,判斷此時請求的種類,如果是AJAX請求,說明頁面到了底部,返回空字串即可。我們將結果渲染到兩個不同的模板中:

  1. 對於AJAX請求,渲染list_ajax.html模板,這個模板僅包含圖片內容。
  2. 對於普通請求,渲染list.html,這個模板會繼承base.html,並且include``list_ajax.html模板

編輯images應用的urls.py檔案,為新檢視新增一行URL:

    path('', views.image_list, name='list'),

最後來建立前述的兩個模板,在images/image/模板目錄下建立list_ajax.html,新增如下程式碼:

{% load thumbnail %}

{% for image in images %}
  <div class="image">
    <a href="{{ image.get_absolute_url }}">
      {% thumbnail image.image "300x300" crop="100%" as im %}
        <a href="{{ image.get_absolute_url }}">
          <img src="{{ im.url }}">
        </a>
      {% endthumbnail %}
    </a>
    <div class="info">
      <a href="{{ image.get_absolute_url }}" class="title">
        {{ image.title }}
      </a>
    </div>
  </div>
{% endfor %}

上述模板顯示圖片列表,將使用這個模板渲染AJAX請求返回的結果。在相同目錄下建立list.html檔案並新增如下程式碼:

{% extends 'base.html' %}

{% block title %}
Images bookmarked
{% endblock %}

{% block content %}
<h1>Images bookmarked</h1>
<div id="image-list">
{% include 'images/image/list_ajax.html' %}
</div>
{% endblock %}

這個頁面繼承base.html,同時包含了list_ajax.html,這個模板中還必須包含傳送AJAX的JS程式碼,所以繼續在其中編寫domready塊的內容:

{% block domready %}
let page = 1;
let empty_page = false;
let block_request = false;
$(window).scroll(
    function () {
        let margin = $(document).height() - $(window).height() - 200;
        if ($(window).scrollTop() > margin && empty_page === false && block_request === false) {
            block_request = true;
            page += 1;
            $.get("?page=" + page, function (data) {
                if (data === "") {
                    empty_page = true;
                }
                else {
                    block_request = false;
                    $('#image-list').append(data)
                }
            });
        }
    }
);
{% endblock %}

這段程式碼實現了滾動載入功能,其中的邏輯解釋如下:

  1. 首先建立如下變數:
    1. page:儲存當前頁數
    2. empty_page:判斷是否已經到達頁面底部。如果已經到達底部,阻止傳送AJAX請求
    3. block_request:在已經發送AJAX請求但還未收到響應時阻止再發送AJAX請求
  2. 使用$(window).scroll()方法監聽滾動事件
  3. 計算頁面高度和視窗高度的差,記錄在margin變數中,表示未顯示的頁面的高度。再減去200表示當滾動到離視窗底部還有200畫素的時候傳送AJAX請求。
  4. 判斷block_requestempty_page同時為False的情況下發送AJAX請求。
  5. 傳送AJAX請求之後將block_request設定為True,避免再次傳送,同時將page增加1,下一次傳送的時候就獲取下一個分頁結果。
  6. 使用$.get()方法傳送型別為GET的AJAX請求,將響應資料儲存到data中,然後處理以下兩種情況:
    1. 響應資料中無內容:說明檢視返回了空字串,已經沒有更多的分頁結果可以載入,此時將empty_page設定為True,阻止後續所有AJAX請求傳送
    2. 響應資料中有資料:說明得到了新的分頁結果,將其中的內容追加到id屬性為image-list的元素內部,頁面下方增加出新的圖片。

在瀏覽器中開啟http://127.0.0.1:8000/images/,可以看到如下頁面(需要自行新增一些圖片):

滾動該頁面到底部,確保在資料庫中添加了超過8張圖片,會看到額外的圖片被載入並顯示出來

最後修改base.html檔案中頂部導航欄的連線,新增下列程式碼:

<li {% if section == "images" %}class="selected"{% endif %}>
    <a href="{% url "images:list" %}">Images</a>
</li>

現在就可以通過使用者首頁訪問圖片清單頁面了。

總結

這一章建立了一個小書籤程式,用於分享圖片到本站,還實現了jQuery傳送AJAX請求和使用AJAX動態載入頁面。

下一章將學習建立關注系統,涉及到模型的通用關係,訊號功能和資料庫的非規範化等知識,還將學習到在Django中使用Redis資料庫。