Skip to content

Latest commit

 

History

History
1515 lines (1112 loc) · 51.6 KB

File metadata and controls

1515 lines (1112 loc) · 51.6 KB

第9章 导入、导出数据

本章中包含如下小节:

  • 从本地CSV文件中导入数据
  • 从本地Excel文件中导入数据
  • 从外部JSON文件中导入数据
  • 从外部XML文件中导入数据
  • 为搜索引擎准备分页站点地图
  • 创建可过滤的RSS feed
  • 使用Django REST framework创建API

引言

不时地我们就需要将本地一种格式的数据传输到数据库中、从外部资源导入数据或是将数据提供给第三方。本章中,我们将学习一个有关如何编写管理命令及API的实际示例进行实现。

技术要求

运行本章的代码要求安装最新稳定版的Python 3、MySQL或PostgreSQL数据库以及通过虚拟环境创建的Django项目。同时请确保在虚拟环境中安装有Django、Pillow和数据库连接的包。

可在GitHub仓库的Chapter09目录中查看本章的代码。

从本地CSV文件中导入数据

逗号分隔值(CSV)格式可能是用于在文本文件中存储表格数据的最简单方式。本节中,我们将创建一个管理命令,可将CSV文件中的数据导入至Django数据库中。我们需要一个CSV歌曲列表。可以使用Excel、Calc或其它电子表格应用轻松创建。

准备工作

我们来创建在本章中持续使用的music应用:

  1. 创建music应用并将其添加到配置文件的INSTALLED_APPS中:

    # myproject/settings/_base.py
    INSTALLED_APPS = [ 
      #...
      "myproject.apps.core",
      "myproject.apps.music",
    ]
    
  2. 其中的Song模型应包含uuid、artist、title、url和image字段。我们还继承了CreationModificationDateBase来添加创建和修改时间戳,并继承了UrlBase来添加操作模型详情URL的方法:

    # myproject/apps/music/models.py
    import os
    import uuid
    from django.urls import reverse
    from django.utils.translation import ugettext_lazy as _ 
    from django.db import models
    from django.utils.text import slugify
    from myproject.apps.core.models import CreationModificationDateBase, UrlBase
    
    def upload_to(instance, filename):
      filename_base, filename_ext = os.path.splitext(filename) 
      artist = slugify(instance.artist)
      title = slugify(instance.title)
      return f"music/{artist}--{title}{filename_ext.lower()}"
    
    class Song(CreationModificationDateBase, UrlBase):
      uuid = models.UUIDField(primary_key=True, default=None, editable=False)
      artist = models.CharField(_("Artist"), max_length=250)
      title = models.CharField(_("Title"), max_length=250)
      url = models.URLField(_("URL"), blank=True)
      image = models.ImageField(_("Image"), upload_to=upload_to, blank=True, null=True)
      
      class Meta:
        verbose_name = _("Song") 
        verbose_name_plural = _("Songs") 
        unique_together = ["artist", "title"]
    
      def __str__(self):
        return f"{self.artist} - {self.title}"
    
      def get_url_path(self):
        return reverse("music:song_detail", kwargs={"pk": self.pk})
    
      def save(self, *args, **kwargs): 
        if self.pk is None:
          self.pk = uuid.uuid4() 
        super().save(*args, **kwargs)
    
  3. 使用如下命令生成和运行数据库迁移:

    (env)$ python manage.py makemigrations 
    (env)$ python manage.py migrate
    
  4. 然后,为Song模型添加一个简单的后台:

    # myproject/apps/music/admin.py
    from django.contrib import admin 
    from .models import Song
    
    @admin.register(Song)
    class SongAdmin(admin.ModelAdmin):
      list_display = ["title", "artist", "url"] 
      list_filter = ["artist"]
      search_fields = ["title", "artist"]
    
  5. 此外我们需要一个在导入脚本中用于验证和创建Song模型的表单。最直播的方式是使用模型表单,如下:

    # myproject/apps/music/forms.py
    from django import forms
    from django.utils.translation import ugettext_lazy as _ 
    from .models import Song
    
    class SongForm(forms.ModelForm): 
      class Meta:
        model = Song 
        fields = "__all__"
    

如何实现...

按照如下步骤来创建和使用导入本地CSV文件歌曲的管理命令:

  1. 创建一个第一行中带有列名为artist、title和url字段的CSV文件。在下面按照对应的列名添加一些歌曲数据。例如,可能是带有如下内容的data/music.csv文件:

    artist,title,url
    Capital Cities,Safe And Sound,https://open.spotify.com/track/40Fs0YrUGuwLNQSaHGVfqT?si=2OUa wusIT-evyZKonT5GgQ
    Milky Chance,Stolen Dance,https://open.spotify.com/track/3miMZ2IlJiaeSWo1DohXlN?si=g-xM M4m9S_yScOm02C2MLQ
    Lana Del Rey,Video Games - Remastered,https://open.spotify.com/track/5UOo694cVvjcPFqLFiNWGU?si =maZ7JCJ7Rb6WzESLXg1Gdw
    Men I Trust,Tailwhip,https://open.spotify.com/track/2DoO0sn4SbUrz7Uay9ACT M?si=SC_MixNKSnuxNvQMf3yBBg
    
  2. 在music应用中,创建一个management目录然后在该目录中创建一个commands目录。在两个目录中都添加空的__init__.py文件让这些新目录变成Python包。

  3. 使用如下内容添加一个import_music_from_csv.py文件:

    # myproject/apps/music/management/commands/import_music_from_csv.py
    from django.core.management.base import BaseCommand
    
    class Command(BaseCommand): 
      help = (
        "Imports music from a local CSV file. "
        "Expects columns: artist, title, url"
      )
      SILENT, NORMAL, VERBOSE, VERY_VERBOSE = 0, 1, 2, 3
    
      def add_arguments(self, parser):
        # Positional arguments 
        parser.add_argument("file_path", nargs=1, type=str)
      
      def handle(self, *args, **options):
        self.verbosity = options.get("verbosity", self.NORMAL) 
        self.file_path = options["file_path"][0] 
        self.prepare()
        self.main()
        self.finalize()
    
  4. 然后,在同一文件的Command类中,创建一个prepare()方法:

    def prepare(self): 
      self.imported_counter = 0 
      self.skipped_counter = 0
    
  5. 接着创建main()方法:

    def main(self): 
      import csv
      from ...forms import SongForm
      
      if self.verbosity >= self.NORMAL: 
        self.stdout.write("=== Importing music ===")
      
      with open(self.file_path, mode="r") as f: 
        reader = csv.DictReader(f)
        for index, row_dict in enumerate(reader):
          form = SongForm(data=row_dict)
          if form.is_valid():
            song = form.save()
            if self.verbosity >= self.NORMAL:
              self.stdout.write(f" - {song}\n") 
            self.imported_counter += 1
          else:
            if self.verbosity >= self.NORMAL: 
              self.stderr.write(
                f"Errors importing song " 
                f"{row_dict['artist']} - {row_dict['title']}:\n"
              )
              self.stderr.write(f"{form.errors.as_json()}\n") 
            self.skipped_counter += 1
    
  6. 在这个类中添加最一个方法finalize():

    def finalize(self)
      if self.verbosity >= self.NORMAL:
        self.stdout.write(f"-------------------------\n") 
        self.stdout.write(f"Songs imported: {self.imported_counter}\n") 
        self.stdout.write(f"Songs skipped: {self.skipped_counter}\n\n")
    
  7. 在命令行中调用如下命令执行导入:

    (env)$ python manage.py import_music_from_csv data/music.csv
    

