制作分享内容的功能

如果一个网站是需要向多个用户或者向公众提供服务的(之前的博客可以说是单用户系统),在引入了用户验证系统之后,整个网站的功能设计基本上都是围绕用户开展的。就像主流的大型网站如电商和社交网站,所有的功能都是基于用户角色开展的。

本章开始的内容逐渐硬核。要实现通过JavaScript小书签程序从其他网站将内容分享到这个网站的功能,以及在Django中使用jQUERY来发送AJAX请求。主要内容有:

  • ORM中的多对多关系
  • 自定义表单的行为
  • 使用jQuery
  • 建一个jQuery bookmarklet
  • 给图片建立缩略图
  • 使用AJAX功能和视图
  • 给视图建立自定义装饰器
  • 用AJAX分页

制作图片书签功能

开始之前分析一下需求,想实现的功能是:用户在本网站和其他网站发现的图片,可以收藏和分享,要做以下几件事情。

  • 用一个数据类存放图片和相关信息
  • 建立表单和视图用于控制图片上传
  • 需要建立一个系统,让用户如果在外网发现了图片,也能贴到本站来。

刚才的用户相关的功能全部放在了account应用下边,现在我们使用一个新的app 叫做 images。建立之后老样子配置到INSTALLED_APPS里。

建立图片数据类和保存数据的方法。

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.CharField(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表。其他要解释的就是created里边用db_index参数建立了索引。对于经常使用filter等之前介绍过的列建立索引,可以提高查询效率。

还要给这个模型自定义一些行为。这里开始自定义的东西就多了起来:

# 重写.save()方法,自动添加slug
    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = self.title
        super(Image, self).save(*args, **kwargs)

如果不存在slug就用标题当做slug,然后调用父类的方法来保存图片。

然后继续修改这个Image类。之前我们建立了外键user,这表示这个图片是谁上传(来源于哪个用户)的,还需要一个字段记录哪些用户喜欢这个图片。这个时候两张表之间的关系,就不再是一对多或者一对一了。一张图片可以有很多个用户喜欢,一个用户会喜欢很多张图片,从两张表的每行数据的角度来看,都是一对多的关系,这样的表与表之间的关系,就是多对多关系。

由于在数据库里没有这种关系,只有外键,所以现在可以总结一下:

  • 一对一就是unqiue 的外键,不允许重复,本质上相当于一张表的扩展。其中外键字段是独特的,不允许重复。
  • 一对多就是普通外键,在多的那一方建立。多那边的每一个数据只允许关联一个外键,即数据+外键的组合是独特的。
  • 多对多就是通过一张中间表建立关系,中间表两个外键一个连到表A,一个连到表B,中间表的两个外键的组合是独特的。

那么就在Image类里继续添加一个多对多的关系字段:

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

多对多的字段和其他的关系字段类似,第一个参数就是关联的表。多对多字段可以设置在两张表的任何一侧,不像外键在一对多的关系中设置在多的那侧,在一对一的关系中设置在扩展的那一侧。

建立了多对多之后,需要知道的是,在一对一和一对多的关系中,直接调用外键属性,得到的是一个数据。在多对多中,直接调用外键属性名,相当于到另外一张表里去查询了所有和当前字段有关系的数据,相当于一个模型管理器来使用。所以需要查询一张表被哪些用户喜欢,只要使用 image.users_like.all()即可。

完整的Image类如下:

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.CharField(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)
    users_like = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='images_liked',blank=True)

    def __str__(self):
        return self.title

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

之后就可以makemigration 和migrate了,之后还是老套路,将Image类加到管理后台去。admin.py里的内容:

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']

从其他网站分享内容过来

这个功能实际上就是用户从按照一个固定的格式访问我们的站点,站点就可以把图片下载回来,然后把相关信息放到Image数据中。

还是重复表单–>视图–>URL–>模板的思路:

from django import forms
from .models import Image


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

表单这里有一个新的内容,就是在不重写某个字段的情况下,通过widget参数,给指定的字段名配置widget。

