完整目录请见:Django 3网页开发指南 – 第4版
本章中包含如下小节:
- 在修改列表页中自定义列
- 创建可排序行内元素
- 创建后台动作
- 开发修改列表过滤器
- 修改每三方应用的应用标签
- 创建自定义accounts应用
- 获取用户头像
- 在修改表单中插入地图
引言
Django框架自带对数据模板的管理后台系统。只需少量修改,就可配置可过滤、可搜索及可排序的列表来用于浏览模型数据,还可以配置表单来添加和管理数据。本章中,我们将学习这些高级托雷斯,来通过开发一些实例安全来自定义管理后台。
技术要求
运行本章的代码要求安装最新稳定版的Python 3、MySQL或PostgreSQL数据库以及通过虚拟环境创建的Django项目。
可在GitHub仓库的Chapter06目录中查看本章的代码。
在修改列表页中自定义列
默认Django后台管理系统中的修改列表视图提供一个对具体模型实例的总览。默认list_display模型属性控制字段的显示。此外,可以实现自定义admin方法来返回关联的数据或显示自定义HTML。本节中,我们创建一个特殊函数,配合list_display属性使用,用于在列表视图的一列中显示图片。我们还会添加list_editable设置来让一个字段可在列表视图中直接进行编辑。
准备工作
本节中我们需要使用Pillow和django-imagekit库。使用如下命令在虚拟环境中进行安装:
1 2 |
(env)$ pip install Pillow (env)$ pip install django-imagekit |
确保在配置文件的INSTALLED_APPS中添加了django.contrib.admin和imagekit:
1 2 3 4 5 6 |
# myproject/settings/_base.py INSTALLED_APPS = [ #... "django.contrib.admin", "imagekit", ] |
然后,在URL配置文件中配置管理站点,如下:
1 2 3 4 5 6 7 8 9 |
# myproject/urls.py from django.contrib import admin from django.conf.urls.i18n import i18n_patterns from django.urls import include, path urlpatterns = i18n_patterns( #... path("admin/", admin.site.urls), ) |
接下来新建products应用,将其放入INSTALLED_APPS。这个应用会包含Product和ProductPhoto模型。此处一个产品可能会有多张图片。本例中我们还会使用UrlMixin,在第2章 模型和数据库结构中的通过URL相关的方法创建模型mixin一节中进行过定义。
在 models.py中创建Product和ProductPhoto模型如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# myproject/apps/products/models.py import os from django.urls import reverse, NoReverseMatch from django.db import models from django.utils.timezone import now as timezone_now from django.utils.translation import ugettext_lazy as _ from ordered_model.models import OrderedModel from myproject.apps.core.models import UrlBase def product_photo_upload_to(instance, filename): now = timezone_now() slug = instance.product.slug base, ext = os.path.splitext(filename) return f"products/{slug}/{now:%Y%m%d%H%M%S}{ext.lower()}" class Product(UrlBase): title = models.CharField(_("title"), max_length=200) slug = models.SlugField(_("slug"), max_length=200) description = models.TextField(_("description"), blank=True) price = models.DecimalField( _("price (EUR)"), max_digits=8, decimal_places=2, blank=True, null=True ) class Meta: verbose_name = _("Product") verbose_name_plural = _("Products") def get_url_path(self): try: return reverse("product_detail", kwargs={"slug": self.slug}) except NoReverseMatch: return "" def __str__(self): return self.title class ProductPhoto(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE) photo = models.ImageField(_("photo"), upload_to=product_photo_upload_to) class Meta: verbose_name = _("Photo") verbose_name_plural = _("Photos") def __str__(self): return self.photo.name |
如何实现…
本节中我为Product模型创建简单的管理后,会将ProductPhoto模型的实例作为产品的内联元素。
在list_display属性中,我们将包含模型后台的first_photo()方法,用于从多对一关联中显示第一张图片。下面就开始吧:
- 创建包含如下内容的admin.py文件:
12345678910111213# myproject/apps/products/admin.pyfrom django.contrib import adminfrom django.template.loader import render_to_stringfrom django.utils.html import mark_safefrom django.utils.translation import ugettext_lazy as _from .models import Product, ProductPhotoclass ProductPhotoInline(admin.StackedInline):model = ProductPhotoextra = 0fields = ["photo"] - 然后,在同一文件中添加产品的管理后台:
12345678910111213141516171819202122232425262728@admin.register(Product)class ProductAdmin(admin.ModelAdmin):list_display = ["get_first_photo", "title", "has_description", "price"]list_display_links = ["get_first_photo", "title"]list_editable = ["price"]fieldsets = ((_("Product"), {"fields": ("title", "slug", "description", "price")}),)prepopulated_fields = {"slug": ("title",)}inlines = [ProductPhotoInline]def get_first_photo(self, obj):project_photos = obj.productphoto_set.all()[:1]if project_photos.count() > 0:photo_preview = render_to_string("admin/products/includes/photo-preview.html",{"photo": project_photos[0], "product": obj},)return mark_safe(photo_preview)return ""get_first_photo.short_description = _("Preview")def has_description(self, obj):return bool(obj.description)has_description.short_description = _("Has description?")has_description.admin_order_field = "description"has_description.boolean = True - 下面,创建用于生成图片预览的模板,如下:
1234{# admin/products/includes/photo-preview.html #}{% load imagekit %}{% thumbnail "120x120" photo.photo -- alt="{{ product.title }} preview" %}
实现原理…
添加几个带图片的产品然后在浏览器中查看产品后台列表,效果类似以下截图:
list_display属性通常用于定义字段以让它们在管理后台列表视图中显示;例如TITLE和PRICE是Product模型的字段。但除了常规的字段名外,list_display还可以接收以下内容:
- 函数或其它可调用对象
- 模型后台类的属性名
- 模型的属性名
在list_display中使用可调用对象时,都会将模型实例作为第一个参数进行传递。因此,在我们的示例中,在模型后台类中定义了get_photo()方法,它接收Product实例作为obj。该方法尝试从多对多一关联中获取第一个ProductPhoto对象,如果存在,返回以包含<img>模板生成的HTML。通过设置list_display_links,我们让图片和标题链接到Product模型的后台修改表单。
可以对list_display中使用的可调用对象设置一些属性:
- 可调用对象的short_description定义显示在字段顶部的标题。
- 默认,可调用对象返回的值在后台中无法进行排序,但可设置admin_order_field属性来定义可使用哪个数据库字段来生成排序。还可以选择在字段前添加减号来表明进行倒序排列。
- 通过设置boolean = True,可以显示True或False值的图标。
最后,可以添加到list_editable设置中来让PRICE字段可进行编辑。因为现在有可编辑字段了,会在底部出现一个Save按钮来保存整个产品列表。
相关内容
- 第2章 模型和数据库结构中的通过URL相关的方法创建模型mixin一节
- 创建后台动作一节
- 开发修改列表过滤器一节
创建可排序行内元素
对于大部分数据库中的模型我们会希望通过创建日期、发生日期或按字母排序。但有时用户需要以自定义排序来显示数据项。这适用于分类、图片集、精选列表等情况。本节中我们将学习如何使用django-ordered-model来允许在后台中进行自定义排序。
准备工作
本节中,我们使用前一节中定义的products应用。按照如下步骤进行操作:
- 在虚拟环境中安装 django-ordered-model:
1(env)$ pip install django-ordered-model - 在配置文件的INSTALLED_APPS中添加ordered_model。
- 接着,修改此前定义的products应用中的ProductPhoto模型如下:
123456789101112class ProductPhoto(OrderedModel):product = models.ForeignKey(Product, on_delete=models.CASCADE)photo = models.ImageField(_("photo"), upload_to=product_photo_upload_to)order_with_respect_to = "product"class Meta(OrderedModel.Meta):verbose_name = _("Photo")verbose_name_plural = _("Photos")def __str__(self):return self.photo.name
OrderedModel类引入了一个order字段。生成并运行迁移来将ProductPhoto的新排序字段添加到数据库中。
如何实现…
要设置可排序的产品图片,我们需要修改products应用的模型管理后台。下面就开始吧:
- 在admin文件中修改ProductPhotoInline如下:
1234567891011121314151617181920212223# myproject/apps/products/admin.pyfrom django.contrib import adminfrom django.template.loader import render_to_string from django.utils.html import mark_safefrom django.utils.translation import ugettext_lazy as _from ordered_model.admin import OrderedTabularInline, OrderedInlineModelAdminMixinfrom .models import Product, ProductPhotoclass ProductPhotoInline(OrderedTabularInline):model = ProductPhotoextra = 0fields = ("photo_preview", "photo", "order", "move_up_down_links")readonly_fields = ("photo_preview", "order","move_up_down_links")ordering = ("order",)def get_photo_preview(self, obj):photo_preview = render_to_string("admin/products/includes/photo-preview.html",{"photo": obj, "product": obj.product},)return mark_safe(photo_preview)get_photo_preview.short_description = _("Preview") - 然后修改ProductAdmin如下:
123@admin.register(Product)class ProductAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin):#...
实现原理…
如果打开Change Product表单,会看到如下内容:
在该模型中,我们设置order_with_respect_to属性来保持对每个产品单独进行排序,而不是笼统地对整个产品图片列表进行排序。
在Django管理后台中,产品图片可以按照行内标签在产品详情本身中进行编辑。在第一列中,有图片预览。我们使用与前一节中相同的photo-preview.html模板来进行生成。第二列中为修改图片的字段。然后一列是ORDER字段,然后接可手动对紧邻图片重新排序的箭头按钮。箭头按钮来自move_up_down_links方法。最后一列为一个复选枉,可以进行行内元素的删除。
readonly_fields属性告诉Django有一些字段或方法为只读。如果希望使用其它方法在修改表单中显示内容,需要将这些方法放到readonly_fields列表中。本例中,get_photo_preview和move_up_down_links就是这类方法。
move_up_down_links在OrderedTabularInline中进行定义,它继承了admin.StackedInline或admin.TabularInline。这渲染出箭头按钮来切换产品图片。
相关内容
- 在修改列表页中自定义列一节
- 创建后台动作一节
- 开发修改列表过滤器一节
创建后台动作
Django后台系统提供可以对列表中指定项进行操作的动作。默认提供了一个用于删除选中实例的动作。本例中,我们将创建用于Product模型列表的其它动作,允许管理员将所选产品导出为Excel表格。
准备工作
我们将使用前面小节中创建的products应用。确认好在虚拟环境中已安装了openpyxl模块,用来创建Excel表格,如下:
1 |
(env)$ pip install openpyxl |
如何实现…
后台动作是一个接收三个参数的函数,如下:
- 当前的ModelAdmin值
- 当前的HttpRequest值
- QuerySet值,包含所选项
执行如下步骤创建一个自定义后台动作来导出数据表:
- 在products应用的admin.py文件中创建一个用于数据表列配置的ColumnConfig为,如下:
1234567891011121314151617181920212223242526# myproject/apps/products/admin.pyfrom openpyxl import Workbookfrom openpyxl.styles import Alignment, NamedStyle, builtinsfrom openpyxl.styles.numbers import FORMAT_NUMBERfrom openpyxl.writer.excel import save_virtual_workbookfrom django.http.response import HttpResponsefrom django.utils.translation import ugettext_lazy as _from ordered_model.admin import OrderedTabularInline, OrderedInlineModelAdminMixin# other imports...class ColumnConfig:def __init__(self,heading,width=None,heading_style="Headline 1",style="Normal Wrapped",number_format=None,):self.heading = headingself.width = widthself.heading_style = heading_styleself.style = styleself.number_format = number_format - 然后在同一个文件中创建export_xlsx()函数:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364def export_xlsx(modeladmin, request, queryset):wb = Workbook()ws = wb.activews.title = "Products"number_alignment = Alignment(horizontal="right")wb.add_named_style(NamedStyle("Identifier", alignment=number_alignment,number_format=FORMAT_NUMBER))wb.add_named_style(NamedStyle("Normal Wrapped",alignment=Alignment(wrap_text=True)))column_config = {"A": ColumnConfig("ID", width=10, style="Identifier"),"B": ColumnConfig("Title", width=30),"C": ColumnConfig("Description", width=60),"D": ColumnConfig("Price", width=15, style="Currency",number_format="#,##0.00 €"),"E": ColumnConfig("Preview", width=100, style="Hyperlink"),}# Set up column widths, header values and stylesfor col, conf in column_config.items():ws.column_dimensions[col].width = conf.widthcolumn = ws[f"{col}1"]column.value = conf.headingcolumn.style = conf.heading_style# Add productsfor obj in queryset.order_by("pk"):project_photos = obj.productphoto_set.all()[:1] url = ""if project_photos:url = project_photos[0].photo.urldata = [obj.pk, obj.title, obj.description, obj.price, url]ws.append(data)row = ws.max_rowfor row_cells in ws.iter_cols(min_row=row, max_row=row):for cell in row_cells:conf = column_config[cell.column_letter]cell.style = conf.styleif conf.number_format:cell.number_format = conf.number_formatmimetype = "application/vnd.openxmlformats- officedocument.spreadsheetml.sheet"charset = "utf-8"response = HttpResponse(content=save_virtual_workbook(wb),content_type=f"{mimetype}; charset={charset}",charset=charset,)response["Content-Disposition"] = "attachment;filename=products.xlsx"return responseexport_xlsx.short_description = _("Export XLSX") - 然后,对ProductAdmin添加actions设置如下:
12345@admin.register(Product)class ProductAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin):#...actions = [export_xlsx]#...
实现原理…
如果在浏览器中查看产品后台列表面,在默认的Delete selected Products动作旁会看到一个名为Export XLSX的动作,如下图所示:
我们使用openpyxl Python模块来创建与Excel及其它数据表软件相兼容的 OpenOffice XML文件。
首先创建一个工作薄,选中当前工作表,将其标题设置为Products。因为在工作表中有一些通用的样式,所以设置了命名样式来对相应的每个单元格中按名称进行应用。这些样式、列头和列宽都存储为Config对象,column_config字典将列的字母作为键与对象进行映射。然后进行遍历来设置列头和列宽。
我们使用工作表的append()方法来为QuerySet中的每个选中产品添加内容,按ID进行排序,在存在图片时包含产品的第一张图片的URL。然后产品数据通过对刚刚添加行的每个单元格进行遍历单独添加样式,再次引用column_config来连续应用样式。
默认,后台动作通过QuerySet做一些事并将管理员重定向回修改列表页面。但对于更为复杂的动作,可返回HttpResponse。export_xlsx() 函数保存了对应HttpResponse工作薄的虚拟拷贝,包含适用the Office Open XML (OOXML)数据表的相应内容类型和字符集。我们使用Content-Disposition来设置响应,这样可下载为products.xlsx 文件。生成的工作表可通过Open Office打开,类似下面这样:
相关内容
- 在修改列表页中自定义列一节
- 开发修改列表过滤器一节
- 第9章 导入、导出数据
开发修改列表过滤器
如果希望管理员能够根据日期、关联或字段选项来过滤修改列表,需要使用后台模型中的list_filter属性。此外,可以创建自定义的过滤器。本节中我们一起创建一个过滤器,用来通过附属图片数量选取产品。
准备工作
我们使用前面小节中创建的products应用。
如何实现…
执行如下步骤:
- 在admin.py文件中,创建继承SimpleListFilter的PhotoFilter类,如下:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849# myproject/apps/products/admin.pyfrom django.contrib import adminfrom django.db import modelsfrom django.utils.translation import ugettext_lazy as _# other imports...ZERO = "zero"ONE = "one"MANY = "many"class PhotoFilter(admin.SimpleListFilter):# Human-readable title which will be displayed in the# right admin sidebar just above the filter options.title = _("photos")# Parameter for the filter that will be used in the# URL query.parameter_name = "photos"def lookups(self, request, model_admin):"""Returns a list of tuples, akin to the values given formodel field choices. The first element in each tuple is thecoded value for the option that will appear in the URLquery. The second element is the human-readable name forthe option that will appear in the right sidebar."""return ((ZERO, _("Has no photos")),(ONE, _("Has one photo")),(MANY, _("Has more than one photo")),)def queryset(self, request, queryset):"""Returns the filtered queryset based on the valueprovided in the query string and retrievable via`self.value()`."""qs = queryset.annotate(num_photos=models.Count("productphoto"))if self.value() == ZERO:qs = qs.filter(num_photos=0)elif self.value() == ONE:qs = qs.filter(num_photos=1)elif self.value() == MANY:qs = qs.filter(num_photos__gte=2)return qs - 然后对ProductAdmin添加列表过滤器,如以下代码所示:
12345@admin.register(Product)class ProductAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin):#...list_filter = [PhotoFilter]#...
实现原理…
在列表过滤器中,根据刚刚创建的自定义字段,会在产品列表的边栏中进行显示,如下:
PhotoFilter类用可翻译的标题和查询参数名作为属性。还有两个方法,如下:
- lookups()方法定义过滤器的选项
- queryset()方法定义如何在特定值选中时过滤QuerySet对象
在lookups()方法中定义了三个选项,如下:
- 无图片no photos
- 一张图片one photo
- 一张以上图片more than one photo
在queryset()方法中,我们使用QuerySet的annotate()方法来获取每个产品的图片数。然后根据选中的选项过滤计数。
了解更多聚合函数,如 annotate(),,请参见Django官方文档。
相关内容
- 在修改列表页中自定义列一节
- 创建后台动作一节
- 创建自定义accounts应用一节
修改每三方应用的应用标签
Django框架有很多第三方应用可用于项目中。在部分的应用都可以通过https://djangopackages.org进行浏览对比。本节中,我们将展示如何在后台中对python-social-auth的标签进行重命名。也就是说,我们可以修改任意Django第三方应用的标签。
准备工作
按照https://python-social-auth.readthedocs.io/en/latest/configuration/django.html上的教程在项目中安装Python Social Auth。Python Social Auth允许用户以社交网站账号或其Open ID来进行登录。一旦完成这一操作,后台的首页会是下面这样:
如何实现…
我们先将PYTHON SOCIAL AUTH标签修改为对用户更为友好的名称,如SOCIAL AUTHENTICATION。按照如下步骤执行:
- 创建名为accounts的应用,在其中的apps.py 文件中,添加如下内容:
1234567891011121314# myproject/apps/accounts/apps.pyfrom django.apps import AppConfigfrom django.utils.translation import ugettext_lazy as _class AccountsConfig(AppConfig):name = "myproject.apps.accounts"verbose_name = _("Accounts")def ready(self):passclass SocialDjangoConfig(AppConfig):name = "social_django"verbose_name = _("Social Authentication") - Python Social Auth的配置要求在INSTALLED_APPS中添加social_django。将该应用替换为myproject.apps.accounts.apps.SocialDjangoConfig:
12345678# myproject/settings/_base.py#...INSTALLED_APPS = [#...#"social_django","myproject.apps.accounts.apps.SocialDjangoConfig",#...]
实现原理…
此时查看后台首页,会看到如下内容:
INSTALLED_APPS配置接收应用的路径或应用配置的路径。除了默认的应用路径外,我们还可以设置应用配置的路径。在其中我们修改应用的显示名称,甚至可以应用一些信号处理器或其它针对该应用的初始化配置。
相关内容
- 创建自定义accounts应用一节
- 获取用户头像一节
创建自定义accounts应用
Django内置有社区贡献的django.contrib.auth应用,用于用户认证。它让用户可以使用自己的用户名来进行登录,例如使用后台功能。这个应用设计时考虑到可以让开发者自己进行扩展。本小节中,我们将创建自定义用户和角色模型,并后其它设置后台。除用户名和密码外,还可以使用email和密码进行登录。
准备工作
创建一个accounts应用,并将该应用添加到配置文件的INSTALLED_APPS中:
1 2 3 4 5 |
# myproject/apps/_base.py INSTALLED_APPS = [ #... "myproject.apps.accounts", ] |
如何实现…
按照如下步骤来重写用户和用户组模型:
- 在accounts应用中创建models.py并添加如下内容:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354# myproject/apps/accounts/models.pyimport uuidfrom django.contrib.auth.base_user import BaseUserManagerfrom django.db import modelsfrom django.contrib.auth.models import AbstractUser, Groupfrom django.utils.translation import ugettext_lazy as _class Role(Group):class Meta:proxy = Trueverbose_name = _("Role")verbose_name_plural = _("Roles")def __str__(self):return self.nameclass UserManager(BaseUserManager):def create_user(self, username="", email="", password="", **extra_fields):if not email:raise ValueError("Enter an email address")email = self.normalize_email(email)user = self.model(username=username, email=email, **extra_fields)user.set_password(password)user.save(using=self._db)return userdef create_superuser(self, username="", email="", password=""):user = self.create_user(email=email, password=password, username=username)user.is_superuser = Trueuser.is_staff = Trueuser.save(using=self._db)return userclass User(AbstractUser):uuid = models.UUIDField(primary_key=True, default=None, editable=False)# change username to non-editable non-required fieldusername = models.CharField(_("username"), max_length=150, editable=False, blank=True)# change email to unique and required fieldemail = models.EmailField(_("email address"), unique=True)USERNAME_FIELD = "email"REQUIRED_FIELDS = []objects = UserManager()def save(self, *args, **kwargs):if self.pk is None:self.pk = uuid.uuid4()super().save(*args, **kwargs) - 在accounts应用中使用User模型的后台配置创建admin.py文件:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667# myproject/apps/accounts/admin.pyfrom django.contrib import adminfrom django.contrib.auth.admin import UserAdmin, Group, GroupAdminfrom django.urls import reversefrom django.contrib.contenttypes.models import ContentTypefrom django.http import HttpResponsefrom django.shortcuts import get_object_or_404, redirectfrom django.utils.encoding import force_bytesfrom django.utils.safestring import mark_safefrom django.utils.translation import ugettext_lazy as _from django.contrib.auth.forms import UserCreationFormfrom .helpers import download_avatarfrom .models import User, Roleclass MyUserCreationForm(UserCreationForm):def save(self, commit=True):user = super().save(commit=False)user.username = user.emailuser.set_password(self.cleaned_data["password1"])if commit:user.save()return user@admin.register(User)class MyUserAdmin(UserAdmin):save_on_top = Truelist_display = ["get_full_name","is_active","is_staff","is_superuser",]list_display_links = ["get_full_name",]search_fields = ["email", "first_name", "last_name", "id", "username"]ordering = ["-is_superuser", "-is_staff", "last_name", "first_name"]fieldsets = [(None, {"fields": ("email", "password")}),(_("Personal info"), {"fields": ("first_name", "last_name")}),(_("Permissions"),{"fields": ("is_active","is_staff","is_superuser","groups","user_permissions",)},),(_("Important dates"), {"fields": ("last_login", "date_joined")}),]add_fieldsets = ((None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),)add_form = MyUserCreationFormdef get_full_name(self, obj):return obj.get_full_name()get_full_name.short_description = _("Full name") - 同样在该文件中,添加Role模型的配置:
1234567891011121314151617181920212223admin.site.unregister(Group)@admin.register(Role)class MyRoleAdmin(GroupAdmin):list_display = ("__str__", "display_users")save_on_top = Truedef display_users(self, obj):links = []for user in obj.user_set.all():ct = ContentType.objects.get_for_model(user)url = reverse("admin:{}_{}_change".format(ct.app_label, ct.model), args=(user.pk,))links.append("""<a href="{}" target="_blank">{}</a>""".format(url,user.get_full_name() or user.username,))return mark_safe(u"<br />".join(links))display_users.short_description = _("Users")
实现原理…
默认的管理后台用户列表像下图这样:
默认的管理后台组列表像下图这样:
本节中我们创建了两个模型:
- Role模型,是对django.contrib.auth应用中的Group模型的代理 。创建Role模型来将显示名称由Group重命名为Role。
- User模型同jango.contrib.auth中的User模型一样继承了AbstractUser抽象类。创建User模型来将主键替换为UUIDField并让用户可以使用email和密码登录,而不是默认的用户名、密码登录。
后台类MyUserAdmin和MyRoleAdmin继承社区代码中的UserAdmin和GroupAdmin类并重写了一些属性。然后,我们取消了对已有后台管理类User和Group模型的注册,并注册了新的修改后的类。
新的管理后台用户如下图所示:
修改后的用户后台管理配置在列表视图中显示了比默认配置更多的选项,还有增加的过滤器和排序选项,以及在编辑表单顶部的Submit按钮。
在新的组管理配置修改列表中,将会显示分配给指定组的用户。在浏览器中显示如下图所示:
相关内容
- 在修改列表页中自定义列一节
- 在修改表单中插入地图一节
获取用户头像(Gravatar)
当前使用自定义的User模型进行认证,我们可以添加一些有益的字段来做进一步的增强。在本节中,我们将添加avatar字段并能够通过Gravatar服务下载用户头像。使用这一服务的用户可以上传头像并将它们分配给到某些邮箱。借助于此,各评论系统及社交平台能够根据用户email的哈希来通过Gravatar显示这些头像。
准备工作
我们继续使用前面小节中所创建的accounts应用。
如何实现…
按照如下步骤改进accounts应用中的User模型:
- 对User模型添加avatar字段及django-imagekit缩略图规格:
123456789101112131415161718192021222324252627282930# myproject/apps/accounts/models.pyimport osfrom imagekit.modelsimport ImageSpecField from pilkit.processorsimport ResizeToFill from django.utils import timezone#...def upload_to(instance, filename):now = timezone.now()filename_base, filename_ext = os.path.splitext(filename)return "users/{user_id}/{filename}{ext}".format(user_id=instance.pk,filename=now.strftime("%Y%m%d%H%M%S"),ext=filename_ext.lower(),)class User(AbstractUser):#...avatar = models.ImageField(_("Avatar"), upload_to=upload_to, blank=True)avatar_thumbnail = ImageSpecField(source="avatar",processors=[ResizeToFill(60, 60)],format="JPEG",options={"quality": 100},)#... - 添加一些方法来为MyUserAdmin类下载并显示Gravatar:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133# myprojects/apps/accounts/admin.pyfrom django.contrib import adminfrom django.contrib.auth.admin import UserAdmin, Group, GroupAdmin from django.urls import reversefrom django.contrib.contenttypes.models import ContentTypefrom django.http import HttpResponsefrom django.shortcuts import get_object_or_404from django.utils.encoding import force_bytesfrom django.utils.safestring import mark_safefrom django.utils.translation import ugettext_lazy as _from django.contrib.auth.forms import UserCreationFormfrom .helpers import download_avatarfrom .models import User, Roleclass MyUserCreationForm(UserCreationForm):def save(self, commit=True):user = super().save(commit=False)user.username = user.emailuser.set_password(self.cleaned_data["password1"])if commit:user.save()return user@admin.register(User)class MyUserAdmin(UserAdmin):save_on_top = True list_display = ["get_avatar","get_full_name","download_gravatar","is_active","is_staff","is_superuser",]list_display_links = ["get_avatar","get_full_name",]search_fields = ["email", "first_name", "last_name", "id", "username"]ordering = ["-is_superuser", "-is_staff", "last_name", "first_name"]fieldsets = [(None, {"fields": ("email", "password")}),(_("Personal info"), {"fields": ("first_name", "last_name")}),(_("Permissions"),{"fields": ("is_active","is_staff","is_superuser","groups","user_permissions",)},),(_("Avatar"), {"fields": ("avatar",)}),(_("Important dates"), {"fields": ("last_login", "date_joined")}),]add_fieldsets = ((None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),)add_form = MyUserCreationFormdef get_full_name(self, obj):return obj.get_full_name()get_full_name.short_description = _("Full name")def get_avatar(self, obj):from django.template.loader import render_to_stringhtml = render_to_string("admin/accounts/includes/avatar.html", context={"obj": obj})return mark_safe(html)get_avatar.short_description = _("Avatar")def download_gravatar(self, obj):from django.template.loader import render_to_stringinfo = self.model._meta.app_label, self.model._meta.model_namegravatar_url = reverse("admin:%s_%s_download_gravatar" % info, args=[obj.pk])html = render_to_string("admin/accounts/includes/download_gravatar.html", context={"url": gravatar_url})return mark_safe(html)download_gravatar.short_description = _("Gravatar")def get_urls(self):from functools import update_wrapperfrom django.conf.urls import urldef wrap(view):def wrapper(*args, **kwargs):return self.admin_site.admin_view(view)(*args, **kwargs)wrapper.model_admin = selfreturn update_wrapper(wrapper, view)info = self.model._meta.app_label, self.model._meta.model_nameurlpatterns = [url(r"^(.+)/download-gravatar/$",wrap(self.download_gravatar_view),name="%s_%s_download_gravatar" % info,)] + super().get_urls()return urlpatternsdef download_gravatar_view(self, request, object_id):if request.method != "POST":return HttpResponse("{} method not allowed.".format(request.method),status=405)from .models import Useruser = get_object_or_404(User, pk=object_id)import hashlibm = hashlib.md5()m.update(force_bytes(user.email))md5_hash = m.hexdigest()# d=404 ensures that 404 error is raised if gravatar is not# found instead of returning default placeholderurl = "https://www.gravatar.com/avatar /{md5_hash}?s=800&d=404".format(md5_hash=md5_hash)download_avatar(object_id, url)return HttpResponse("Gravatar downloaded.", status=200) - 在accounts应用的helpers.py文件中添加如下内容:
1234567891011121314151617181920212223242526272829303132# myproject/apps/accounts/helpers.pydef download_avatar(user_id, image_url):import tempfileimport requestsfrom django.contrib.auth import get_user_modelfrom django.core.files import Fileresponse = requests.get(image_url, allow_redirects=True, stream=True)user = get_user_model().objects.get(pk=user_id)if user.avatar: # delete the old avataruser.avatar.delete()if response.status_code != requests.codes.ok:user.save()returnfile_name = image_url.split("/")[-1]image_file = tempfile.NamedTemporaryFile()# Read the streamed image in sectionsfor block in response.iter_content(1024 * 8):# If no more file then stopif not block:break# Write image block to temporary fileimage_file.write(block)user.avatar.save(file_name, File(image_file))user.save() - 在后台文件中对头像创建模板:
1234{# admin/accounts/includes/avatar.html #}{% if obj.avatar %}<img src="{{ obj.avatar_thumbnail.url }}" alt="" width="30" height="30" />{% endif %} - 对下载Gravatar的button创建模板:
12345{# admin/accounts/includes/download_gravatar.html #}{% load i18n %}<button type="button" data-url="{{ url }}" class="button js_download_gravatar download-gravatar">{% trans "Get Gravatar" %}</button> - 最终,使用JavaScript对用户修改列表后台创建模板来处理Get Gravatar按钮的鼠标点击:
123456789101112131415161718192021222324252627282930313233343536{# admin/accounts/user/change_list.html #}{% extends "admin/change_list.html" %}{% load static %}{% block footer %}{{ block.super }}<style nonce="{{ request.csp_nonce }}">.button.download-gravatar {padding: 2px 10px;}</style><script nonce="{{ request.csp_nonce }}">django.jQuery(function($) {$('.js_download_gravatar').on('click', function(e) {e.preventDefault();$.ajax({url: $(this).data('url'),cache: 'false',dataType: 'json',type: 'POST',data: {},beforeSend: function(xhr) {xhr.setRequestHeader('X-CSRFToken', '{{ csrf_token }}');}}).then(function(data) {console.log('Gravatar downloaded.');document.location.reload(true);}, function(data) {console.log('There were problems downloading the Gravatar.');document.location.reload(true);});})})</script>{% endblock %}
实现原理…
如果此时查看后台用户修改列表,界面类似下面这样:
列以用户的AVATAR开头,然后是 FULL NAME,接收是获取Gravatar的按钮。在用户点击Get Gravatar按钮时,JavaScript onclick事件处理器对download_gravatar_view发出一个POST请求。这个视图对用户的Gravatar创建一个URL,它依赖于用户email的email哈希值,然后调用帮助函数来为用户下载图片并将其链接到avatar字段。
扩展知识…
Gravatar图片非常小通常下载很快速。如果从其它服务下载更大的图片,可以使用Celery或Huey任务队列来在后台获取图片。可通过https://docs.celeryproject.org/en/latest/django/first-steps-with-django.html学习Celery相关知识,通过https://huey.readthedocs.io/en/0.4.9/django.html学习Huey相关知识。
相关内容
- 修改每三方应用的应用标签一节
- 创建自定义accounts应用一节
在修改表单中插入地图
Google Maps提供有JavaScript API,我们可以使用在网站中插入地图。本节中,我们将创建一个带有Location模型的locations应用并扩展修改表单的模板,来添加地图让管理员可以查找亲标记某位置的地理坐标。
准备工作
注册Google Maps API key并在模板中进行暴露,参见第4章 模板和JavaScript中的使用HTML5 data属性一节。注意学习本小节,需要在Google Cloud Platform控制台中启用Maps JavaScript API 和 Geocoding API。要使用这些API,还需要填写账单信息。
我们将继续创建locations应用:
- 把该应用放到配置文件的INSTALLED_APPS中:
12345# myproject/settings/_base.pyINSTALLED_APPS = [#..."myproject.apps.locations",] - 创建一个Location模型,包含名称、描述、地址、地理坐标和图片等,如下:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667# myproject/apps/locations/models.pyimport osimport uuidfrom collections import namedtuplefrom django.contrib.gis.db import modelsfrom django.urls import reversefrom django.conf import settingsfrom django.utils.translation import gettext_lazy as _from django.utils.timezone import now as timezone_nowfrom myproject.apps.core.models import CreationModificationDateBase, UrlBaseCOUNTRY_CHOICES = getattr(settings, "COUNTRY_CHOICES", [])Geoposition = namedtuple("Geoposition", ["longitude", "latitude"])def upload_to(instance, filename):now = timezone_now()base, extension = os.path.splitext(filename)extension = extension.lower()return f"locations/{now:%Y/%m}/{instance.pk}{extension}"class Location(CreationModificationDateBase, UrlBase):uuid = models.UUIDField(primary_key=True, default=None, editable=False)name = models.CharField(_("Name"), max_length=200)description = models.TextField(_("Description"))street_address = models.CharField(_("Street address"), max_length=255, blank=True)street_address2 = models.CharField(_("Street address (2nd line)"), max_length=255, blank=True)postal_code = models.CharField(_("Postal code"), max_length=255, blank=True)city = models.CharField(_("City"), max_length=255, blank=True)country = models.CharField(_("Country"), choices=COUNTRY_CHOICES, max_length=255, blank=True)geoposition = models.PointField(blank=True, null=True)picture = models.ImageField(_("Picture"), upload_to=upload_to)class Meta:verbose_name = _("Location")verbose_name_plural = _("Locations")def __str__(self):return self.namedef get_url_path(self):return reverse("locations:location_detail", kwargs={"pk": self.pk})def save(self, *args, **kwargs):if self.pk is None:self.pk = uuid.uuid4()super().save(*args, **kwargs)def delete(self, *args, **kwargs):if self.picture:self.picture.delete()super().delete(*args, **kwargs)def get_geoposition(self):if not self.geoposition:return Nonereturn Geoposition(self.geoposition.coords[0], self.geoposition.coords[1])def set_geoposition(self, longitude, latitude):from django.contrib.gis.geos import Pointself.geoposition = Point(longitude, latitude, srid=4326) - 接下来我们需要对PostgreSQL数据库安装PostGIS。最简单的方式是运行dbshell管理命令行,运行如下命令:
1> CREATE EXTENSION postgis; - 接着对具有geoposition的模型创建默认后台(我们会在如何实现…一节中进行修改):
12345678# myproject/apps/locations/admin.pyfrom django.contrib.gis import adminfrom .models import Location@admin.register(Location)class LocationAdmin(admin.OSMGeoAdmin):pass
社区gis模型的地理Point字段在Django后台中默认使用Leaflet.js JavaScript映射库。图层从Open Street Maps 获取,后台如下:
注意普通的配置中,是无法手动输入经度和纬度的,也无法通过地址信息推导出地理位置信息。本节中我们会进行实现。
如何实现…
Location模型的后台会和全多个文件合并。执行如下步骤来进行创建:
- 为Location模型创建后台配置。注意我们还创建了一个自定义模型表单用于创建单独的纬度和经度字段:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990# myproject/apps/locations/admin.pyfrom django.contrib import adminfrom django import formsfrom django.conf import settingsfrom django.template.loader import render_to_stringfrom django.utils.translation import ugettext_lazy as _from .models import LocationLATITUDE_DEFINITION = _("Latitude (Lat.) is the angle between any point and the ""equator (north pole is at 90°; south pole is at -90°).")LONGITUDE_DEFINITION = _("Longitude (Long.) is the angle east or west of a point ""on Earth at Greenwich (UK), which is the international ""zero-longitude point (longitude = 0°). The anti-meridian ""of Greenwich (the opposite side of the planet) is both ""180° (to the east) and -180° (to the west).")class LocationModelForm(forms.ModelForm):latitude = forms.FloatField(label=_("Latitude"), required=False,help_text=LATITUDE_DEFINITION)longitude = forms.FloatField(label=_("Longitude"), required=False,help_text=LONGITUDE_DEFINITION)class Meta:model = Locationexclude = ["geoposition"]def __init__(self, *args, **kwargs):super().__init__(*args, **kwargs)if self.instance:geoposition = self.instance.get_geoposition()if geoposition:self.fields["latitude"].initial = geoposition.latitudeself.fields["longitude"].initial = geoposition.longitudedef save(self, commit=True):cleaned_data = self.cleaned_datainstance = super().save(commit=False)instance.set_geoposition(longitude=cleaned_data["longitude"],latitude=cleaned_data["latitude"],)if commit:instance.save()self.save_m2m()return instance@admin.register(Location)class LocationAdmin(admin.ModelAdmin):form = LocationModelFormsave_on_top = Truelist_display = ("name", "street_address", "description")search_fields = ("name", "street_address", "description")def get_fieldsets(self, request, obj=None):map_html = render_to_string("admin/locations/includes/map.html",{"MAPS_API_KEY": settings.GOOGLE_MAPS_API_KEY},)fieldsets = [(_("Main Data"), {"fields": ("name", "description")}),(_("Address"),{"fields": ("street_address","street_address2","postal_code","city","country","latitude","longitude",)},),(_("Map"), {"description": map_html, "fields": []}),(_("Image"), {"fields": ("picture",)}),]return fieldsets - 创建自定义修改表单模板需要添加一个change_form.html文件,放在模型路径的admin/locations/location/目录下。这个模板会扩展默认的admin/change_form.html模板并会重写extrastyle和field_sets代码块,如下:
12345678910111213141516{# admin/locations/location/change_form.html #}{% extends "admin/change_form.html" %}{% load i18n static admin_modify admin_urls %}{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css"href="{% static 'site/css/location_map.css' %}" />{% endblock %}{% block field_sets %}{% for fieldset in adminform %}{% include "admin/includes/fieldset.html" %}{% endfor %}<script src="{% static 'site/js/location_change_form.js' %}"></script>{% endblock %} - 然后要创建地图模板,用于插入到Map字段集中,如下:
12345678910111213141516171819{# admin/locations/includes/map.html #}{% load i18n %}<div class="form-row map js_map"><div class="canvas"><!-- THE GMAPS WILL BE INSERTED HERE DYNAMICALLY --></div><ul class="locations js_locations"></ul><div class="btn-group"><button type="button"class="btn btn-default locate-address js_locate_address">{% trans "Locate address" %}</button><button type="button"class="btn btn-default remove-geo js_remove_geo">{% trans "Remove from map" %}</button></div></div><script src="https://maps-api-ssl.google.com/maps/api/js?key={{ MAPS_API_KEY }}"></script> - 这个地图默认自然没有样式。因此我们要像如下代码那样添加一些CSS:
123456789101112131415161718192021222324252627282930313233343536373839/* site_static/site/css/location_map.css */.map {box-sizing: border-box; width: 98%;}.map .canvas,.map ul.locations, .map .btn-group {margin: 1rem 0;}.map .canvas {border: 1px solid #000;box-sizing: padding-box;height: 0;padding-bottom: calc(9 / 16 * 100%); /* 16:9 aspect ratio */width: 100%;}.map .canvas:before {color: #eee;color: rgba(0, 0, 0, 0.1);content: "map";display: block;font-size: 5rem;line-height: 5rem;margin-top: -25%;padding-top: calc(50% - 2.5rem);text-align: center;}.map ul.locations {padding: 0;}.map ul.locations li {border-bottom: 1px solid #ccc;list-style: none;}.map ul.locations li:first-child {border-top: 1px solid #ccc;}.map .btn-group .btn.remove-geo {float: right;} - 接着,创建JavaScript文件location_change_form.js。我们不希望用全局变量污染环境。因此,我们通过闭包来对变量和函数创建私有作用域。本文件中将使用jQuery(因为jQuery自带社区后台系统,更为简单也跨浏览器兼容),如下:
123456789/* site_static/site/js/location_change_form.js */(function ($, undefined) {var gettext = window.gettext || function (val) {return val;};var $map, $foundLocations, $lat, $lng, $street, $street2,$city, $country, $postalCode, gMap, gMarker;// ...this is where all the further JavaScript functions go...}(django.jQuery)); - 创建JavaScript函数并逐一添加到location_change_form.js中。getAddress4search()函数会从地址字段中获取地址字符串,稍后用于地理定位,如下:
12345678910111213function getAddress4search() {var sStreetAddress2 = $street2.val();if (sStreetAddress2) {sStreetAddress2 = " " + sStreetAddress2;}return [$street.val() + sStreetAddress2,$city.val(),$country.val(),$postalCode.val()].join(", ");} - updateMarker() 函数接收latitude和longitude参数并在地图上画出或移动标记。还会让标记可进行拖拽,如下:
123456789101112131415161718192021function updateMarker(lat, lng) {var point = new google.maps.LatLng(lat, lng);if (!gMarker) {gMarker = new google.maps.Marker({position: point,map: gMap});}gMarker.setPosition(point);gMap.panTo(point, 15);gMarker.setDraggable(true);google.maps.event.addListener(gMarker, "dragend",function() {var point = gMarker.getPosition();updateLatitudeAndLongitude(point.lat(), point.lng());});} - updateLatitudeAndLongitude()函数,在上面的dragend事件兼听器中进行了引入,接收latitude和longitude参数,使用id_latitude和id_longitude的ID值更新这些字段的值,如下:
12345function updateLatitudeAndLongitude(lat, lng) {var precision = 1000000;$lat.val(Math.round(lat * precision) / precision);$lng.val(Math.round(lng * precision) / precision);} - autocompleteAddress()函数获取Google Maps地理编码的结果并在地图下面列举出来,以选择正确的结果。如果只有一个结果,它更新地理位置和地址字段,如下:
12345678910111213141516171819202122232425262728293031323334353637function autocompleteAddress(results) {var $item = $('<li/>');var $link = $('<a href="#"/>');$foundLocations.html("");results = results || [];if (results.length) {results.forEach(function (result, i) {$link.clone().html(result.formatted_address).click(function (event) {event.preventDefault();updateAddressFields(result.address_components);var point = result.geometry.location;updateLatitudeAndLongitude(point.lat(), point.lng());updateMarker(point.lat(), point.lng());$foundLocations.hide();}).appendTo($item.clone().appendTo($foundLocations));});$link.clone().html(gettext("None of the above")).click(function(event) {event.preventDefault();$foundLocations.hide();}).appendTo($item.clone().appendTo($foundLocations));$foundLocations.show();} else {$foundLocations.hide();}} - updateAddressFields()函数接收一个内嵌字典,地址组件作为其参数,并填写所有地址字段,如下:
1234567891011121314151617181920212223242526272829303132333435function updateAddressFields(addressComponents) {var streetName, streetNumber;var typeActions = {"locality": function(obj) {$city.val(obj.long_name);},"street_number": function(obj) {streetNumber = obj.long_name;},"route": function(obj) {streetName = obj.long_name;},"postal_code": function(obj) {$postalCode.val(obj.long_name);},"country": function(obj) {$country.val(obj.short_name);}};addressComponents.forEach(function(component) {var action = typeActions[component.types[0]];if (typeof action === "function") {action(component);}});if (streetName) {var streetAddress = streetName;if (streetNumber) {streetAddress += " " + streetNumber;}$street.val(streetAddress);}} - 最后,初始化函数在页面加载时进行调用。它将onclick事件处理器与按钮进行关联,创建一个Google Map,并且初始标识latitude和longitude字段中所定义的地址位置,如下:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253$(function(){$map = $(".map");$foundLocations = $map.find("ul.js_locations").hide();$lat = $("#id_latitude");$lng = $("#id_longitude");$street = $("#id_street_address");$street2 = $("#id_street_address2");$city = $("#id_city");$country = $("#id_country");$postalCode = $("#id_postal_code");$map.find("button.js_locate_address").click(function(event) {var geocoder = new google.maps.Geocodergeocoder.geocode({address: getAddress4search()},function (results, status) {if (status === google.maps.GeocoderStatus.OK) {autocompleteAddress(results);} else {autocompleteAddress(false);}});});$map.find("button.js_remove_geo").click(function() {$lat.val("");$lng.val("");gMarker.setMap(null);gMarker = null;});gMap = new google.maps.Map($map.find(".canvas").get(0), {scrollwheel: false,zoom: 16,center: new google.maps.LatLng(51.511214, -0.119824),disableDoubleClickZoom: true});google.maps.event.addListener(gMap, "dblclick", function(event) {var lat = event.latLng.lat();var lng = event.latLng.lng();updateLatitudeAndLongitude(lat, lng);updateMarker(lat, lng);});if ($lat.val() && $lng.val()) {updateMarker($lat.val(), $lng.val());}});
实现原理…
此时如果在浏览器中查看Change Location表单,会在字段集中显示一个地图,这个字段集后接地址字段的字段集,如下图所示:
地图下面,有两个按钮:Locate address 和 Remove from map。
点击 Locate address按钮时,会调用地理编码来搜索所输入地址的地理坐标。执行地理编码的结果是在以内置的字典格式列出一个或多个地址。我以将使用可点击链接列表的形式展示,如下:
要在开发者工具控制台中查看内置字典的结构,可在autocompleteAddress() 函数的开头处添加如下代码:
1 |
console.log(JSON.stringify(results, null, 4)); |
在点击其中一个选项时,地图上显示的标记展现地点的具体地理位置。会下下面这样填充Latitude和Longitude字段:
这时管理可以通过拖拽在地图上移动标记。同时,在地图上任意位置双击会更新地理坐标及标记的位置。
最后,在点击Remove from map按钮时,会清除地理坐标并删除标记。
管理后台使用自定义LocationModelForm,排除了geoposition字段、添加了Latitude和Longitude字段,并处理对它们值的保存和载入。