实现原理...

Django管理命令是带有派生自BaseCommand的Command类的脚本,它重写add_arguments() 和 handle()方法。help属性定义了管理命令的帮助文本。可在输入如下命令时出现:

(env)$ python manage.py help import_music_from_csv

Django管理命令使用内置的argparse模块来解析传递过来的参数。add_arguments() 方法定义应将哪些位置参数或命名参数传递给管理命令。本例中,我们将添加Unicode类型的file_path位置参数。通过将nargs变量设置为1,仅允许有一个值。

ℹ️学习更多可定义的其它参数以及使用方法,参见argparse官方文档

在handle()方法的开始处,会查看verbosity参数。verbosity定义了命令在终端中的输出内容量,值从0不给出任何日志到3打印出丰富的日志。可以像下面这样对该命令传递命名参数:

(env)$ python manage.py import_music_from_csv data/music.csv --verbosity=0

我们还需要有文件名作为第一个位置参数。options["file_path"]返回一个列表,值的长度由nargs定义。本例中,nargs等于1,因此options["file_path"] 为一个包含一个元素的列表。

将管理命令分隔为多个更小的方法是一种良好实践,例如这里脚本中所使用的prepare()、main()和finalize():

  • prepare()方法将导入计数器设置为0。还可用于脚本所需要的其它配置中。
  • 在main() 方法中,我们执行管理命令的主逻辑。首先我们打开待读取的给定文件并将其指针传递给csv.DictReader。文件的第一行中被认为包含每一列的列头。DictReader使用它们作为每一行字典的键。然后遍历每一行,将字典传递给模型表单并对其进行验证。如果验证通过的话,会保存歌曲并增加imported_counter。如果因值过长、缺少必填值、类型错误或其它验证错误导致验证失败,会增加skipped_counter。verbosity的值大于等于NORMAL(即值1)时,每个导入或跳过的歌曲会和可能存在的验证错误一并打印出。
  • finalize()方法打印出导入的歌曲数以及因验证错误而跳过的歌曲数。

如果在开发时希望调试管理命令的错误,可传递--traceback参数来实现。在出现错误时,会看到该问题的完整栈追踪信息。

假定使用--verbosity=1或更高值再次调用命令,可能会看到如下的输出的内容:

TODO

可以看到,在第二次导入歌曲时,没有通过unique_together约束因此跳过了。

相关内容

  • 从本地Excel文件中导入数据一节
  • 从外部JSON文件中导入数据一节
  • 从外部XML文件中导入数据一节

从本地Excel文件中导入数据

另一种流行的格式是将表格数据存储在Excel电子表格中。本节中,我们就学习如何从这种格式的文件中导入歌曲。

准备工作

我们使用前一小节中所创建的music应用。读取Excel文件需要先安装openpyxl包,如下:

(env)$ pip install openpyxl==3.0.2

如何实现...

按照如下步骤来创建和使用从本地导入XLSX文件的管理命令:

  1. 创建一个XLSX文件,第一行中的列名分别为Artist、Title和URL。在接下来的几行中对照列名添加一些歌曲数据。可以在电子表格应用中通过将前一小节中的CSV保存为XLSX文件data/music.xlsx来实现。下面是示例:
    TODO

  2. 如尚未创建,请在music应用中创建management目录及其子目录commands。在这两个新目录下添加空的__init__.py文件来让它们成为Python包。

  3. 使用如下内容添加一个import_music_from_xlsx.py文件:

    # myproject/apps/music/management/commands/import_music_from_xlsx.py
    from django.core.management.base import BaseCommand
    
    class Command(BaseCommand):
      help = (
        "Imports music from a local XLSX file. " 
        "Expects columns: Artist, Title, URL"
      )
      SILENT, NORMAL, VERBOSE, VERY_VERBOSE = 0, 1, 2, 3
    
      def add_arguments(self, parser):
        # Positional arguments 
        parser.add_argument("file_path",
                            nargs=1, 
                            type=str)
    
      def handle(self, *args, **options):
        self.verbosity = options.get("verbosity", self.NORMAL) 
        self.file_path = options["file_path"][0] 
        self.prepare()
        self.main()
        self.finalize()
    
  4. 然后还是在该文件的Command类中,创建一个prepare()方法:

    def prepare(self): 
      self.imported_counter = 0 
      self.skipped_counter = 0
    
  5. 接着在其中创建main()方法:

    def main(self):
      from openpyxl import load_workbook 
      from ...forms import SongForm
      
      wb = load_workbook(filename=self.file_path) 
      ws = wb.worksheets[0]
      
      if self.verbosity >= self.NORMAL: 
        self.stdout.write("=== Importing music ===")
      
      columns = ["artist", "title", "url"]
      rows = ws.iter_rows(min_row=2) # skip the column captions
      for index, row in enumerate(rows, start=1):
        row_values = [cell.value for cell in row] 
        row_dict = dict(zip(columns, row_values)) 
        form = SongForm(data=row_dict)
        if form.is_valid():
          song = form.save()
          if self.verbosity >= self.NORMAL:
            self.stdout.write(f" - {song}\n") 
          self.imported_counter += 1
        else:
          if self.verbosity >= self.NORMAL:
            self.stderr.write(
              f"Errors importing song " 
              f"{row_dict['artist']} -
              {row_dict['title']}:\n"
            )
            self.stderr.write(f"{form.errors.as_json()}\n") 
          self.skipped_counter += 1
    
  6. 在类中添加最一个方法finalize():

    def finalize(self):
      if self.verbosity >= self.NORMAL:
        self.stdout.write(f"-------------------------\n") 
        self.stdout.write(f"Songs imported: {self.imported_counter}\n") 
        self.stdout.write(f"Songs skipped: {self.skipped_counter}\n\n")
    
  7. 在命令行中调用如何命令执行导入:

    (env)$ python manage.py import_music_from_xlsx data/music.xlsx
    