这里还没完,有一个字段是URL,这个默认的验证器在URL只要符合一般的URL的时候就通过,但是我们需要保存图片,因此这个URL的结尾一定是图片的后缀名,所以要给url增加一个自定义的验证器,以验证URL是否是一个图片的URL。

    def clean_url(self):
        url = self.cleaned_data['url']
        valid_extensions = ['jpg', 'jpeg', 'png', 'bmp', 'gif']
        extension = url.rsplit('.', 1)[1].lower()
        if extension not in valid_extensions:
            raise forms.ValidationError("The given URL is not a image")
        return url

自定义验证器对url使用内置切分方法,然后取后缀名进行比较。逻辑很简单。这样就可以保证URL指向一个图片了。

在这个步骤里最后要解决的问题就是通过URL来获取图片了。当用户提交的数据都完成的时候,我们在之前的项目里是实例化一个表单类,然后直接调用表单类的.save()方法就把表单类存到了新的或者instance参数指定的数据对象中。这一次我们来重写.save()方法,让用户提交成功之后,就自动下载然后保存到数据库中。

在ImageCreateForm类中继续编写:

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

    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())

        # 下载图片
        response = request.urlopen(image_url)
        image.image.save(image_name, ContentFile(response.read()), save=False)

        if commit:
            image.save()
        return image

这个表单类的save方法的逻辑如下:

  • 先调用超类的save方法,建立一个新的image数据对象
  • 获取url和图片名称,这其中用内置的slugify来处理标题,拆分url得到图片后缀,这相当于是用用户提供的名称给这个图片重新命名。
  • 调用了image类的image字段的.save()方法,传进去的是名称和读出的内容。
  • 最后是为了让行为和原来一致,只有commit=True的时候才会去真正将表单对象写入数据库。
  • image.image是Image类中的Imagefield字段,Imagefield字段的save方法继承自Filefield字段:FieldFile.save(name,content,save=True)手动地将一个文件内容关联到该Field,name是调用后文件的名称,content是要关联的文件的内容,save表示该实例是否在调用完成后就执行保存到数据库。注:这里的content必须是一个django.core.files.File的实例,而不是python内置的file对象,但是可以通过用python内置的open方法获得file对象后构造一个File实例。这里的File实例就是ContentFile生成的对象。
  • 再梳理一遍这个自定义方法的逻辑:先把现有的字段通过父类方法执行一下,暂时存储但是不写进数据库,表单字段只有’title’, ‘url’, ‘description’,这个时候当前数据对象里还有user和 image两个必须有的字段没有存进来。然后去下载文件生成名称和文件对象,调用image.image.save()方法存了image字段。下边的视图函数给form配上了user为当前的user。最后model的save方法把slug存了进去,这样各个字段都齐全了。

解决了数据存储的问题,现在来编写视图:

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:
        form = ImageCreateForm(data=request.GET)

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

这个视图需要解释以下几点:

  • 首先是为什么使用GET获取数据生成请求,是因为在后边用JS发分享链接到我们的视图,会传入title和url,就会用这个来生成基础的表单展示,后续让用户再完整填写,只有收到POST请求的时候才会去下载图片。
  • 如果POST请求提交,然后都验证通过,那么建立一个新的Image对象,但是不能保存,因为外键还没有关联,必须取得当前的用户,赋给Image类的外键,才能够保存
  • 这里使用的redirect方法,在成功的完成了保存之后,会跳转到新保存的图片的detail页面。但是现在还没有编写Image Model的该方法,之后编写。
  • new_item = form.save(commit=False) 这是一个以前忽略的点:form对象调用.save()方法的时候,会返回这个表单对应的数据对象,这个数据对象就保存了当前form里已经有的数据。在这里,new_item就是当前表单执行了save之后返回的Image类的一个新实例,其中的字段只有form表单显式指定的title,url和description三个字段,以及执行了ImageCreateForm表单类里被重写的save方法之后新增的image字段(也就是那个图片)。之后再执行的new_item.save()方法是Image ORM类里重写的.save()方法,用于生成slug,此时除了users_like字段所有的信息都完全生成,可以写入数据库了。Print一下new_item的类型就可以发现是<class 'images.models.Image'>

