在上一章我们使用内置验证框架迅速的建立了整个网站的用户相关功能,还学习了如何通过一对一字段扩展用户信息,以及为网站添加第三方认证登录功能。
这一章会学习使用JavaScript小书签程序,将其他网站的图片内容分享到本站,还将学习使用jQuery在Django中使用AJAX技术。本章包含如下要点
我们的站点将让用户可以收藏然后分享他们在互联网上看到的图片到本站来,为此将要做以下工作:
这是一个独立与用户验证系统的新功能,为此新建一个应用images
:
django-admin startapp images
然后在settings.py
中激活该应用:
INSTALLED_APPS = [ # ... 'images.apps.ImagesConfig', ]
编辑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
:该图片的简称,用于动态建立该图片的URLimage
:图片文件字段,用于存放图片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
字段的内容。之后调用超类的方法保存图片,这样用户无需手工输入。
我们将在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
类的数据迁移。
编辑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
已经被加入管理后台,如图所示:
我们实现用户将外站图片分享到本站的方式是:用户提供图片的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
模型创建了表单,仅包含title
,url
和description
字段。用户无需直接在表单中输入图片URL,我们将使用一个JavaScript小书签程序来从外站选择一个图片并将其URL作为Get
请求的参数,然后访问我们的站点。所以我们使用了HiddenInput
小插件替代了默认的url
字段的设置。我们这么做是希望这个字段不被用户看到。
为了验证这个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字段,该方法解释如下:
cleaned_data
中获取url
字段的值.
进行切分,然后取切分结果的第二个元素,也就是扩展名进行比较。如果验证失败,则抛出一个ValidationError
错误。这里我们采用的验证方式比较简陋,而且仅支持jpg
类型图片,你可以采用正则表达式或者其他高级方法来验证URL是否是一个有效的图片文件地址。除了验证URL之外,我们还必须在验证成功的时候将图片下载并保存到数据库中。我们可以使用处理该表单的视图来完成这个操作,但更常用的方式是重写表单的save()
来实现此功能。
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()
方法,保持与原来方法一样的默认参数设置。重写的方法工作逻辑如下:
save()
方法,使用现有表单数据建立一个新的image
数据对象但不保存cleaned_data
中获取URLimage.slug
与扩展名拼成新的文件名urllib
模块下载图片,然后使用image
字段的save()
方法保存到MEDIA
目录中。image
字段的save()
方法的参数之一ContentFile
是下载的图片内容,这里使用了save=False
防止直接将字段写入数据库。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
视图仅供登录后的用户使用,这个视图工作逻辑如下::
Get
请求附加的参数创建表单对象,参数会带着url
和title
字段对应的内容。这个Get
请求是由之后我们创建的JavaScript小书签程序发起的,现在,我们就假设该表单已经被初始化而且被用户确认并提交。Image
对象,但是不存入数据库。Image
对象的外键后进行保存,这样就可以知道该图片由哪个用户上传。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=...
的链接,其中包含title
和url
两个参数,分别表示图片的名称和URL地址。可以使用下边这个测试地址:
应该可以看到下面的页面:
在description内输入一些内容,然后点击BOOKMARK IT!按钮,一个新的Image对象会被存入数据库。由于此时get_absolute_url()
方法还未编写,所以会报错如下:
此时不用担心这个错误信息,通过刚才编写的视图可以知道,执行到这里报错说明图片已经成功存入数据库,打开http://127.0.0.1:8000/admin/images/image/即可看到该图片的信息,如下图所示:
小书签程序是一段JavaScript代码,可以被浏览器保存为书签,在点击该小书签时,其中的JavaScript代码被执行,从而实现一些功能。
一些比较知名的站点,如Pinterest,使用小书签程序让用户可以从其他网站将内容分享到其网站上。我们建立的程序和这个小书签程序类似,让用户将图片分享到我们的站点来。
我们将使用jQuery建立小书签程序,jQuery是一个得到广泛使用的JavaScript库,可以快速开发基于JavaScript的程序,可以访问其官方站点https://jquery.com/了解更多信息。
用户将会这样使用我们的小书签:
href
属性中保存着JS代码,这个链接被保存到浏览器书签成为一个可点击的书签由于小书签程序保存在用户的浏览器上,在用户第一次保存后,想要更新该程序就很困难,所以一般小书签程序实际上是一个程序启动器,实际执行的程序位于我们的网站上。这就是我们创建小书签的方法解说,现在来实现:
在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_url
和static_url
:我们网站的地址和静态文件地址min_width
和min_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(); }); };
这段代码的逻辑如下:
bookmarklet.css
,使用随机数确保浏览器不从缓存中读取<body>
标签中,在页面的右上方显示一个浮动的图片列表区域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'); });
这个函数的逻辑如下:
click()
事件打开一个网站,然后点击小书签,在右上方出现的窗口中点击一张图片,会被重定向到我们网站的图片创建页面,如下所示:
撒花庆祝,我们实现了第一个小书签程序,然后将其集成到了我们的Django项目中。
完成了图片分享并保存的功能之后,现在需要建立一个详情视图用来展示具体图片,编辑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.url
和user.profile.photo.url
:这两个字段不是Image
类中的url
字段,而是在定义Imagefield
字段时upload_to
的路径名称。
现在可以通过小书签程序再导入一个新图片,保存成功之后,会被重定向到图片的详情页,如下所示:
现在我们的图片详情页展示的是原始的图片,但是图片的尺寸可能差异很大,而且原始图片的大小可能会很大,载入时间较长。一般网站需要大量展示图片的通用做法是生成图片的缩略图然后展示缩略图。我们使用一个第三方应用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/。
现在要给站点增加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
:用户正在喜欢/不喜欢的图片的IDaction
:用户执行的动作,用字符串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'),
我们将使用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加载完毕后会执行该方法。DOM是Document Object Model的简称,由浏览器在加载页面时生成,以树形结构保存当前页面的所有节点数据。这样保证了JS代码执行时,其要操作的对象已经全部生成。
domready
块,用于存放在DOM加载完毕后执行的JS代码,我们将在需要执行JS代码的具体模板中编写该块内容。
注意不要混淆JavaScript代码和Djanog模板标签。Django的模板语言在服务端进行处理,转换最终的HTML字节流,浏览器取得HTML字节流创建页面和DOM对象,并执行JavaScript代码。有时候动态的生成JavaScript代码非常方便。
在这一章里,我们直接将JS代码通过模板内块的形式编写进来,这是为了教学方便。最好的方式是从静态文件中导入.js
文件,以做到有效解耦HTML与JS。
在第二章中已经了解到POST
请求中需要包含{% csrf_token %}
生成的token数据,以防止跨站伪造请求攻击。不过在AJAX中发送CRSF token有点不方便,所以Django允许在AJAX请求中设置一个X-CSRFToken
请求头,其中包含CSRF token的数据。jQuery在发送AJAX请求的时候设置上该请求头,就可以完成CRSF的发送了。
为了在AJAX请求中设置CSRF token,需要做如下事情:
csrftoken
cookie中取得CSRF token,如果开启了CSRF中间件,cookie中一直会有CSRF token数据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>
以上代码解释如下::
js-cookie
--一个轻量级的操作cookie的第三方库,可以在https://github.com/js-cookie/js-cookie找到该库的详细信息。Cookies.get()
方法拿到csrftoken
的值csrfSafeMethod()
函数,使用正则验证HTTP请求种类,GET,HEAD,OPTIONS和TRACE类型的请求无需添加CSRF信息X-CSRFToken
请求头信息,这个设置会影响到所有jQuery发送的AJAX请求。这样所有的不安全的HTTP请求,比如GET
或PUT
,都会被添加上CRSF信息。
编辑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
中,显示like或unlike,还为<a>
标签设置了两个HTML5自定义属性:
data-id
:当前页面显示图片的IDdata-action
:用户的动作,喜欢或者不喜欢,值是like
或unlike
我们将把这两个HTML5自定义属性的值通过AJAX发送给image_like
视图,当用户点击喜欢/不喜欢按钮的时候,我们需要在客户端做如下操作:
id
和action
data-action
属性为相反的操作(原来是like
则更新为unlike
,反之亦反)为此来编写页面所需的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 %}
这段代码的逻辑解释如下:
$('a.like')
选择所有属于like
类的<a>
标签<a>
标签绑定click
事件,每次点击就发送AJAX请求。e.preventDefault()
阻止<a>
的默认功能,即阻止打开新的超链接$.post()
发送异步的POST
请求。jQuery还提供了$.get()
用于发送异步的GET
请求,和一个更底层的$.ajax()
方法。{% url %}
反向解析出AJAX的请求目标地址<a>
标签的data-id
和data-action
设置id
和action
键值对。data
中。data
中的status
判断值是否为ok
,如果是则切换data-action
和按钮文本。译者注:原书这里的逻辑是为了让读者可以迅速看出操作结果。在多用户的环境中,不能如此简单的增减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或者在页面上右击选“检查”来启动开发工具。
在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错误)。
如果你发现项目中的很多视图对同一个条件做判断,可以考虑将该判断逻辑编写为一个自定义装饰器。
我们将制作一个图片列表页,用于列出我们网站所有的图片。这里将使用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请求,说明页面到了底部,返回空字符串即可。我们将结果渲染到两个不同的模板中:
list_ajax.html
模板,这个模板仅包含图片内容。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 %}
这段代码实现了滚动加载功能,其中的逻辑解释如下:
page
:存储当前页数empty_page
:判断是否已经到达页面底部。如果已经到达底部,阻止发送AJAX请求block_request
:在已经发送AJAX请求但还未收到响应时阻止再发送AJAX请求$(window).scroll()
方法监听滚动事件margin
变量中,表示未显示的页面的高度。再减去200表示当滚动到离窗口底部还有200像素的时候发送AJAX请求。block_request
和empty_page
同时为False
的情况下发送AJAX请求。block_request
设置为True
,避免再次发送,同时将page
增加1,下一次发送的时候就获取下一个分页结果。$.get()
方法发送类型为GET
的AJAX请求,将响应数据保存到data
中,然后处理以下两种情况:
empty_page
设置为True
,阻止后续所有AJAX请求发送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数据库。