实现原理...

导入XLSX文件的原理和CSV相同。我们打开文件,逐行读取,形成数据字典、通过模型表单进行校验,根据所提供的数据创建Song对象。

同样,我们使用prepare()、main()和finalize()方法来将逻辑分割为更为原子的级别。

以下是对main()方法的详细说明,因为这可能是管理命令中唯一不同的部分:

  • Excel文件是包含不同标签数据表的工作薄。
  • 我们使用openpyxl库来打开以位置参数传递给命令的文件。然后读取工作薄中的第一张数据表。
  • 第一行为列标题。我们跳过了这一行。
  • 然后,我们逐行读取每行值为列表,使用zip()函数创建字典,将它们传递给模型表单,验证并通过它们创建Song对象。
  • 如何存在错误且verbosity的值大于等于NORMAL,则会输出验证错误信息。
  • 同时管理命令会在终端中打印出所导入的歌曲,--verbosity=0时除外。

--verbosity=1或更高时运行该命令,会出现如下的输出:

TODO

ℹ️可以通过http://www.python-excel.org学习更多有关操作Excel文件的知识。

相关内容

  • 从本地CSV文件中导入数据一节
  • 从外部JSON文件中导入数据一节
  • 从外部XML文件中导入数据一节

从外部JSON文件中导入数据

Last.fm音乐网站有一个API,域名为https://ws.audioscrobbler.com,可以用于读取专辑、艺术家、单曲、事件等信息。这一API允许我们使用JSON或XML格式。本节中我们使用JSON格式导入标签为indie的热门单曲。

准备工作