整理思路

上边的部分编写完毕以后,可以看出来一个雏形,就是通过一个GET类型的链接访问这个视图,然后自动生成一个页面,只要用户点击,就可以把这个图片上传到本站。

在具体处理数据的过程中,Image类的全部字段是被各个击破的,首先是表单类显式指定了三个字段url, title 和 description,这三个通过外部链接的GET请求拿到。之后重写表单类的save方法通过拿到的URL将图片保存在image字段里。然后调用表单类的save方法得到数据对象,再给数据对象配上当前用户的user赋给外键,最后调用数据对象被重写过的save方法增加slug。这样除了users_like字段全都生成了。

目前在写入之后,会报错,因为Image类里没有定义 .get_absolute_url()方法。不过由于跳转是在数据保存后才完成的,所以数据依然被写进了数据库。

为了方便,书里提供了一个测试链接,也贴出来方便使用:测试链接

主要是都重写了model 和 form 的save方法,看着有点绕,只要知道form.save()返回的是一个model对象就会清楚很多了,通过form–>form.save–>view赋值–>model.save的方式逐步补全了字段

继续编写urls.py和create.html

# images/urls.py, 新增creete/路径
    path('create/', views.image_create, name='create'),
# main urls.py 主路由里把分支路由添加上
    path('images/', include('images.urls', namespace='images'))

{# 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 %}

urls.py和视图的配置比较简单,页面通过url显示这张图片,然后表单里有一个隐藏的url。实际提交表单的时候,再执行保存图片的动作。这样就完成好了通过一个固定的GET请求建立一个页面然后保存图片的功能。

通过测试连接传入的图片示例页面如下:

使用jQuery建立小书签 bookmarklet

bookmarklet在wiki上叫小书签,是以URL的形式被保存为浏览器的书签,点击时小书签执行一些功能。

小书签其实就是一个JS程序,和我们通常上网时候保存的单纯网站地址的书签不同,小书签以javascript:开头。后边的部分会被浏览器按照JS代码解释。所以可以在当前页面执行一些程序。

我们这里就打算写一个小书签程序,放在浏览器上,用户看到想分享的链接,就可以通过这个小程序将链接分享到我们的网站。前端所有东西感觉阮一峰都写过相应的文章,小书签也不例外。

用户将会这样使用我们的小书签:

  1. 用户将我们网站上的一个链接拖到浏览器的书签栏中,代码就会保存到浏览器的书签里
  2. 到任何其他网站上点击这个书签,小书签程序就会运行

这样做有一个问题,就是用户第一次添加了书签之后,一般不会再更新书签,我们便很难再更新其中的程序。一个替代的做法是,小书签里只放一个启动程序,实际执行的时候从一个URL里去执行实际的代码,这样就可以更新代码了。只是这么说大概还想不到如何去实现,下边开始编写代码:

编写JS程序

需要让用户拖到浏览器书签栏里的小书签程序一般长这个样子: <a href="javascript:alert('hi');">xxx</a>,按照刚才的想法,我们必须要编写一个页面让用户拖其中的链接,还需要编写一个JS程序,用于小书签内的启动器链接到这个JS程序。

在images/templates/下边新建一个bookmarklet_launcher.js:

(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);
    }
})();

这段代码的意思是,如果当前页面已经有了myBookmarklet这个函数的话,就执行这个函数,如果没有,就去当前页面的body里添加一个script标签,src为 "http://127.0.0.1:8000/static/js/bookmarklet.js?r=xxxxxxxxxxxxxxxxxxxx",也就是导入了我们网站的一段JS程序并且执行。

前边先判断是否存在的原因是避免用户反复点击从而反复加载。后边使用随机数的原因是避免浏览器直接从缓存中读取程序,而是每次都会去读取最新的程序。

这就是一个启动器,用于加载实际上位于我们站点上的bookmarklet.js然后在当前页面运行。

下一步就是来给用户增加将启动器加入书签栏的页面,这些内容都展示在用户自己的登录页面dashboard.html上:

