制作分享内容的功能
如果一个网站是需要向多个用户或者向公众提供服务的(之前的博客可以说是单用户系统),在引入了用户验证系统之后,整个网站的功能设计基本上都是围绕用户开展的。就像主流的大型网站如电商和社交网站,所有的功能都是基于用户角色开展的。
本章开始的内容逐渐硬核。要实现通过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代码解释。所以可以在当前页面执行一些程序。
我们这里就打算写一个小书签程序,放在浏览器上,用户看到想分享的链接,就可以通过这个小程序将链接分享到我们的网站。前端所有东西感觉阮一峰都写过相应的文章,小书签也不例外。
用户将会这样使用我们的小书签:
- 用户将我们网站上的一个链接拖到浏览器的书签栏中,代码就会保存到浏览器的书签里
- 到任何其他网站上点击这个书签,小书签程序就会运行
这样做有一个问题,就是用户第一次添加了书签之后,一般不会再更新书签,我们便很难再更新其中的程序。一个替代的做法是,小书签里只放一个启动程序,实际执行的时候从一个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>
页面内的逻辑是:
- 新引入users_like变量保存所有喜欢该图片的用户,然后检测当前登录用户是不是在users_like中。
- 定义一个外观为按钮的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从分页器中获得数据。