按照如下步骤来从Last.fm以JSON格式导入数据:

  1. 首先使用从本地CSV文件中导入数据一节中所创建的music应用。

  2. 要使用Last.fm,需要注册获取一个API密钥。API密钥可以通过https://www.last.fm/api/account/create进行创建。

  3. 需要在配置文件中使用LAST_FM_API_KEY设置API密钥。推荐通过密钥文件或环境变量设置并在配置文件中进行提取,如下所示:

    # myproject/settings/_base.py
    LAST_FM_API_KEY = get_secret("LAST_FM_API_KEY")
    
  4. 另外在虚拟环境中使用如下命令安装requests库:

    (env)$ pip install requests==2.22.0
    
  5. 我们来查看热门indie单曲JSON端点的结构(https://ws.audioscrobbler.com/2.0/?method=tag.gettoptracks&tag =indie&api_key=YOUR_API_KEY&format=json),类似如下结果:

    {
      "tracks": {
        "track": [
          {
            "name": "Mr. Brightside",
            "duration": "224",
            "mbid": "37d516ab-d61f-4bcb-9316-7a0b3eb845a8",
            "url": "https://www.last.fm/music/The+Killers/_/Mr.+Brightside",
            "streamable": {
              "#text": "0",
              "fulltrack": "0"
            },
            "artist": {
              "name": "The Killers",
              "mbid": "95e1ead9-4d31-4808-a7ac-32c3614c116b",
              "url": "https://www.last.fm/music/The+Killers"
            },
            "image": [
              {
                "#text": 
                "https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png",
                "size": "small"
              },
              {
                "#text":  
               "https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png",
                "size": "medium"
              },
              {
                "#text": 
                "https://lastfm.freetls.fastly.net/i/u/174s
                 /2a96cbd8b46e442fc41c2b86b821562f.png",
                "size": "large"
              },
              {
                "#text": 
                "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
                "size": "extralarge"
              }
            ],
            "@attr": {
              "rank": "1"
            }
          },
          ...
        ],
        "@attr": {
          "tag": "indie",
          "page": "1",
          "perPage": "50",
          "totalPages": "4475",
          "total": "223728"
        }
      }
    }
    

我们希望读取单区的名称、艺术家、URL以及中等大小的图片。此外,我们想要了解一共存在多少页,这在JSON文件最后面的元信息中有提供。

如何实现...

按照如下步骤创建Song模型以及管理命令,用于从Last.fm将JSON格式的最佳单曲导入至数据库中:

  1. 如尚未创建,请在music应用中创建management目录及其子目录commands。对这两个新目录添加空__init__.py文件让它们成为Python包。

  2. 添加import_music_from_lastfm_json.py文件并加入如下内容:

    # myproject/apps/music/management/commands/import_music_from_lastfm_json.py
    from django.core.management.base import BaseCommand
    
    class Command(BaseCommand):
      help = "Imports top songs from last.fm as JSON." 
      SILENT, NORMAL, VERBOSE, VERY_VERBOSE = 0, 1, 2, 3 
      API_URL = "https://ws.audioscrobbler.com/2.0/"
    
      def add_arguments(self, parser):
        # Named (optional) arguments 
        parser.add_argument("--max_pages", type=int, default=0)
      
      def handle(self, *args, **options):
        self.verbosity = options.get("verbosity", self.NORMAL) 
        self.max_pages = options["max_pages"]
        self.prepare()
        self.main()
        self.finalize()
    
  3. 然后在该文件的Command类中创建一个prepare()方法:

    def prepare(self):
      from django.conf import settings
    
      self.imported_counter = 0 
      self.skipped_counter = 0 
      self.params = {
        "method": "tag.gettoptracks", 
        "tag": "indie",
        "api_key": settings.LAST_FM_API_KEY,
        "format": "json",
        "page": 1, 
      }
    
  4. 接着在其中创建main()方法:

    def main(self):
      import requests
    
      response = requests.get(self.API_URL, params=self.params)
      if response.status_code != requests.codes.ok: 
        self.stderr.write(f"Error connecting to {response.url}") 
        return
      response_dict = response.json() 
      pages = int(
        response_dict.get("tracks", {})
        .get("@attr", {}).get("totalPages", 1)
      )
      
      if self.max_pages > 0:
        pages = min(pages, self.max_pages)
    
      if self.verbosity >= self.NORMAL: 
        self.stdout.write(f"=== Importing {pages} page(s) of tracks ===")
      
      self.save_page(response_dict)
      
      for page_number in range(2, pages + 1): 
        self.params["page"] = page_number 
        response = requests.get(self.API_URL, params=self.params)
        if response.status_code != requests.codes.ok: 
          self.stderr.write(f"Error connecting to {response.url}") 
          return
        response_dict = response.json() 
        self.save_page(response_dict)
    
  5. 每页分页feed会由我们所创建的save_page() 方法进行保存,如下:

    def save_page(self, data): 
      import os
      import requests
      from io import BytesIO
      from django.core.files import File
      from ...forms import SongForm
    
      for track_dict in data.get("tracks", {}).get("track"): 
        if not track_dict:
          continue
        
        song_dict = {
          "artist": track_dict.get("artist", {}).get("name", ""), 
          "title": track_dict.get("name", ""),
          "url": track_dict.get("url", ""),
        }
        form = SongForm(data=song_dict)
        if form.is_valid(): 
          song = form.save()
        
          image_dict = track_dict.get("image", None) 
          if image_dict:
            image_url = image_dict[1]["#text"]
            image_response = requests.get(image_url) 
            song.image.save(
              os.path.basename(image_url),
              File(BytesIO(image_response.content)), 
            )
    
          if self.verbosity >= self.NORMAL: 
            self.stdout.write(f" - {song}\n")
          self.imported_counter += 1 
        else:
          if self.verbosity >= self.NORMAL: 
            self.stderr.write(
              f"Errors importing song " 
              f"{song_dict['artist']} - {song_dict['title']}:\n"
            )
            self.stderr.write(f"{form.errors.as_json()}\n") 
          self.skipped_counter += 1
    
  6. 在该类中添加最后一个方法finalize():

    def finalize(self):
      if self.verbosity >= self.NORMAL:
        self.stdout.write(f"-------------------------\n") 
        self.stdout.write(f"Songs imported:
          {self.imported_counter}\n") 
        self.stdout.write(f"Songs skipped:
          {self.skipped_counter}\n\n")
    
  7. 调用如下命令执行导入:

    (env)$ python manage.py import_music_from_lastfm_json --max_pages=3
    

实现原理...

前面已经提到,脚本中的参数在只列出一个字符串序列时可以是位置参数,或者可以为以--加变量名开头的命名参数。命名参数--max_pages限制导入数据为3页。如果希望下载所有热门单曲可以跳过这一设置或传递值0。

注意在totalPages的值中列明有大约4500页,这会花费很长的时间来进行处理。

脚本的结构类似此前的导入脚本:

  • prepare()方法用于配置准备
  • main()方法处理请求及响应
  • save_page()方法保存每一页中的歌曲
  • finalize()方法打印出导入统计数据

在main()方法中,我们使用requests.get()来从Last.fm读取数据,传递params查询参数。响应对象有一个内置方法json(),它将JSON字符串转化为一个已解析的字典对象。通过第一个请求,我们知道了总页数,然后读取每一页并调用save_page()方法来解析信息并保存歌曲。

在save_page()方法中,我们从单曲中读取值并构建一个模型表单所需的字典。对表单进行验证,如果数据通过验证,则创建Song对象。

导入中一个有意思的部分是对图片的下载和保存。这里我们还使用requests.get()来获取图片数据,然后通过BytesIO传递给File,相应地在image.save()方法中使用。image.save()的第一个参数是文件名,它会由upload_to函数进行重写,仅需用到文件的扩展名。

如果调用命令时使用--verbosity=1或更高的值,会像前面小节一样看到有关导入的详细信息。

ℹ️可以通过https://www.last.fm/api/了解对Last.fm的更多操作。

相关内容

  • 从本地CSV文件中导入数据一节
  • 从本地Excel文件中导入数据一节
  • 从外部XML文件中导入数据一节

从外部XML文件中导入数据

正如我们在前一节中展示的操作JSON数据一样,Last.fm还允许我们以XML格式从其服务中获取数据。本节中,我们来学习如何实现。

准备工作

按照如下步骤来从Last.fm导入XML格式的数据:

  1. 首先使用从本地CSV文件中导入数据一节中所创建的music应用。

  2. 要使用Last.fm,需要注册并获取一个API密钥,创建地址为https://www.last.fm/api/account/create。

  3. 应配置文件中以LAST_FM_API_KEY配置API密钥。推荐使用密钥文件或环境变量提供这一信息并在配置文件中进行提取,如下所示:

    # myproject/settings/_base.py
    LAST_FM_API_KEY = get_secret("LAST_FM_API_KEY")
    
  4. 还应使用如下命令在虚拟环境中安装requests和defusedxml库:

    (env)$ pip install requests==2.22.0 
    (env)$ pip install defusedxml==0.6.0
    
  5. 我们来查看热门indie单曲XML端点的结构(https://ws.audioscrobbler.com/2.0/?method=tag.gettoptracks&tag =indie&api_key=YOUR_API_KEY&format=xml),类似如下结果:

    <?xml version="1.0" encoding="UTF-8" ?> 
    <lfm status="ok">
      <tracks tag="indie" page="1" perPage="50" 
        totalPages="4475" total="223728">
        <track rank="1">
          <name>Mr. Brightside</name>
          <duration>224</duration> 
          <mbid>37d516ab-d61f-4bcb-9316-7a0b3eb845a8</mbid> 
          <url>https://www.last.fm/music/The+Killers/_/Mr.+Brightside</url>
          <streamable fulltrack="0">0</streamable>
          <artist>
            <name>The Killers</name> 
            <mbid>95e1ead9-4d31-4808-a7ac-32c3614c116b</mbid> 
            <url>https://www.last.fm/music/The+Killers</url>
          </artist>
          <image size="small">https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png</image>
          <image size="medium"> https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png</image> 
          <image size="large">https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png</image> 
          <image size="extralarge">https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png</image>
        </track> 
        ...
      </tracks>
    </lfm>
    

如何实现...

按照如下步骤来创建Song模型及管理命令,用于将Last.fm的XML格式热门单曲导入到数据库中:

  1. 如尚未创建,请在music包下创建一个management目录及其子目录commands。在这两个新目录中添加空的__init__.py文件将它们转化为Python包。

  2. 使用如下内容新增一个import_music_from_lastfm_xml.py文件:

    # myproject/apps/music/management/commands
    # /import_music_from_lastfm_xml.py
    
    from django.core.management.base import BaseCommand
    
    class Command(BaseCommand):
      help = "Imports top songs from last.fm as XML." 
      SILENT, NORMAL, VERBOSE, VERY_VERBOSE = 0, 1, 2, 3 
      API_URL = "https://ws.audioscrobbler.com/2.0/"
      
      def add_arguments(self, parser):
        # Named (optional) arguments 
        parser.add_argument("--max_pages", type=int, default=0)
      
      def handle(self, *args, **options):
        self.verbosity = options.get("verbosity", self.NORMAL) 
        self.max_pages = options["max_pages"]
        self.prepare()
        self.main()
        self.finalize()
    
  3. 然后在该文件的Command类中创建一个prepare() 方法:

    def prepare(self):
      from django.conf import settings
    
      self.imported_counter = 0 
      self.skipped_counter = 0 
      self.params = {
        "method": "tag.gettoptracks", 
        "tag": "indie",
        "api_key": settings.LAST_FM_API_KEY, 
        "format": "xml",
        "page": 1, 
      }
    
  4. 接着创建如下的main()方法:

    def main(self): 
      import requests
      from defusedxml import ElementTree
    
      response = requests.get(self.API_URL, params=self.params)
      if response.status_code != requests.codes.ok: 
        self.stderr.write(f"Error connecting to {response.url}") 
        return
      root = ElementTree.fromstring(response.content)
    
      pages = int(root.find("tracks").attrib.get("totalPages", 1)) 
      if self.max_pages > 0:
        pages = min(pages, self.max_pages)
      
      if self.verbosity >= self.NORMAL: 
        self.stdout.write(f"=== Importing {pages} page(s) of songs ===")
    
      self.save_page(root)
    
      for page_number in range(2, pages + 1):
        self.params["page"] = page_number
        response = requests.get(self.API_URL, params=self.params) 
        if response.status_code != requests.codes.ok:
          self.stderr.write(f"Error connecting to {response.url}")
          return
        root = ElementTree.fromstring(response.content) 
        self.save_page(root)
    
  5. 分页feed的每一页经由我们所创建的save_page()方法进行保存,如下:

    def save_page(self, root): import os
      import requests
      from io import BytesIO
      from django.core.files import File 
      from ...forms import SongForm
      
      for track_node in root.findall("tracks/track"): 
        if not track_node:
          continue
    
        song_dict = {
          "artist": track_node.find("artist/name").text, 
          "title": track_node.find("name").text,
          "url": track_node.find("url").text, 
        }
        form = SongForm(data=song_dict)
        if form.is_valid(): 
          song = form.save()
    
          image_node = track_node.find("image[@size='medium']") 
          if image_node is not None:
            image_url = image_node.text
            image_response = requests.get(image_url) 
            song.image.save(
              os.path.basename(image_url),
              File(BytesIO(image_response.content)), 
            )
          
          if self.verbosity >= self.NORMAL: 
            self.stdout.write(f" - {song}\n")
          self.imported_counter += 1 
        else:
          if self.verbosity >= self.NORMAL: 
            self.stderr.write(
              f"Errors importing song "
              f"{song_dict['artist']} - {song_dict['title']}:\n" 
            )
            self.stderr.write(f"{form.errors.as_json()}\n") 
          self.skipped_counter += 1
    
  6. 我们在该类中添加最后一个方法finalize():

    def finalize(self):
      if self.verbosity >= self.NORMAL:
        self.stdout.write(f"-------------------------\n") 
        self.stdout.write(f"Songs imported: {self.imported_counter}\n") 
        self.stdout.write(f"Songs skipped: {self.skipped_counter}\n\n")
    
  7. 在命令行中调用如下命令来执行导入:

    (env)$ python manage.py import_music_from_lastfm_xml --max_pages=3
    

实现原理...

流程类似JSON中的操作。我们使用requests.get()从Last.fm读取数据,以params传递查询参数。XML响应内容通过defusedxml模块传递给ElementTree解析器,根节点不进行返回。

ℹ️defusedxml是xml模块的一个更安全替代。它防止了XML bomb攻击-一种让攻击者可以使用几百字节的XML数据耗用几G内存的漏洞攻击。

ElementTree节点具有find() 和 findall()方法,可以对其传递XPath查询来过滤出具体的子节点。

以下是ElementTree所支持的XPath语法表:

[table id=61 /]

因此,在main()方法中使用root.find("tracks").attrib.get("totalPages", 1)我们读取到页面的总数,如果出于某种原因缺失了该数据默认为1。我们会保存第一页,然后逐一对各页进行保存。

在save_page() 方法中,root.findall("tracks/track") 返回节点下节点的迭代器。通过track_node.find("image[@size='medium']")可以获取到中等大小的图片。同时,在整个用于验证输入数据的模型表单中会进行Song的创建。

如果使用--verbosity=1或更大的值调用命令,会像前面小节中一样看到有关导入歌曲的详细信息。

扩展知识...

可以通过如下链接学习更多知识:

相关内容

  • 从本地CSV文件中导入数据一节
  • 从本地Excel文件中导入数据一节
  • 从外部JSON文件中导入数据一节

为搜索引擎准备分页站点地图

Sitemaps协议告知搜索引擎网站上的不同页面。通常是一个sitemap.xml 文件,其中包含哪些内容可进行索引以及索引频率。如果在网站上有很多个不同页面,还可以对 XML 文件进行分割和分页,来更快的渲染出资源列表。

本节中,我们将展示如何在Django网站中创建分页的站点地图。

准备工作

本节及后续小节中,我们需要对music应用进行扩展,添加列表和详情页:

  1. 使用如下内容创建views.py文件:

    # myproject/apps/music/views.py
    from django.views.generic import ListView, DetailView 
    from django.utils.translation import ugettext_lazy as _ 
    from .models import Song
    
    class SongList(ListView): 
      model = Song
    
    class SongDetail(DetailView): 
      model = Song
    
  2. 使用如下内容创建urls.py文件:

    # myproject/apps/music/urls.py
    from django.urls import path
    from .views import SongList, SongDetail
    
    app_name = "music"
    
    urlpatterns = [
      path("", SongList.as_view(), name="song_list"), 
      path("<uuid:pk>/", SongDetail.as_view(), name="song_detail"),
    ]
    
  3. 将URL配置添加到项目的URL配置文件中:

    # myproject/urls.py
    from django.conf.urls.i18n import i18n_patterns 
    from django.urls import include, path
    
    urlpatterns = i18n_patterns( 
      #...
      path("songs/", include("myproject.apps.music.urls", namespace="music")),
    )
    
  4. 为歌曲列表视图创建一个模板:

    {# music/song_list.html #}
    {% extends "base.html" %} 
    {% load i18n %}
    
    {% block main %}
      <ul>
        {% for song in object_list %}
          <li><a href="{{ song.get_url_path }}">
            {{ song }}</a></li>
        {% endfor %}
      </ul>
    {% endblock %}
    
  5. 然后为歌曲详情视图创建一个模板:

    {# music/song_detail.html #}
    {% extends "base.html" %} 
    {% load i18n %}
    
    {% block content %}
      {% with song=object %}
        <h1>{{ song }}</h1> 
        {% if song.image %}
          <img src="{{ song.image.url }}" alt="{{ song }}" /> 
        {% endif %}
        {% if song.url %}
          <a href="{{ song.url }}" target="_blank"
            rel="noreferrer noopener">
            {% trans "Check this song" %}
          </a>
        {% endif %}
      {% endwith %}
    {% endblock %}
    

如何实现...

按照如下步骤添加分页站点地图:

  1. 在配置文件的INSTALLED_APPS中添加django.contrib.sitemaps:

    # myproject/settings/_base.py
    INSTALLED_APPS = [ 
      #...
      "django.contrib.sitemaps",
      #... 
    ]
    
  2. 修改项目的urls.py 如下:

    # myproject/urls.py
    from django.conf.urls.i18n import i18n_patterns
    from django.urls import include, path
    from django.contrib.sitemaps import views as sitemaps_views 
    from django.contrib.sitemaps import GenericSitemap
    from myproject.apps.music.models import Song
    
    class MySitemap(GenericSitemap):
      limit = 50
      def location(self, obj): 
        return obj.get_url_path()
    
    song_info_dict = {
      "queryset": Song.objects.all(), 
      "date_field": "modified",
    }
    sitemaps = {"music": MySitemap(song_info_dict, priority=1.0)}
    
    urlpatterns = [
      path("sitemap.xml", sitemaps_views.index,
        {"sitemaps": sitemaps}), 
      path("sitemap-<str:section>.xml", sitemaps_views.sitemap,
        {"sitemaps": sitemaps}, 
          name="django.contrib.sitemaps.views.sitemap"
      ), 
    ]
    
    urlpatterns += i18n_patterns( 
      #...
      path("songs/", include("myproject.apps.music.urls", namespace="music")),
    )
    

实现原理...

如果访问http://127.0.0.1:8000/sitemap.xml,会看到索引及分页站点视图:

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap> 
    <loc>http://127.0.0.1:8000/sitemap-music.xml</loc>
  </sitemap>
  <sitemap>
    <loc>http://127.0.0.1:8000/sitemap-music.xml?p=2</loc> 
  </sitemap>
  <sitemap> 
    <loc>http://127.0.0.1:8000/sitemap-music.xml?p=3</loc>
  </sitemap>
</sitemapindex>

这里的每一页每显示50个条目,包含URL、最后修改时间及优先级:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url> 
    <loc>http://127.0.0.1:8000/en/songs/b2d3627b-dbc7-4c11-a13e-03d86f32a719/</loc>
    <lastmod>2019-12-15</lastmod> 
    <priority>1.0</priority>
  </url> 
  <url>
    <loc>http://127.0.0.1:8000/en/songs/f5c386fd-1952-4ace-9848-717d27186fa9/</loc>
    <lastmod>2019-12-15</lastmod>
    <priority>1.0</priority> 
  </url>
  <url> 
    <loc>http://127.0.0.1:8000/en/songs/a59cbb5a-16e8-46dd-9498-d86e24e277a5/</loc> 
    <lastmod>2019-12-15</lastmod> 
    <priority>1.0</priority>
  </url> 
  ...
</urlset>

在站点就绪并发布至生产环境时,可以执行站点地图框架所提供的ping_google管理命令通知Google搜索引擎你的页面已上线。在生产服务器上执行如下命令:

(env)$ python manage.py ping_google --settings=myproject.settings.production

扩展知识...

可以通过如下链接学习更多内容:

相关内容

  • 创建可过滤的RSS feed一节

创建可过滤的RSS feed

Django自带有聚合feed框架,让我们可以创建简单信息聚合(RSS)和Atom feed。RSS及Atom feed是具有特殊语法的XML文档。可在RSS阅读器中进行订阅,如Feedly,或者是在其它网站、移动端应用或桌面应中进行聚合。本节中我们会创建一个提供歌曲信息的RSS feed。并且结果可通过URL查询参数进行过滤。

准备工作

使用从本地CSV文件中导入数据为搜索引擎准备分页站点地图小节中所创建的music应用。具体可参见其准备工作部分中的步骤配置模型、表单、视图、URL配置及模板。

对于歌曲列表视图,我们会添加通过artist进行过滤并在稍后的RSS feed中进行使用:

  1. 在forms.py中添加过滤器表单。其中包含一个artist选项字段,按所有艺术家姓名忽略大小写字母排序:

    # myproject/apps/music/forms.py
    from django import forms
    from django.utils.translation import ugettext_lazy as _ 
    from .models import Song
    
    #...
    
    class SongFilterForm(forms.Form):
      def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs) 
        artist_choices = [
          (artist, artist)
          for artist in sorted(
            Song.objects.values_list("artist", flat=True).distinct(),
            key=str.casefold 
          )
        ]
        self.fields["artist"] = forms.ChoiceField( 
          label=_("Artist"), 
          choices=artist_choices, 
          required=False,
        )
    
  2. 通过管理过滤的方法来改进SongList视图:get()方法会处理过滤并显示结果,get_form_kwargs()方法为过滤表单准备关键词参数,get_queryset()通过艺术家过滤歌曲:

    # myproject/apps/music/views.py
    from django.http import Http404
    from django.views.generic import ListView, DetailView, FormView 
    from django.utils.translation import ugettext_lazy as _
    from .models import Song
    from .forms import SongFilterForm
    
    class SongList(ListView, FormView): 
      form_class = SongFilterForm 
      model = Song
      
      def get(self, request, *args, **kwargs): 
        form_class = self.get_form_class() 
        self.form = self.get_form(form_class)
        
        self.object_list = self.get_queryset() 
        allow_empty = self.get_allow_empty()
        if not allow_empty and len(self.object_list) == 0:
          raise Http404(_(u"Empty list and '%(class_name)s .allow_empty' is False.")
                        % {'class_name': 
                        self.__class__.__name__})
        context = self.get_context_data(object_list= self.object_list, form=self.form)
        return self.render_to_response(context)
      
      def get_form_kwargs(self): 
        kwargs = {
          'initial': self.get_initial(),
          'prefix': self.get_prefix(), 
        }
        if self.request.method == 'GET': 
          kwargs.update({
            'data': self.request.GET,
          })
        return kwargs
      
      def get_queryset(self):
        queryset = super().get_queryset() 
        if self.form.is_valid():
          artist = self.form.cleaned_data.get("artist") 
          if artist:
            queryset = queryset.filter(artist=artist) 
        return queryset
    
  3. 修改歌曲列表模板来添加用于过滤的表单:

    {# music/song_list.html #}
    {% extends "base.html" %}
    {% load i18n %}
    
    {% block sidebar %}
        <form action="" method="get">
            {{ form.errors }}
            {{ form.as_p }}
            <button type="submit" class="btn btn-primary">
                {% trans "Filter" %}</button>
        </form>
    {% endblock %}
    
    {% block main %}
        <ul>
            {% for song in object_list %}
                <li><a href="{{ song.get_url_path }}">
                    {{ song }}</a></li>
            {% endfor %}
        </ul>
    {% endblock %}
    

如果现在在浏览器中查看歌曲列表视图并进行歌曲过滤,比如Lana Del Rey,会看到类似下面的结果:

TODO

过滤歌曲列表的URL为http://127.0.0.1:8000/en/songs/?artist=Lana+Del+Rey。

如何实现...

下面在music应用中添加RSS feed:

  1. 在music应用中,创建feeds.py 文件并添加如下内容:

    # myproject/apps/music/feeds.py
    from django.contrib.syndication.views import Feed 
    from django.urls import reverse
    
    from .models import Song
    from .forms import SongFilterForm
    
    class SongFeed(Feed):
        description_template = "music/feeds/song_description.html"
    
        def get_object(self, request, *args, **kwargs): 
            form = SongFilterForm(data=request.GET) 
            obj = {}
            if form.is_valid():
                obj = {"query_string": request.META["QUERY_STRING"]} 
                for field in ["artist"]:
                    value = form.cleaned_data[field]
                    obj[field] = value 
            return obj
    
        def title(self, obj): 
            the_title = "Music" 
            artist = obj.get("artist") 
            if artist:
                the_title = f"Music by {artist}" 
            return the_title
        
        def link(self, obj):
            return self.get_named_url("music:song_list", obj)
    
        def feed_url(self, obj):
            return self.get_named_url("music:song_rss", obj)
        
        @staticmethod
        def get_named_url(name, obj):
            url = reverse(name)
            qs = obj.get("query_string", False) 
            if qs:
                url = f"{url}?{qs}" 
            return url
    
        def items(self, obj):
            queryset = Song.objects.order_by("-created")
            
            artist = obj.get("artist") 
            if artist:
                queryset = queryset.filter(artist=artist) 
            return queryset[:30]
    
        def item_pubdate(self, item): 
            return item.created
    
  2. 在RSS feed中为歌曲描述创建一个模板:

    {# music/feeds/song_description.html #}
    {% load i18n %}
    {% with song=obj %}
        {% if song.image %}
            <img src="{{ song.image.url }}" alt="{{ song }}" />
        {% endif %}
        {% if song.url %}
            <a href="{{ song.url }}" target="_blank" rel="noreferrer noopener">
                {% trans "Check this song" %}
            </a>
        {% endif %}
    {% endwith %}
    
  3. 在应用的URL配置中加入RSS feed :

    # myproject/apps/music/urls.py
    from django.urls import path
    
    from .feeds import SongFeed
    from .views import SongList, SongDetail
    
    app_name = "music"
    urlpatterns = [
        path("", SongList.as_view(), name="song_list"), 
        path("<uuid:pk>/", SongDetail.as_view(), name="song_detail"),
        path("rss/", SongFeed(), name="song_rss"),
    ]
    
  4. 在歌曲列表视图的模板中,为RSS feed添加一个链接:

    {# music/song_list.html #}
    
    {% url "music:songs_rss" as songs_rss_url %}
    <p>
        <a href="{{ songs_rss_url }}?{{ request.META.QUERY_STRING }}"> 
            {% trans "Subscribe to RSS feed" %}
        </a> 
    </p>
    

实现原理...

如果通过http://127.0.0.1:8000/en/songs/?artist=Lana+Del+Rey刷新过滤列表视图,会看到**Subscribe to RSS feed**,链接到http://127.0.0.1:8000/en/songs/rss/?artist=Lana+Del+Rey。这是一个通过艺术家过滤的最多30首歌曲的RSS feed。

SongFeed类处理为RSS feed自动生成XML标记。在其中指定了如下方法:

  • get_object()方法定义Feed类的上下文字典,在其它方法中会进行使用。
  • title()方法定义根据是否对结果进行过滤的feed标题。
  • link()方法返回列表视图的URL,而feed_url()返回的是feed的URL。两者都使用一个帮助方法,get_named_url(),它通过路径名和查询参数生成URL。
  • items()方法歌曲的queryset,可通过艺术家进行过滤。
  • item_pubdate()方法返回歌曲的创建日期。

ℹ️要查看我们所继承的Feed类中所有可用的方法和属性,参见以这个文档:https://docs.djangoproject.com/en/3.0/ref/contrib/syndication/#feed-class-reference。

相关内容

  • 从本地CSV文件中导入数据一节
  • 为搜索引擎准备分页站点地图一节

使用Django REST framework创建API

在需要对模型创建RESTful API和第三方之间传输数据时,Django REST framework可能是能够使用的最好的工具。这一框架具有深入的文档以及以Django为中心的实现,有助于让其更方便维护。本节中,我们将学习如何使用Django REST framework来允许项目伙伴、移动客户端或基于Ajax的网站 来访问网站数据,相应地创建、读取、更新及删除内容。

准备工作

首先,使用如下命令在虚拟环境中安装Django REST Framework:

(env)$ pip install djangorestframework==3.11.0

在配置文件的INSTALLED_APPS中添加rest_framework。

然后改进从本地CSV文件中导入数据一节中所定义的music应用。还需要转存由Django REST framework所提供的静态文件,它们是由 DRF 提供美化页面显示的:

(env)$ python manage.py collectstatic

如何实现...

在music应用中集成新的RESTful API,执行如下步骤:

  1. 在配置文件中添加Django REST framework的配置,如下所示:

    # myproject/settings/_base.py
    REST_FRAMEWORK = {
        "DEFAULT_PERMISSION_CLASSES": [ 
            "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly" 
        ],
        "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", 
        "PAGE_SIZE": 50,
    }
    
  2. 在music应用中,使用如下内容新建serializers.py文件:

    from rest_framework import serializers 
    from .models import Song
    
    class SongSerializer(serializers.ModelSerializer): 
        class Meta:
            model = Song
            fields = ["uuid", "artist", "title", "url", "image"]
    
  3. 在music应用的views.py文件中新增两个类视图:

    from rest_framework import generics
    
    from .serializers import SongSerializer
    from .models import Song
    
    #...
    
    class RESTSongList(generics.ListCreateAPIView): 
        queryset = Song.objects.all() 
        serializer_class = SongSerializer
    
        def get_view_name(self):
            return "Song List"
    
    
    class RESTSongDetail(generics.RetrieveUpdateDestroyAPIView): 
        queryset = Song.objects.all()
        serializer_class = SongSerializer
        
        def get_view_name(self):
            return "Song Detail"
    
  4. 最后,在项目的URL配置中添加新视图:

    # myproject/urls.py
    from django.urls import include, path
    from myproject.apps.music.views import RESTSongList, RESTSongDetail
    
    urlpatterns = [
        path("api-auth/", include("rest_framework.urls",
        namespace="rest_framework")), 
        path("rest-api/songs/", 
            RESTSongList.as_view(),
            name="rest_song_list"
        ), 
        path(
            "rest-api/songs/<uuid:pk>/", 
            RESTSongDetail.as_view(), 
            name="rest_song_detail"
        ),
        #... 
    ]
    

实现原理...

这里我们创建的是一个音乐API,通过它可以读取一个分页歌单,新建歌单以及根据ID读取、修改或删除一首单曲。读取不需要进行身份认证,但需要有相应权限的用户账号来添加、修改或删除一首歌。DRF 提供了一个网页API文档,通过GET在浏览器中访问 API 端点时会显示。未登录时框架所显示的内容如下:

TODO

下面是访问所创建 API 的方式:

[table id=62 /]

你可能想知道如何使用API。例如,我们可能会使用requests库来通过Python脚本新建一首歌,如下:

import requests

response = requests.post( 
    url="http://127.0.0.1:8000/rest-api/songs/", data={
        "artist": "Luwten",
        "title": "Go Honey",
    },
    auth=("admin", "<YOUR_ADMIN_PASSWORD>"), 
)
assert(response.status_code == requests.codes.CREATED)

通过Postman应用可以实际同样的操作,它的请求提交界面对用户更为友好,如下所示:

TODO

也可以在登录后通过框架生成的API文档中的集成表单测试API,如下图所示:

TODO

我们来快速查看所编写代码的运行方式。在配置中,所设置的访问权限依赖于Django系统权限。对于匿名请求仅允许读操作。其它访问方式有允许任何人进行所有操作,仅允许认证身份用户进行所有操作,仅允许员工进行所有操作等等。完整列表请见https://www.django-rest-framework.org/api-guide/permissions/。

然后在配置文件中设置了分页。当前选项是像SQL查询一样的限制和偏移量。其它选项有通过页码获取静态内容或通过游标分页获取实时数据。我们对每页内容默认设置为50条。

再后,我们为歌曲定义了一个serializer。它控制在输出中显示的数据并验证输入。在 DRF 中有很多种序列化关联的方式,在本例中选择了一种最不精简的方式。

ℹ️要学习更多有关序列化关联的知识,参见文档https://www.django-rest-framework.org/api-guide/relations/。

定义好序列化器之后,我们创建了两个类视图来处理API端点,并将它们添加到了URL配置中。在URL配置中,我们还为浏览API页面、登入和登出建立的规则(/api-auth/)。

相关内容

  • 为搜索引擎准备分页站点地图一
  • 创建可过滤的RSS feed一节
  • 第11章 测试中的测试Django REST framework所创建的API一节