{% 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>

第一部分展示当前用户已经上传了多少图片,通过Image的外键related_name进行查询;第二行就是一个小书签按钮,供用户拖到书签上,其中的内容通过include引入。

启动站点到/account/里边看一下。

现在把小书签拖过去还没有用,因为小书签里的地址 http://127.0.0.1:8000/static/js/bookmarklet.js还不存在,因此在images应用的目录下边建立static/js目录,然后建立bookmarklet.js。这里还记得把随书源代码中的CSS目录也copy到static中来。然后编写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){
        // Here goes our bookmarklet code
    }

    // Check if jQuery is loaded
    if(typeof window.jQuery !== 'undefined'){
        bookmarklet();
    }
    else {
        // check for conflicts
        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()
            }
        })();
    }
})();

现在又进入了JS时间(笑),这段代码先定义了几个变量,用于方便的修改jQuery的版本,站点相关的url,以及图片大小。然后的逻辑主要是导入jQuery,因此先检测jQuery是否存在,如果不存在就到Google的CDN上加载jQuery到head标签里。然后会间隔250毫秒重复去检测是否已经加载成功,一旦加载成功就执行bookmarklet函数。

现在来编写bookmarklet函数:

function bookmarklet(msg){
    // load 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);

    // load 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);

    // close event
    jQuery('#boorkmarklet #close').click(function () {
        jQuery("#bookmarklet").remove();
    });

    // find images and display them
    jQuery.each(jQuery('img[src$="jpg"]'), function (index, image) {
        if (jQuery(image).width() >= min_width && jQuery(image).height() >= min_height) {
            let image_url = jQuery(image).attr('src');
            jQuery('#bookmarklet .images').append('<a href="#"><img src="' + image_url + '"/></a>');
        }
    });
}

这段代码首先是装载CSS,用jQuery动态生成一个link元素,然后属性设置为我们网站里的那个CSS文件。然后加载HTML,使用一个box_html的HTML字符串,然后通过jQuery加载到body的尾部。之后给加载的元素绑定事件,让用户选择完图片后就清除新加入的元素。

最后一部分的功能是,用$.each方法遍历当前文档中的所有src以jpg结尾的img元素,然后逐个判断是否同时长和宽高于设置的最小值,是的话就把图片加入到最后插入的class类的div里。

在开始试验编写的功能之前,有些网站使用HTTPS协议,不会允许来自HTTP网站的小书签程序运行,因此必须将我们自己的网站弄成HTTPS出来,有一个工具Ngrok可以建立一个隧道将自己的本机变成HTTP和HTTPS向外提供服务。

下载下来解压之后得到一个exe文件,在命令行下运行:

ngrok http 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

现在就有了一个公开域名可以同时使用http和https协议访问本机了。把这个网址加入到settings.py的ALLOWED_HOSTS里。启动django服务,来实验一下是否能从刚才的域名采用https访问网站。

结果发现真的可以,这样我们就有了一个使用HTTPS的公开域名,去将bookmarlet_launcher.ls里的scr修改为这个新的https域名,把bookmarklet.js里的site_url变量也修改为这个域名。

配置好HTTPS之后的dashboard.html页面如下:

然后就访问新的HTTPS域名/account/login,任意用户登录之后,将那个BOOKMARTK IT的绿色按钮拖到书签栏里,然后就再找个网站,点击书签栏就会发现,页面左侧显示出了当前页面所有的大于100*100的JPG图像。

还剩下最后一步了,回想一下一开始我们编辑的视图是通过GET请求传递进来的,那么最后只需要将点击的那个图片的url和title拼接到一个GET请求上发回给我们的网站就可以了。继续编写bookmarklet函数:

    // when an image is selected open URL with it
    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');
    });

这个函数为点击图片绑定了事件,让selected_img的值为图片URL,然后隐藏这个框体。之后打开一个新页面,将之前我们的HTTPS网站加上配好的URL images/create/,之后加上GET请求的参数 ?url=用JS处理过的URI&title=JS拿到的当前页面的title值

经过试验,确实可以通过书签很方便的将图片加入到自己的站点中来。

