第五章 内容分享功能

在上一章我们使用内置验证框架迅速的建立了整个网站的用户相关功能,还学习了如何通过一对一字段扩展用户信息,以及为网站添加第三方认证登录功能。

这一章会学习使用JavaScript小书签程序,将其他网站的图片内容分享到本站,还将学习使用jQuery在Django中使用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

这是我们用于存储图片的模型,来看一下具体的字段:

数据库索引可以有效的提高数据库查询效率。对于频繁使用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()函数,该函数含有实际的分享图片代码。在文件开始的地方还定义了如下几个全局变量:

现在来编写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取得数据:

这里还使用了多对多字段的管理器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文件,在</body>之前增加下列代码:

<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加载完毕后会执行该方法。DOMDocument 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. csrftoken cookie中取得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,并且includelist_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数据库。