小书签程序在第三方网站上工作的示例如下:

图片详情页与缩略图功能

刚才做完了外部内容分享并且保存至本机的功能,暂时不去动了。现在显然很迫切的需求就是给用户一个管理自己图片的功能,否则只上传,连图片都无法查看和操作,就没什么意义了。

Image类已经有了,现在要做的就是用视图和模板来做一个给用户展示图片详情的功能。编辑views.py:

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})

这个也很简单,目的是展示一幅图片所以使用了get_object_or_404,下边配置url

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

看到这里,写过博客项目的我们微微一笑,就知道肯定要写get_absolute_url按照这个格式返回图片的URL了。

# get_absolute_url in models.Image
from django.urls import reverse
    def get_absolute_url(self):
        return reverse('images:detail', args=[self.id, self.slug])

这里多说一下,在django 2里,除了在include url的时候用namespace关键字参数指定命名空间之外,还可以在urls.py里写上app_name = ‘namespace’ 来设置命名空间,而APP默认有一个同名的命名空间。通过命名空间加名称可以找到想要的url,如果name是唯一的,直接通过name也可以找到。

get_absolute_url是很多情况下为了取得当前对象URL,其他程序会去默认调用的方法,因此一定要多写,多用。

之后就是建立模板了:

{#/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是为了暂时保存查询结果,避免反复查询数据库。

这里还有一个要解释的就是 image.image.url 和 user.profile.photo.url,这两个url不是Image类中的url字段,而是在定义Imagefield时候upload_to的路径名称。

这个时候有的图片可以正常保存,有的会报Noreservematch错误,分析原因,其实是作者有一个小疏忽。因为在Image类重写的save方法里,只把title赋给了slug,不能保证slug字段一定能够被path自动使用的正则匹配到,这里就用django内置的Slugify来处理一下即可:

# 修改 models.Image的save方法
from django.template.defaultfilters import slugify
    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        super(Image, self).save(*args, **kwargs)

这样slug就正确了。每次导入图片之后,会自动跳到这个图片的详情页面,还能显示谁喜欢了这张图片。

图片详情页的示例如下:

在做完了图片分享的跳转网站之后,很显然还需要做一个图片列表页给用户进行管理。由于用户上传过来的图片很可能比较大,而且分辨率各不相同,如果每次都按照原尺寸传输图片,速度较慢。

常用的解决方案就是显示缩略图,然后统一在页面上显示一个固定的大小,这里使用一个第三方库 sorl-thumbnail 来生成缩略图。

pip install sorl-thumbnail==12.4.1

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

之后按惯例migrate,看到生成了一个新表。

这个模块的提供了一个新的模板标签{% thumbnail %}供在模板内显示缩略图,和一个基于Imagefield自定义的图片字段,用于在模型内设置缩略图字段。这两种方式都可以显示缩略图。

我们来采取模板标签的方法。先在detail.html页面中实验一下:

{% load thumbnail %}

{% block content %}
    <h1>{{ image.title }}</h1>
    {% thumbnail image.image '300' as im %}
    <a href="{{ image.image.url }}"><img src="{{ im.url }}" class="image-detail"></a>
    {% endthumbnail %}
    ...
{% endblock %}

通过之前提供的测试链接进来然后保存,通过浏览器的开发功能,可以看到生成了一个300*368大小的缩略图,放在了media/cache/目录下边。

这个插件还可以使用很多算法生成各种缩略图,具体文档可以看这里。值得一提的是如果生成不了,在settings.py里写一行 THUMBNAIL_DEBUG=True就可以让插件显示debug信息。

今后在自己开发的网站中,对于要向用户展示内容的图片,都应该使用缩略图。

使用AJAX

这是本章的第二个大内容。AJAX对于有基础的我们不再赘述了。有兴趣的读者可以看本站在Django中使用jQuery发送AJAX请求使用原生JS发送AJAX请求的方法

这里要实现的功能,就是用户在图片详情页点击喜欢和不喜欢。之前你可能已经注意到了,image_detail视图并没有使用@login_required装饰器,这是为了让所有用户都可以看到别人上传的图片然后来点赞。

点赞和取消赞是一个经常进行的动作,没有必要多次重载这个页面,这种只更新页面中的一小部分的功能,都可以用AJAX来实现。

编写视图

每个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'})

这里要解释几点:

  • 导入了 JsonResponse,作用是把一串字符串格式化成JSON格式,返回给前端。AJAX一般都用来发送和接收JSON格式的字符串。
  • require_POST是一个装饰器,限制视图只接受POST请求,比自己写判断要方便一些
  • 前端我们用AJAX准备发过来两个东西,一个是当前图片的id,一个是当前的动作,其中动作打算发的是like和unlike
  • 视图的其余逻辑很简单,如果一个用户点赞,就在Image类里多对多关系里增加这个用户,如果点了不喜欢,就去掉这个用户。
  • 最后根据操作成功与否,返回ok或者ko。
  • 多说一下的就是多对多关系的字段更新,有add,remove和clear三个方法,clear是清除该行数据的所有多对多关系。

通过后端的逻辑,我们就可以推断一下前端的逻辑:

  • 一个用户打开图片详情页,如果他之前没有喜欢过这个图片,那么应该给他展示一个点喜欢的按钮,只要点了这个按钮,就会发送AJAX的like到后端去。如果他已经喜欢过了这个图片,那么显示一个取消喜欢的按钮,只要点了这个按钮,就发送AJAX请求到后端。
  • 这个时候AJAX根据成功或者失败会返回不同的两个字符串,前端根据这个结果,相应的修改页面。如果成功了,就把原来的按钮变更另外一个按钮,如果失败,就显示错误信息但不修改按钮。

搞清楚了逻辑,下边来修改images/urls.py:

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

这个path就是用来接受AJAX请求的地址。

编写前端页面

这里我们还会解决几个小问题:

  • 页面内导入jQuery
  • 页面内增加JS代码
  • 页面内处理AJAX请求头增加CSRF
  • 修改页面元素的内容

前端页面打算使用jQuery来发送请求,所以需要引入jQuery,可见学后端也必须精通前端是多么的重要。

修改base.html,在body标签内的最下边加上这些内容:

<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script>
    $(document).ready(function () {
        {% block domready %}
        {% endblock %}
    });
</script>

原书是用了googleCDN的jQuery,这里为了方便,采用了国内bootCDN的jQuery。之后下边代码用了ready函数内写了一个块,用于在DOM加载完毕的时候运行其中的代码。

接下来的一个问题是处理CSRF,由于后端我们接受的是POST请求,为了安全起见不可能去关闭CSRF中间件,所以需要jQuery发送请求时将CSRF信息一起发送过来。为此,常用的做法是在页面上埋一个CSRF_TOKEN,然后通过jQuery拿到这个token,将其中的数据包含在请求中一起发送。

书里的方法更加硬核一些,找了一个依赖于jQuery的第三方库JS.cookie,从cookie中取CSRF。这是因为只要启用了CSRF中间件,每次cookie中都会有CSRF数据。由于这段代码也是每次必须执行的,所以直接写在base.html中:

{#在导入jQuery后增加#}
<script src="https://cdn.bootcss.com/js-cookie/latest/js.cookie.min.js"></script>
<script>
    let csrftoken = Cookies.get('csrftoken');

    function csrfSafeMethon(method) {
        // these HTTP methods do not require CSRF protection
        return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
    }

    $.ajaxSetup({
        beforeSend: function (xhr, settings) {
            if (!csrfSafeMethon(settings.type) && !this.crossDomain) {
                xhr.setRequestHeader("X-CSRFToken", csrftoken);
            }
        }
    });

这里解释一下几个要点:

  • 首先导入第三方库js-cookie,为了方便依然从国内BootCDN导入,js-cookie的源码地址
  • 通过Cookies.get方法拿到csrftoken 的值
  • 建立了一个函数使用正则验证去测试HTTP请求,一般GET,HEAD,OPTIONS和TRACE请求无需使用CSRF
  • 调用jQuery.ajaxSetup来对ajax做全局设置。
  • AJAX设置中的beforesend设置了一个回调函数,第一个参数一定是XHR对象,第二个就是settings,从中可以取得HTTP的请求类型,只要不属于这些类型,就给XHR的请求头设置上CSRF键值对。
  • 当然,还有很多方法可以设置请求头,在发送的时候使用beforesend和header属性都可以。

这样只要在点赞的时候发送AJAX的POST请求,其预先都设置好了请求头中包含CSRF信息。剩下的核心问题就是编写前端页面与发送AJAX请求相关的部分了:

修改detail.html页面,有几个地方需要修改:

1 把 {% with total_likes=image.users_like.count %} 这一行修改成:
{% with total_likes=image.users_like.count users_like=image.users_like.all %}

2 修改CSS类为image-info的div标签内的内容为:
<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>

页面内的逻辑是:

  1. 新引入users_like变量保存所有喜欢该图片的用户,然后检测当前登录用户是不是在users_like中。
  2. 定义一个外观为按钮的A标签,其中有两个符合HTML5标准的自定义数据属性data-id和data-action,其中data-action的内容和A标签的文本属性根据第一条逻辑的结果而变化。

剩下的工作就交给前端代码了,AJAX做的工作已经很明显了,就是发送A标签的自定义数据属性,然后根据返回的结果,来更新A标签和页面内人数相关的内容。在detail.html里增加一段:

{% 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');
                //toggle data-action
                $('a.like').data('action', previous_action === 'like' ? 'unlike' : 'like');
                //toggle link text
                $('a.like').text(previous_action === 'like' ? 'Unlike' : 'Like');
                //update total_likes
                let previous_likes = parseInt($('span.count.total').text());
                $('span.count.total').text(previous_action === 'like' ? previous_likes + 1 : previous_likes - 1);
            }
        }
    );
});
{% endblock %}

这段代码说实在写的挺简单粗暴:

  • 首先是给按钮绑定事件,事件里先停止按钮的默认功能,然后使用jQuery.post方法向反向解析的 ‘images:like’ URL发送POST AJAX请求,请求内容就是通过data取到的自定义属性id和action
  • 回调函数是匿名函数function(data),后边的逻辑判断很简单,就是返回数据的status为OK的时候,先拿到原来的A标签的action和内容,如果是unlike就变like,如果是like就变unlike
  • 最后还要处理人数,这个也很简单,如果原来是like就加1,原来是unlike就减1。

为什么说简单粗暴,是因为点赞之后处理人数增加或者减少1的逻辑太过于粗暴,由于可以多用户登录,每个用户点过赞以后,未必只会变动1,完善的处理应该是每次点赞之后,后端应该返回实际当前喜欢的人数以及这些人的列表,然后再去填充相关的页面。这里估计作者也是为了简化一些处理,让用户立竿见影的看到结果吧。

现在可以到detail页面看一看效果了。可以手工增加赞了。

不过现在还有一个问题是,图片详情页面添加了多对多关系的时候,一刷新就提示一个The ‘photo’ attribute has no file associated with it.错误,这个估计是作者在这里没有讲清楚,因为detail.html 的页面用了user.profile.photo.url,但没有上传用户头像所致。在管理后台给每个用户上几个头像,再任意按照生成detail URL的方式访问任意详情图片页,就不会报错了。直接修改多对多的关系再查看这张表,就能发现显示出同样喜欢了这张图的用户头像和名称。这里如果要完善的话,应该判断用户是否上传头像,如果没有就用默认头像代替。

点击变化的示例如下:

自定义装饰器限制AJAX视图的使用

随着网站功能的增多,问题是一个接一个的出现。思考一下接受AJAX请求的后端,只是一个普通的处理POST请求和返回JSON字符串的后端。一般来说,需要明确的区分普通的后端与AJAX的后端。Django里对于request对象内置了一个.is_ajax()方法来判断视图接受的请求是否是一个AJAX请求(AJAX请求是一个XMLHttpRequest,有着特殊的报头HTTP_X_REQUESTED_WITH HTTP)

为了实现这个功能,我们就自行编写一个装饰器,用来让AJAX视图只接受AJAX请求,相信装饰器对于我们已经不陌生了。我们的装饰器打算用于任何一个视图,因此就在项目目录里建立一个叫common的包(带有__init__.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

这里导入了HttpResponseBadRequest,这个的意思是返回一个400错误。如果是AJAX请求,则原来视图的功能正常执行。

想使用装饰器,就在views.py里像导入包然后像其他装饰器一样使用即可:

from common.decorators import ajax_required

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

之后试试直接访问:/images/like/,得到的是一个400错误。之前直接访问,得到的是一个405错误。

建立图片列表页并且使用AJAX分页

AJAX分页实现的一个功能是,当页面滚动到底部的时候,就会继续去发送请求拿到新的数据。我们准备建立一个新页面,用于展示当前用户所有上传的图片,但是只动态加载其中的一部分,当用户向下滚动的时候,会显示其他的图片。当达到预先设置好的最大值的时候,才算完整的一页。

先来写视图,这次依然用到内置的分页器:

@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)
    # 如果是AJAX请求,向下滚动的时候页数加了1,就再传页数加1的页面进到页面上渲染。
    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})

视图的逻辑比较简单,按照8个图片一页,滚动的时候触发事件,设置了几个变量防止反复提交请求和返回空白页(分页已经到最后一页)块就不再发送AJAX请求,这里唯一要注意的就是,如果是普通请求超出了范围,那么就返回最后一页,如果是AJAX请求超出了范围,就意味着到了页面底部,那么就返回空字符串,什么也不干。其他情况下对于普通请求和AJAX请求都一视同仁。

但是下边为了实现滚动的要求,采用了一些小技巧,对于AJAX请求我们来渲染list_ajax.html,对于普通请求渲染list.html,但是其中包含list_ajax.html。现在先把URL配置好:

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

通过URL可以看出,我们把图片列表页作为images/的默认页面。

之后先来编写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 %}

这个页面的逻辑比较简单,就是拿到当前的image然后挨个展示。

再编写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 %}

这个页面包含了ajax请求展示的页面,但是关键是在于其中的JS代码,还记得我们在base.html里定义了domready 这个块专门用于执行DOM加载完毕的代码,现在继续在list.html中编写JS代码:

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)
                }
            });
        }
    }
);

这段JS代码是用了滚动事件,设置了一个margin变量为页面实际的高(超出当前窗口也算上)减去-整个视口的高再减去200。向下滑动距离底部小于200像素的时候的时候就会触发这个事件。同时设置回调函数,如果还能拿到新页面,则阻止请求的函数再改成false。回调函数里的data就是HTML字节流,直接添加在#image-list的标签之内即可。

最后还有一点要修改的,就是把base.html里的Images链接修改成”images:list”即可。

还有一个地方是,当分页的时候console 里会提示,Image的查询没有排序,进行分页可能会造成不一致的结果,这个在image_list的视图中增加一个按照create排序即可。created字段在建立模型的时候已经设置过索引

还有一点要说的就是我们用了同一个视图处理ajax请求和普通请求,所以ajax不用设置目标url,只需要直接发送即可,默认就是当前页面的URL。例子中的$,get方法的第一个参数直接使用了GET请求的附加数据,AJAX实际发送到的URL地址就是当前页面地址加上后边的附加参数。

带有动态加载功能的图片列表页的示例如下:

总结

这一章的开发难度比起上一个项目有着明显的提升,而且使用了前后端的的各种技术和第三方插件:

  • 建立包含有文件字段的数据类
  • 重新model和form类的save方法,用来完善一些字段
  • form.save()方法返回model类的对象。而Imagefield也有save方法和.url属性
  • 小书签的JS启动器编写
  • 使用JS动态加载CSS和JS文件
  • sorl模块生成缩略图
  • 使用AJAX发送请求和更新页面
  • 自定义装饰器控制视图只能接受AJAX请求
  • 滚动加载图片的技巧:使用AJAX从分页器中获得数据。