1. 过滤文章

用户需要某个特定范围的文章时,后端需要把返回的数据进行过滤。最简单的过滤方法是修改视图集中的queryset属性:

# article/views.py

from rest_framework import viewsets

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article
from article.serializers import ArticleSerializer


class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.filter(author__username='admin')
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

def perform_create(self, serializer):
serializer.save(author=self.request.user)

这样会导致原本正常的列表也都过滤了

1.1 参数过滤

假设有如下带有参数 GET 的请求:

http://127.0.0.1:8000/api/article/?username=admin

可以通过覆写get_queryset()的方式实现过滤:

# article/views.py

from rest_framework import viewsets

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article
from article.serializers import ArticleSerializer


class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

def perform_create(self, serializer):
serializer.save(author=self.request.user)

def get_queryset(self):
queryset = self.queryset
username = self.request.query_params.get('username', None)

if username is not None:
queryset = queryset.filter(author__username=username)

return queryset

1.2 通用过滤

django-filter库可以用于通用过滤,要使用这个库,首先执行pip install django-filter==23.1安装,然后修改配置文件:

# backend/settings.py
...
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'article',
'user_info',
'django_filters',
]
...


REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 2,
'DEFAULT_FILTER_BACKENDS': ['django-filter.rest_framework.DjangoFilterBackend'],
}
# article/views.py

from rest_framework import viewsets

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article
from article.serializers import ArticleSerializer


class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

filterset_fields = ['author__username', 'title']

def perform_create(self, serializer):
serializer.save(author=self.request.user)

也可以将其单独配置在特定的视图中,不在setting.py中配置 REST_FRAMEWORK 相关内容:

# article/views.py

from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article
from article.serializers import ArticleSerializer


class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

filter_backends = [DjangoFilterBackend]
filterset_fields = ['author__username', 'title']

def perform_create(self, serializer):
serializer.save(author=self.request.user)

完全匹配示例url:http://127.0.0.1:8000/api/article/?author__username=dusai&title=newtest

如果要实现模糊搜索,可以使用SearchFilter来做。

# article/views.py
from rest_framework import filters, viewsets

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article
from article.serializers import ArticleSerializer


class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

filter_backends = [filters.SearchFilter]
search_fields = ['title']

def perform_create(self, serializer):
serializer.save(author=self.request.user)

模糊搜索示例url:http://127.0.0.1:8000/api/article/?search=post

2. 文章分类

博客文章通常需要分类,方便用户快速识别文章的类型,或者进行某种关联操作。

2.1 增加模型

首先在article/models.py增加一个分类的模型,并且将其和博文称为一对多的外键:

# article/models.py

from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User


class Category(models.Model):
"""
文章分类
"""
title = models.CharField(max_length=100)
created = models.DateTimeField(default=timezone.now

class Meta:
ordering = ['-created']

def __str__(self):
return self.title


class Article(models.Model):
"""
博客文章
"""
title = models.CharField(max_length=100) # 标题
category = models.ForeignKey(
Category,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='articles'
) # 分类
author = models.ForeignKey(
User,
null=True,
on_delete=models.CASCADE,
related_name="articles"
) # 作者
body = models.TextField() # 正文
created = models.DateTimeField(default=timezone.now) # 创建时间
updated = models.DateTimeField(auto_now=True) # 更新时间

class Meta:
ordering = ['-created']

def __str__(self):
return self.title


进行数据迁移:

(venv) > python manage.py makemigrations
(venv) > python manage.py migrate

教程把分类的 model 放到 article app中了。实际项目应根据情况考虑是否需要另起一个单独的分类 app。

2.2 序列化器

编写和修改序列化器:

# article/serializers.py

from rest_framework import serializers
from article.models import Article, Category
from user_info.serializers import UserDescSerializer


class CategorySerializer(serializers.ModelSerializer):
"""
分类序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='category-detail')

class Meta:
model = Category
fields = '__all__'
read_only_fields = ['created']


class ArticleSerializer(serializers.HyperlinkedModelSerializer):
"""
文章序列化器
"""
author = UserDescSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)

def validate_category_id(self, value):
if not Category.objects.filter(id=value).exists() and value is not None:
raise serializers.ValidationError(f"Category with id {value} not exits.")
return value

class Meta:
model = Article
fields = '__all__'

CategorySerializer

  • HyperlinkedIdentityField 前面章节有讲过,作用是将路由间的表示转换为超链接。view_name 参数是路由名,你必须显示指定。 category-detail 是自动注册路由时,Router 默认帮你设置的详情页面的名称,类似的还有 category-list 等,更多规则参考文档
  • 创建日期不需要后期修改,所以设置为 read_only_fields

ArticleSerializer

  • 文章接口不仅仅只返回分类的 id ,需要显式指定 category ,将其变成一个嵌套数据,与之前的 author 类似。
  • DRF 框架原生没有实现可写的嵌套数据,想创建/更新文章和分类的外键关系时:一种方法是自己去实现序列化器的 create()/update() 方法;另一种就是 DRF 框架提供的修改外键的快捷方式,即显式指定 category_id 字段,则此字段会自动链接到 category 外键,以便你更新外键关系。
  • 再看 category_id 内部。write_only 表示此字段仅需要可写;allow_null 表示允许将其设置为空;required 表示在创建/更新时可以不设置此字段。

如果用户提交了一个不存在的分类外键,后端会返回外键数据不存在的 500 错误,解决方法就是对数据预先进行验证

验证方式又有如下几种:

  • 覆写序列化器的 .validate(...) 方法。这是个全局的验证器,其接收的唯一参数是所有字段值的字典。当你需要同时对多个字段进行验证时,这是个很好的选择。
  • 另一种就是教程用到的,即 .validate_{field_name}(...) 方法,它会只验证某个特定的字段,比如 category_id

validate_category_id 检查了两样东西:

  • 数据库中是否包含了对应 id 值的数据。
  • 传入值是否为 None。这是为了能够将已有的外键置空。

2.3 视图与路由

编写视图:

# article/views.py

from rest_framework import viewsets, filters

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article, Category
from article.serializers import ArticleSerializer, CategorySerializer


class CategoryViewSet(viewsets.ModelViewSet):
"""
分类视图集
"""
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [IsAdminUserOrReadOnly]


class ArticleViewSet(viewsets.ModelViewSet):
"""
文章视图集
"""
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

filter_backends = [filters.SearchFilter]
search_fields = ['title']

def perform_create(self, serializer):
serializer.save(author=self.request.user)

编写路由:

# backend/urls.py

from django.urls import path, include
from rest_framework.routers import DefaultRouter
from article import views


router = DefaultRouter()
router.register(r'article', views.ArticleViewSet)
router.register(r'category', views.CategoryViewSet)

urlpatterns = [
path('api/', include(router.urls)),
]

2.4 测试

# 1.创建分类
(venv) > http -a admin:admin POST http://127.0.0.1:8000/api/category/ title=Django
HTTP/1.1 201 Created
Allow: GET, POST, HEAD, OPTIONS
Connection: close
Content-Length: 116
Content-Type: application/json
Cross-Origin-Opener-Policy: same-origin
Date: Mon, 27 Mar 2023 15:52:17 GMT
Location: http://127.0.0.1:8000/api/category/2/
Referrer-Policy: same-origin
Server: WSGIServer/0.2 CPython/3.10.6
Vary: Accept, Cookie
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
"created": "2023-03-27T23:52:17.604118+08:00",
"id": 2,
"title": "Django",
"url": "http://127.0.0.1:8000/api/category/2/"
}

# 2.更新已有分类
(venv) > http -a admin:admin PUT http://127.0.0.1:8000/api/category/2/ title=Flask
HTTP/1.1 200 OK
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
Connection: close
Content-Length: 115
Content-Type: application/json
Cross-Origin-Opener-Policy: same-origin
Date: Mon, 27 Mar 2023 15:53:12 GMT
Referrer-Policy: same-origin
Server: WSGIServer/0.2 CPython/3.10.6
Vary: Accept, Cookie
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
"created": "2023-03-27T23:52:17.604118+08:00",
"id": 2,
"title": "Flask",
"url": "http://127.0.0.1:8000/api/category/2/"
}

# 3.创建文章时指定分类
(venv) > http -a admin:admin POST http://127.0.0.1:8000/api/article/ category_id=2 title=ILoveDRF body=WishYouToo!
HTTP/1.1 201 Created
Allow: GET, POST, HEAD, OPTIONS
Connection: close
Content-Length: 437
Content-Type: application/json
Cross-Origin-Opener-Policy: same-origin
Date: Mon, 27 Mar 2023 15:54:05 GMT
Location: http://127.0.0.1:8000/api/article/9/
Referrer-Policy: same-origin
Server: WSGIServer/0.2 CPython/3.10.6
Vary: Accept, Cookie
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
"author": {
"date_joined": "2023-03-27T02:08:41.097951+08:00",
"id": 1,
"last_login": "2023-03-27T23:24:54.504055+08:00",
"username": "admin"
},
"body": "WishYouToo!",
"category": {
"created": "2023-03-27T23:52:17.604118+08:00",
"id": 2,
"title": "Flask",
"url": "http://127.0.0.1:8000/api/category/2/"
},
"created": "2023-03-27T23:54:05.550915+08:00",
"title": "ILoveDRF",
"updated": "2023-03-27T23:54:05.550915+08:00",
"url": "http://127.0.0.1:8000/api/article/9/"
}

# 4.把已有的分类置空
(venv) > http -a admin:admin PATCH http://127.0.0.1:8000/api/article/9/ category_id:=null
HTTP/1.1 200 OK
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
Connection: close
Content-Length: 326
Content-Type: application/json
Cross-Origin-Opener-Policy: same-origin
Date: Mon, 27 Mar 2023 15:55:26 GMT
Referrer-Policy: same-origin
Server: WSGIServer/0.2 CPython/3.10.6
Vary: Accept, Cookie
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
"author": {
"date_joined": "2023-03-27T02:08:41.097951+08:00",
"id": 1,
"last_login": "2023-03-27T23:24:54.504055+08:00",
"username": "admin"
},
"body": "WishYouToo!",
"category": null,
"created": "2023-03-27T23:54:05.550915+08:00",
"title": "ILoveDRF",
"updated": "2023-03-27T23:55:26.915902+08:00",
"url": "http://127.0.0.1:8000/api/article/9/"
}

在更新资源时用到了 POSTPUTPATCH 三种请求方法

  • POST :创建新的资源。
  • PUT : 整体更新特定资源,默认情况下你需要完整给出所有必须的字段。
  • PATCH: 部分更新特定资源,仅需要给出需要更新的字段,未给出的字段默认不更改。

2.5 完善分类详情

现在希望分类的列表页面不显示其链接的文章,以保持数据简洁,但是详情页面则展示出链接的所有文章,方便接口的使用。因此就需要同一个视图集用到两个不同的序列化器,可以使用 get_serializer_class()

# article/serializers.py

from rest_framework import serializers
from article.models import Article, Category
from user_info.serializers import UserDescSerializer


class CategorySerializer(serializers.ModelSerializer):
"""
分类序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='category-detail')

class Meta:
model = Category
fields = '__all__'
read_only_fields = ['created']


class ArticleSerializer(serializers.HyperlinkedModelSerializer):
"""
文章序列化器
"""
author = UserDescSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)

def validate_category_id(self, value):
if not Category.objects.filter(id=value).exists() and value is not None:
raise serializers.ValidationError(f"Category with id {value} not exits.")
return value

class Meta:
model = Article
fields = '__all__'


class ArticleCategoryDetailSerializer(serializers.ModelSerializer):
"""
给分类详情的文章嵌套序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='article-detail')

class Meta:
model = Category
fields = [
'url',
'title',
]


class CategoryDetailSerializer(serializers.ModelSerializer):
"""
分类详情
"""
articles = ArticleCategoryDetailSerializer(many=True, read_only=True)

class Meta:
model = Category
fields = [
'id',
'title',
'created',
'articles',
]

然后修改视图:

# article/views.py

from rest_framework import viewsets, filters

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article, Category
from article.serializers import ArticleSerializer, CategorySerializer, CategoryDetailSerializer


class CategoryViewSet(viewsets.ModelViewSet):
"""
分类视图集
"""
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [IsAdminUserOrReadOnly]

def get_serializer_class(self):
if self.action == 'list':
return CategorySerializer
else:
return CategoryDetailSerializer


class ArticleViewSet(viewsets.ModelViewSet):
"""
文章视图集
"""
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

filter_backends = [filters.SearchFilter]
search_fields = ['title']

def perform_create(self, serializer):
serializer.save(author=self.request.user)

3. 文章标签

文章通常还有标签功能,作为分类的补充。分类对文章一般是一对多的关系,标签对文章时多对多的关系。

3.1 增加模型

先创建标签的 model 并进行数据迁移

# article/models.py

from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User


class Tag(models.Model):
"""
文章标签
"""
text = models.CharField(max_length=30)

class Meta:
ordering = ['-id']

def __str__(self):
return self.text


class Category(models.Model):
"""
文章分类
"""
title = models.CharField(max_length=100)
created = models.DateTimeField(default=timezone.now)

class Meta:
ordering = ['-created']

def __str__(self):
return self.title


class Article(models.Model):
"""
博客文章
"""
title = models.CharField(max_length=100) # 标题
category = models.ForeignKey(
Category,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='articles'
) # 分类
tags = models.ManyToManyField(
Tag,
blank=True,
related_name='articles'
) # 标签
author = models.ForeignKey(
User,
null=True,
on_delete=models.CASCADE,
related_name="articles"
) # 作者
body = models.TextField() # 正文
created = models.DateTimeField(default=timezone.now) # 创建时间
updated = models.DateTimeField(auto_now=True) # 更新时间

class Meta:
ordering = ['-created']

def __str__(self):
return self.title

进行数据迁移:

(venv) > python manage.py makemigrations
(venv) > python manage.py migrate

3.2 序列化器

# article/serializers.py

from rest_framework import serializers
from article.models import Article, Category, Tag
from user_info.serializers import UserDescSerializer


class TagSerializer(serializers.HyperlinkedIdentityField):
"""
标签序列化器
"""
class Meta:
model = Tag
fields = '__all__'


class CategorySerializer(serializers.ModelSerializer):
"""
分类序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='category-detail')

class Meta:
model = Category
fields = '__all__'
read_only_fields = ['created']


class ArticleSerializer(serializers.HyperlinkedModelSerializer):
"""
文章序列化器
"""
author = UserDescSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
tag = serializers.SlugRelatedField(query_set=Tag.objects.all(), many=True, required=False, slug_field='text')

def validate_category_id(self, value):
if not Category.objects.filter(id=value).exists() and value is not None:
raise serializers.ValidationError(f"Category with id {value} not exits.")
return value

class Meta:
model = Article
fields = '__all__'


class ArticleCategoryDetailSerializer(serializers.ModelSerializer):
"""
给分类详情的文章嵌套序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='article-detail')

class Meta:
model = Category
fields = [
'url',
'title',
]


class CategoryDetailSerializer(serializers.ModelSerializer):
"""
分类详情
"""
articles = ArticleCategoryDetailSerializer(many=True, read_only=True)

class Meta:
model = Category
fields = [
'id',
'title',
'created',
'articles',
]

SlugRelatedField 直接显示其 text 字段的内容。

多对多关系,DRF 默认你必须先得有这个外键对象,才能指定其关系。

现在希望在创建、更新文章时,程序会自动检查数据库里是否存在当前标签。如果存在则指向它,如果不存在则创建一个并指向它。可以覆写to_internal_value()方法。to_internal_value() 方法原本作用是将请求中的原始 Json 数据转化为 Python 表示形式(期间还会对字段有效性做初步检查)。它的执行时间比默认验证器的字段检查更早,因此有机会在此方法中将需要的数据创建好,然后等待检查的降临。isinstance() 确定标签数据是列表,才会循环并创建新数据。

除此之外,因为标签仅有 text 字段是有用的,两个 id 不同但是 text 相同的标签没有任何意义。更重要的是,SlugRelatedField 是不允许有重复的 slug_field 。因此还需要覆写 TagSerializercreate()/update() 方法。

# article/serializers.py

from rest_framework import serializers
from article.models import Article, Category, Tag
from user_info.serializers import UserDescSerializer


class TagSerializer(serializers.HyperlinkedModelSerializer):
"""
标签序列化器
"""
def check_tag_obj_exists(self, validated_data):
text = validated_data.get('text')
if Tag.objects.filter(text=text).exists():
raise serializers.ValidationError(f'Tag with text {text} exists.')

def create(self, validated_data):
self.check_tag_obj_exists(validated_data)
return super().create(validated_data)

def update(self, instance, validated_data):
self.check_tag_obj_exists(validated_data)
return super().update(instance, validated_data)

class Meta:
model = Tag
fields = '__all__'


class CategorySerializer(serializers.ModelSerializer):
"""
分类序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='category-detail')

class Meta:
model = Category
fields = '__all__'
read_only_fields = ['created']


class ArticleSerializer(serializers.HyperlinkedModelSerializer):
"""
文章序列化器
"""
author = UserDescSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
tags = serializers.SlugRelatedField(queryset=Tag.objects.all(), many=True, required=False, slug_field='text')

def validate_category_id(self, value):
if not Category.objects.filter(id=value).exists() and value is not None:
raise serializers.ValidationError(f"Category with id {value} not exits.")
return value

def to_internal_value(self, data):
tags_data = data.get('tags')

if isinstance(tags_data, list):
for text in tags_data:
if not Tag.objects.filter(text=text).exists():
Tag.objects.create(text=text)
return super().to_internal_value(data)


class Meta:
model = Article
fields = '__all__'


class ArticleCategoryDetailSerializer(serializers.ModelSerializer):
"""
给分类详情的文章嵌套序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='article-detail')

class Meta:
model = Category
fields = [
'url',
'title',
]


class CategoryDetailSerializer(serializers.ModelSerializer):
"""
分类详情
"""
articles = ArticleCategoryDetailSerializer(many=True, read_only=True)

class Meta:
model = Category
fields = [
'id',
'title',
'created',
'articles',
]

3.3 视图与路由

编写视图:

# article/views.py


from rest_framework import viewsets, filters

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article, Category, Tag
from article.serializers import ArticleSerializer, CategorySerializer, CategoryDetailSerializer, TagSerializer


class TagViewSet(viewsets.ModelViewSet):
"""
标签视图集
"""
queryset = Tag.objects.all()
serializer_class = TagSerializer
permission_classes = [IsAdminUserOrReadOnly]


class CategoryViewSet(viewsets.ModelViewSet):
"""
分类视图集
"""
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [IsAdminUserOrReadOnly]

def get_serializer_class(self):
if self.action == 'list':
return CategorySerializer
else:
return CategoryDetailSerializer


class ArticleViewSet(viewsets.ModelViewSet):
"""
文章视图集
"""
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

filter_backends = [filters.SearchFilter]
search_fields = ['title']

def perform_create(self, serializer):
serializer.save(author=self.request.user)

编写路由:

# backend/urls.py

from django.urls import path, include
from rest_framework.routers import DefaultRouter
from article import views


router = DefaultRouter()
router.register(r'article', views.ArticleViewSet)
router.register(r'category', views.CategoryViewSet)
router.register(r'tag', views.TagViewSet)

urlpatterns = [
path('api/', include(router.urls)),
]

当然也可以简化代码,可以直接设置 tag 模型的 text 字段唯一, 即unique=True,然后执行数据迁移,再简化 Tag 的序列化器中唯一性的检查:

# article/models.py

from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User


class Tag(models.Model):
"""
文章标签
"""
text = models.CharField(max_length=30, unique=True)

class Meta:
ordering = ['-id']

def __str__(self):
return self.text


class Category(models.Model):
"""
文章分类
"""
title = models.CharField(max_length=100)
created = models.DateTimeField(default=timezone.now)

class Meta:
ordering = ['-created']

def __str__(self):
return self.title


class Article(models.Model):
"""
博客文章
"""
title = models.CharField(max_length=100) # 标题
category = models.ForeignKey(
Category,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='articles'
) # 分类
tags = models.ManyToManyField(
Tag,
blank=True,
related_name='articles'
) # 标签
author = models.ForeignKey(
User,
null=True,
on_delete=models.CASCADE,
related_name="articles"
) # 作者
body = models.TextField() # 正文
created = models.DateTimeField(default=timezone.now) # 创建时间
updated = models.DateTimeField(auto_now=True) # 更新时间

class Meta:
ordering = ['-created']

def __str__(self):
return self.title


# article/serializers.py

from rest_framework import serializers
from article.models import Article, Category, Tag
from user_info.serializers import UserDescSerializer


class TagSerializer(serializers.HyperlinkedModelSerializer):
"""
标签序列化器
"""
class Meta:
model = Tag
fields = '__all__'


class CategorySerializer(serializers.ModelSerializer):
"""
分类序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='category-detail')

class Meta:
model = Category
fields = '__all__'
read_only_fields = ['created']


class ArticleSerializer(serializers.HyperlinkedModelSerializer):
"""
文章序列化器
"""
author = UserDescSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
tags = serializers.SlugRelatedField(queryset=Tag.objects.all(), many=True, required=False, slug_field='text')

def validate_category_id(self, value):
if not Category.objects.filter(id=value).exists() and value is not None:
raise serializers.ValidationError(f"Category with id {value} not exits.")
return value

def to_internal_value(self, data):
tags_data = data.get('tags')

if isinstance(tags_data, list):
for text in tags_data:
if not Tag.objects.filter(text=text).exists():
Tag.objects.create(text=text)
return super().to_internal_value(data)

def create(self, validated_data):
category_id = validated_data.pop('category_id')
validated_data['category'] = Category.objects.get(id=category_id)
tags = Tag.objects.filter(text__in=validated_data['tags'])
validated_data.pop('tags')
article = Article.objects.create(**validated_data)
article.tags.set(tags)
return article


class Meta:
model = Article
fields = '__all__'


class ArticleCategoryDetailSerializer(serializers.ModelSerializer):
"""
给分类详情的文章嵌套序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='article-detail')

class Meta:
model = Category
fields = [
'url',
'title',
]


class CategoryDetailSerializer(serializers.ModelSerializer):
"""
分类详情
"""
articles = ArticleCategoryDetailSerializer(many=True, read_only=True)

class Meta:
model = Category
fields = [
'id',
'title',
'created',
'articles',
]

4. Markdown正文

Markdown 是一种排版标注规则,”渲染“ Markdown 也就是把原始文本中的注释转化为前端中真正被用户看到的 HTML 排版文字。渲染过程可以在前端也可以在后端,本文将使用后端渲染,以便你理解 DRF 的相关知识。

1.1 修改模型

markdown库可Markdown渲染,要使用这个库,首先执行pip install markdown==3.4.3, 给文章模型添加一个 get_md() 方法:

# article/models.py

from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User
from markdown import Markdown


class Tag(models.Model):
"""
文章标签
"""
text = models.CharField(max_length=30, unique=True)

class Meta:
ordering = ['-id']

def __str__(self):
return self.text


class Category(models.Model):
"""
文章分类
"""
title = models.CharField(max_length=100)
created = models.DateTimeField(default=timezone.now)

class Meta:
ordering = ['-created']

def __str__(self):
return self.title


class Article(models.Model):
"""
博客文章
"""
title = models.CharField(max_length=100) # 标题
category = models.ForeignKey(
Category,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='articles'
) # 分类
tags = models.ManyToManyField(
Tag,
blank=True,
related_name='articles'
) # 标签
author = models.ForeignKey(
User,
null=True,
on_delete=models.CASCADE,
related_name="articles"
) # 作者
body = models.TextField() # 正文
created = models.DateTimeField(default=timezone.now) # 创建时间
updated = models.DateTimeField(auto_now=True) # 更新时间

class Meta:
ordering = ['-created']

def __str__(self):
return self.title

def get_md(self):
md = Markdown(
extensions=[
'markdown.extensions.extra',
'markdown.extensions.codehilite',
'markdown.extensions.toc',
]
)
md_body = md.convert(self.body)

return md_body, md.toc

方法返回了包含了两个元素的元组,分别为已渲染为 html 的正文目录

1.2 序列化器

重构代码,添加文章序列化器父类,在列表接口传入 extra_kwargs 使其变成仅可写却不显示的字段,然后写新的 ArticleDetailSerializer

# article/serializers.py

from rest_framework import serializers
from article.models import Article, Category, Tag
from user_info.serializers import UserDescSerializer


class TagSerializer(serializers.HyperlinkedModelSerializer):
"""
标签序列化器
"""
class Meta:
model = Tag
fields = '__all__'


class CategorySerializer(serializers.ModelSerializer):
"""
分类序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='category-detail')

class Meta:
model = Category
fields = '__all__'
read_only_fields = ['created']


class ArticleBaseSerializer(serializers.HyperlinkedModelSerializer):
"""
文章序列化器父类
"""
author = UserDescSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
tags = serializers.SlugRelatedField(queryset=Tag.objects.all(), many=True, required=False, slug_field='text')

def validate_category_id(self, value):
if not Category.objects.filter(id=value).exists() and value is not None:
raise serializers.ValidationError(f"Category with id {value} not exits.")
return value

def to_internal_value(self, data):
tags_data = data.get('tags')

if isinstance(tags_data, list):
for text in tags_data:
if not Tag.objects.filter(text=text).exists():
Tag.objects.create(text=text)
return super().to_internal_value(data)

def create(self, validated_data):
category_id = validated_data.pop('category_id')
validated_data['category'] = Category.objects.get(id=category_id)
tags = Tag.objects.filter(text__in=validated_data['tags'])
validated_data.pop('tags')
article = Article.objects.create(**validated_data)
article.tags.set(tags)
return article


class ArticleSerializer(ArticleBaseSerializer):
"""
文章列表序列化器
"""

class Meta:
model = Article
fields = '__all__'
extra_kwargs = {'body': {'write_only': True}}


class ArticleDetailSerializer(ArticleBaseSerializer):
"""
文章详情序列化器
"""
body_html = serializers.SerializerMethodField()
toc_html = serializers.SerializerMethodField()

def get_body_html(self, obj):
return obj.get_md()[0]

def get_toc_html(self, obj):
return obj.get_md()[1]

class Meta:
model = Article
fields = '__all__'


class ArticleCategoryDetailSerializer(serializers.ModelSerializer):
"""
给分类详情的文章嵌套序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='article-detail')

class Meta:
model = Category
fields = [
'url',
'title',
]


class CategoryDetailSerializer(serializers.ModelSerializer):
"""
分类详情
"""
articles = ArticleCategoryDetailSerializer(many=True, read_only=True)

class Meta:
model = Category
fields = [
'id',
'title',
'created',
'articles',
]

body_htmltoc_html 这两个渲染后的字段是经过加工后的数据,不存在于原始的数据中。为了将这类只读的附加字段添加到接口里,可以用SerializerMethodField() 字段。比如说上面代码中的 body_html 字段,它会自动去调用 get_body_html() 方法,并将其返回结果作为需要序列化的数据。方法中的 obj 参数是序列化器获取到的 model 实例,即文章对象。

1.3 修复视图

渲染后的数据,在文章详情接口是需要的,但是在列表接口却没必要,因此视图集根据请求方式动态获取序列化器:

# article/views.py

from rest_framework import viewsets, filters

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article, Category, Tag
from article.serializers import ArticleSerializer, CategorySerializer, CategoryDetailSerializer, TagSerializer, ArticleDetailSerializer


class TagViewSet(viewsets.ModelViewSet):
"""
标签视图集
"""
queryset = Tag.objects.all()
serializer_class = TagSerializer
permission_classes = [IsAdminUserOrReadOnly]


class CategoryViewSet(viewsets.ModelViewSet):
"""
分类视图集
"""
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [IsAdminUserOrReadOnly]

def get_serializer_class(self):
if self.action == 'list':
return CategorySerializer
else:
return CategoryDetailSerializer


class ArticleViewSet(viewsets.ModelViewSet):
"""
文章视图集
"""
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

filter_backends = [filters.SearchFilter]
search_fields = ['title']

def perform_create(self, serializer):
serializer.save(author=self.request.user)

def get_serializer_class(self):
if self.action == 'list':
return ArticleSerializer
else:
return ArticleDetailSerializer

5. 文章标题图

博客可能会有文件的上传与下载,但是 JSON 格式的载体是字符串,不能够直接处理文件流。

Django 用 multipart/form-data 表单发送夹杂着元数据的文件,DRF中也可以这样做,这种方法可行,但在主要接口中发送编码文不太好,还有其他方法:

  • 方法1:用 Base64 对文件进行编码(将文件变成字符串)。这种方法简单粗暴,并且只靠 Json 接口就可以实现。代价是数据传输大小增加了约 33%,并在服务器和客户端中增加了编码/解码的开销。
  • 方法2:首先在 multipart/form-data 中单独发送文件,然后后端将保存好的文件 id 返回给客户端。客户端拿到文件 id 后,发送带有文件 id 的 Json 数据,在服务器端将它们关联起来。
  • 方法3:首先单独发送 Json 数据,然后后端保存好这些元数据后将其 id 返回给客户端。接着客户端发送带有元数据 id 的文件,在服务器端将它们关联起来。

三种方法各有优劣,具体用哪种方法应当视实际情况确定。

本文将使用第二种方法来实现博文标题图的功能。

5.1 增加模型

Pillow库可用于处理图片字段,要使用这个库,首先执行pip install Pillow==9.4.0,添加标题图模型:

# article/models.py

from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User
from markdown import Markdown


class Avatar(models.Model):
content = models.ImageField(upload_to='avatar/%Y%m%d')


class Tag(models.Model):
"""
文章标签
"""
text = models.CharField(max_length=30, unique=True)

class Meta:
ordering = ['-id']

def __str__(self):
return self.text


class Category(models.Model):
"""
文章分类
"""
title = models.CharField(max_length=100)
created = models.DateTimeField(default=timezone.now)

class Meta:
ordering = ['-created']

def __str__(self):
return self.title


class Article(models.Model):
"""
博客文章
"""
title = models.CharField(max_length=100) # 标题
avatar = models.ForeignKey(
Avatar,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='article'
)
category = models.ForeignKey(
Category,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='articles'
) # 分类
tags = models.ManyToManyField(
Tag,
blank=True,
related_name='articles'
) # 标签
author = models.ForeignKey(
User,
null=True,
on_delete=models.CASCADE,
related_name="articles"
) # 作者
body = models.TextField() # 正文
created = models.DateTimeField(default=timezone.now) # 创建时间
updated = models.DateTimeField(auto_now=True) # 更新时间

class Meta:
ordering = ['-created']

def __str__(self):
return self.title

def get_md(self):
md = Markdown(
extensions=[
'markdown.extensions.extra',
'markdown.extensions.codehilite',
'markdown.extensions.toc',
]
)
md_body = md.convert(self.body)

return md_body, md.toc

Avatar 模型仅包含一个图片字段。接收的图片将保存在 media/avatar/年月日/ 的路径中。

执行迁移:

(venv) > python manage.py makemigrations
(venv) > python manage.py migrate

5.2 序列化器

用户的操作流程如下:

  • 发表新文章时,标题图需要先上传,添加一个单独的序列化器,DRF 对图片的处理进行了封装,通常不需要你关心实现的细节。
  • 标题图上传完成会返回其数据(比如图片数据的 id)到前端并暂存,前端将图片的信息以嵌套结构表示到文章接口中,并在适当的时候将其链接到文章数据中,等待新文章完成后一起提交。
  • 提交新文章时,序列化器对标题图进行检查,如果无效则返回错误信息。
# article/serializers.py

from rest_framework import serializers
from article.models import Article, Category, Tag, Avatar
from user_info.serializers import UserDescSerializer


class AvatarSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='avatar-detail')

class Meta:
model = Avatar
fields = '__all__'


class TagSerializer(serializers.HyperlinkedModelSerializer):
"""
标签序列化器
"""
class Meta:
model = Tag
fields = '__all__'


class CategorySerializer(serializers.ModelSerializer):
"""
分类序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='category-detail')

class Meta:
model = Category
fields = '__all__'
read_only_fields = ['created']


class ArticleBaseSerializer(serializers.HyperlinkedModelSerializer):
"""
文章序列化器父类
"""
author = UserDescSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
tags = serializers.SlugRelatedField(queryset=Tag.objects.all(), many=True, required=False, slug_field='text')
avatar = AvatarSerializer(read_only=True)
avatar_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)

default_error_messages = {
'incorrect_avatar_id': 'Avatar with id {value} not exits.',
'incorrect_category_id': 'Category with id {value} not exits.',
'default': 'No more message here...'
}

def check_obj_exists_or_fail(self, model, value, message='default'):
if not self.default_error_messages.get(message, None):
message = 'default'

if not model.objects.filter(id=value).exists() and value is not None:
self.fail(message, value=value)

def validate_category_id(self, value):
self.check_obj_exists_or_fail(model=Category, value=value, message='incorrect_category_id')
return value

def validate_avatar_id(self, value):
self.check_obj_exists_or_fail(model=Avatar, value=value, message='incorrect_avatar_id')
return value

def to_internal_value(self, data):
tags_data = data.get('tags')

if isinstance(tags_data, list):
for text in tags_data:
if not Tag.objects.filter(text=text).exists():
Tag.objects.create(text=text)
return super().to_internal_value(data)

def create(self, validated_data):
category_id = validated_data.pop('category_id')
validated_data['category'] = Category.objects.get(id=category_id)
tags = Tag.objects.filter(text__in=validated_data['tags'])
validated_data.pop('tags')
article = Article.objects.create(**validated_data)
article.tags.set(tags)
return article


class ArticleSerializer(ArticleBaseSerializer):
"""
文章列表序列化器
"""

class Meta:
model = Article
fields = '__all__'
extra_kwargs = {'body': {'write_only': True}}


class ArticleDetailSerializer(ArticleBaseSerializer):
"""
文章详情序列化器
"""
body_html = serializers.SerializerMethodField()
toc_html = serializers.SerializerMethodField()

def get_body_html(self, obj):
return obj.get_md()[0]

def get_toc_html(self, obj):
return obj.get_md()[1]

class Meta:
model = Article
fields = '__all__'


class ArticleCategoryDetailSerializer(serializers.ModelSerializer):
"""
给分类详情的文章嵌套序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='article-detail')

class Meta:
model = Category
fields = [
'url',
'title',
]


class CategoryDetailSerializer(serializers.ModelSerializer):
"""
分类详情
"""
articles = ArticleCategoryDetailSerializer(many=True, read_only=True)

class Meta:
model = Category
fields = [
'id',
'title',
'created',
'articles',
]

5.3 视图与路由

编写视图:

# article/views.py

from rest_framework import viewsets, filters

from article.permissions import IsAdminUserOrReadOnly
from article.models import Article, Category, Tag, Avatar
from article.serializers import (
ArticleSerializer,
CategorySerializer,
CategoryDetailSerializer,
TagSerializer,
ArticleDetailSerializer,
AvatarSerializer,
)


class AvatarViewSet(viewsets.ModelViewSet):
"""
文章标题图视图集
"""
queryset = Avatar.objects.all()
serializer_class = AvatarSerializer
permission_classes = [IsAdminUserOrReadOnly]


class TagViewSet(viewsets.ModelViewSet):
"""
标签视图集
"""
queryset = Tag.objects.all()
serializer_class = TagSerializer
permission_classes = [IsAdminUserOrReadOnly]


class CategoryViewSet(viewsets.ModelViewSet):
"""
分类视图集
"""
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [IsAdminUserOrReadOnly]

def get_serializer_class(self):
if self.action == 'list':
return CategorySerializer
else:
return CategoryDetailSerializer


class ArticleViewSet(viewsets.ModelViewSet):
"""
文章视图集
"""
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsAdminUserOrReadOnly]

filter_backends = [filters.SearchFilter]
search_fields = ['title']

def perform_create(self, serializer):
serializer.save(author=self.request.user)

def get_serializer_class(self):
if self.action == 'list':
return ArticleSerializer
else:
return ArticleDetailSerializer

还需要修改配置文件,配置图片存放的路径

# backend/settings.py

...
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

最后编写路由:

# backend/urls.py

from django.conf import settings
from django.conf.urls.static import static
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from article import views


router = DefaultRouter()
router.register(r'article', views.ArticleViewSet)
router.register(r'category', views.CategoryViewSet)
router.register(r'tag', views.TagViewSet)
router.register(r'avatar', views.AvatarViewSet)

urlpatterns = [
path('api/', include(router.urls)),
]

if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

5.4 测试

Postman 操作文件接口需要将 Content-Type 改为 multipart/form-data ,并在 Body 中上传图片文件。

可浏览器打开http://127.0.0.1:8000/api/avatar/"进行测试

6. 评论

有很多方式可以将评论功能托管给第三方(推荐这么做),不过也可以自己实现简单的评论接口。

评论功能比较独立,因此新增一个 comment 的 App:

(venv) > python manage.py startapp comment

将App注册到配置文件

# backend/settings.py

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'article',
'user_info',
'django_filters',
'comment',
]

6.1 添加模型

# comment/models.py

from django.db import models
from django.utils import timezone

from article.models import Article
from django.contrib.auth.models import User


class Comment(models.Model):
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='comments'
)
article = models.ForeignKey(
Article,
on_delete=models.CASCADE,
related_name='comments'
)
content = models.TextField()
created = models.DateTimeField(default=timezone.now)

class Meta:
ordering = ['-created']

def __str__(self):
return self.content[:20]

模型包含一对多的作者外键、一对多的文章外键、评论实际内容、评论时间这4个字段。

执行 python manage.py makemigrationspython manage.py migrate

6.2 序列化器

# comment/serializers.py

from rest_framework import serializers

from comment.models import Comment
from user_info.serializers import UserDescSerializer


class CommentSerializer(serializers.ModelSerializer):
"""
评论序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='comment-detail')
author = UserDescSerializer(read_only=True)

class Meta:
model = Comment
fields = '__all__'
extra_kwargs = {'created': {'read_only': True}}

url 超链接字段让接口的跳转更方便,author 嵌套序列化器让显示的内容更丰富,让评论通过文章接口显示出来:

# article/serializers.py

from rest_framework import serializers
from article.models import Article, Category, Tag, Avatar
from user_info.serializers import UserDescSerializer
from comment.serializers import CommentSerializer


class AvatarSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='avatar-detail')

class Meta:
model = Avatar
fields = '__all__'


class TagSerializer(serializers.HyperlinkedModelSerializer):
"""
标签序列化器
"""
class Meta:
model = Tag
fields = '__all__'


class CategorySerializer(serializers.ModelSerializer):
"""
分类序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='category-detail')

class Meta:
model = Category
fields = '__all__'
read_only_fields = ['created']


class ArticleBaseSerializer(serializers.HyperlinkedModelSerializer):
"""
文章序列化器父类
"""
author = UserDescSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)
tags = serializers.SlugRelatedField(queryset=Tag.objects.all(), many=True, required=False, slug_field='text')
avatar = AvatarSerializer(read_only=True)
avatar_id = serializers.IntegerField(write_only=True, allow_null=True, required=False)

default_error_messages = {
'incorrect_avatar_id': 'Avatar with id {value} not exits.',
'incorrect_category_id': 'Category with id {value} not exits.',
'default': 'No more message here...'
}

def check_obj_exists_or_fail(self, model, value, message='default'):
if not self.default_error_messages.get(message, None):
message = 'default'

if not model.objects.filter(id=value).exists() and value is not None:
self.fail(message, value=value)

def validate_category_id(self, value):
self.check_obj_exists_or_fail(model=Category, value=value, message='incorrect_category_id')
return value

def validate_avatar_id(self, value):
self.check_obj_exists_or_fail(model=Avatar, value=value, message='incorrect_avatar_id')
return value

def to_internal_value(self, data):
tags_data = data.get('tags')

if isinstance(tags_data, list):
for text in tags_data:
if not Tag.objects.filter(text=text).exists():
Tag.objects.create(text=text)
return super().to_internal_value(data)

def create(self, validated_data):
category_id = validated_data.pop('category_id')
validated_data['category'] = Category.objects.get(id=category_id)
tags = Tag.objects.filter(text__in=validated_data['tags'])
validated_data.pop('tags')
article = Article.objects.create(**validated_data)
article.tags.set(tags)
return article


class ArticleSerializer(ArticleBaseSerializer):
"""
文章列表序列化器
"""

class Meta:
model = Article
fields = '__all__'
extra_kwargs = {'body': {'write_only': True}}


class ArticleDetailSerializer(ArticleBaseSerializer):
"""
文章详情序列化器
"""
id = serializers.IntegerField(read_only=True)
comments = CommentSerializer(many=True, read_only=True)
body_html = serializers.SerializerMethodField()
toc_html = serializers.SerializerMethodField()

def get_body_html(self, obj):
return obj.get_md()[0]

def get_toc_html(self, obj):
return obj.get_md()[1]

class Meta:
model = Article
fields = '__all__'


class ArticleCategoryDetailSerializer(serializers.ModelSerializer):
"""
给分类详情的文章嵌套序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='article-detail')

class Meta:
model = Category
fields = [
'url',
'title',
]


class CategoryDetailSerializer(serializers.ModelSerializer):
"""
分类详情
"""
articles = ArticleCategoryDetailSerializer(many=True, read_only=True)

class Meta:
model = Category
fields = [
'id',
'title',
'created',
'articles',
]

6.3 权限、视图与路由

评论对用户身份的要求比文章宽松,非安全请求只需要是本人操作就可以了。

因此自定义一个所有人都可查看、仅本人可更改的权限:

# comment/permissions.py

from rest_framework.permissions import BasePermission, SAFE_METHODS


class IsOwnerOrReadOnly(BasePermission):
message = 'You must be the owner to update.'

def safe_methods_or_owner(self, request, func):
if request.method in SAFE_METHODS:
return True

return func()

def has_permission(self, request, view):
return self.safe_methods_or_owner(
request,
lambda: request.user.is_authenticated
)

def has_object_permission(self, request, view, obj):
return self.safe_methods_or_owner(
request,
lambda: obj.author == request.user
)

进行非安全请求时,需要验证当前评论的作者和当前登录的用户是否为同一个人,这里用到了 def has_object_permission(...) 这个钩子方法,方法参数中的 obj 即为评论模型的实例。

看起来只需要实现这个 def has_object_permission(...) 就可以了,但还有一点点小问题:此方法是晚于视图集中的 def perform_create(author=self.request.user) 执行的。如果用户未登录时新建评论,由于用户不存在,接口会抛出 500 错误。

本着即使出错也要做出正确错误提示的原则,增加了 def has_permission(...) 方法。此方法早于 def perform_create(...) 执行,因此能够对用户登录状态做一个预先检查。

编写视图:

# comment/views.py

from rest_framework import viewsets

from comment.models import Comment
from comment.serializers import CommentSerializer
from comment.permissions import IsOwnerOrReadOnly


class CommentViewSet(viewsets.ModelViewSet):
"""
评论视图集
"""
queryset = Comment.objects.all()
serializer_class = CommentSerializer
permission_classes = [IsOwnerOrReadOnly]

def perform_create(self, serializer):
serializer.save(author=self.request.user)


注册路由:

# backend/urls.py

from django.conf import settings
from django.conf.urls.static import static
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from article import views
from comment.views import CommentViewSet


router = DefaultRouter()
router.register(r'article', views.ArticleViewSet)
router.register(r'category', views.CategoryViewSet)
router.register(r'tag', views.TagViewSet)
router.register(r'avatar', views.AvatarViewSet)
router.register(r'comment', CommentViewSet)

urlpatterns = [
path('api/', include(router.urls)),
]

if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

可浏览器打开http://127.0.0.1:8000/api/comment/"进行测试

6.4 多级评论

多级评论,也就是让评论模型和自身相关联,使其可以有一个父级。

修改评论模型,新增 parent 字段:

# comment/models.py

from django.db import models
from django.utils import timezone

from article.models import Article
from django.contrib.auth.models import User


class Comment(models.Model):
parent = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='children'
)
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='comments'
)
article = models.ForeignKey(
Article,
on_delete=models.CASCADE,
related_name='comments'
)
content = models.TextField()
created = models.DateTimeField(default=timezone.now)

class Meta:
ordering = ['-created']

def __str__(self):
return self.content[:20]

  • 一个父评论可以有多个子评论,而一个子评论只能有一个父评论,因此用了一对多外键。
  • 之前的一对多外键,第一个参数直接引用了对应的模型,但是由于语法规则限制,这里显然不能够自己引用自己,因此用了传递字符串 self 的方式,作用都是一样的。

执行 python manage.py makemigrationspython manage.py migrate 进行迁移。

修改序列化器:

# comment/serializers.py

from rest_framework import serializers

from comment.models import Comment
from user_info.serializers import UserDescSerializer


class CommentChildrenSerializer(serializers.ModelSerializer):
"""
子评论序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='comment-detail')
author = UserDescSerializer(read_only=True)

class Meta:
model = Comment
exclude = ['parent', 'article']


class CommentSerializer(serializers.ModelSerializer):
"""
评论序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='comment-detail')
author = UserDescSerializer(read_only=True)

article = serializers.HyperlinkedRelatedField(view_name='article-detail', read_only=True)
article_id = serializers.IntegerField(write_only=True, allow_null=False, required=True)

parent = CommentChildrenSerializer(read_only=True)
parent_id = serializers.IntegerField(write_only=True, allow_null=True, required=True)

def update(self, instance, validated_data):
validated_data.pop('parent_id', None)
return super().update(instance, validated_data)

class Meta:
model = Comment
fields = '__all__'
extra_kwargs = {'created': {'read_only': True}}

新增代码大致可以分为三块:

  • article 改为超链接字段用了 HyperlinkedRelatedField ,它同 HyperlinkedIdentityField 差别很小,可以简化理解为 HyperlinkedRelatedField 用于对外键关系,而 HyperlinkedIdentityField 用于对当前模型自身。(完整的解释看这里
  • parent 为父评论,用了嵌套序列化器 CommentChildrenSerializer 。注意这个序列化器的 Metaexclude 来定义不需要的字段。
  • 由于希望父评论只能在创建时被关联,后续不能更改,因此覆写 def update(...) ,使得在更新评论时忽略掉 parent_id 参数。

可浏览器打开http://127.0.0.1:8000/api/comment/",在界面上进行评论的增删改查测试

7. JWT身份认证

Web 程序是使用 HTTP 协议传输的,而 HTTP 协议是无状态的协议,对于事务没有记忆能力。如果没有其他形式的帮助,服务器是没办法知道前后两次请求是否是同一个用户发起的,也不具有对用户进行身份验证的能力。

传统 web 开发中,身份验证通常是基于 Session 会话机制的。Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。 Session 通常是存储在服务器当中的,如果 Session 过多,会对服务器产生压力。

另一种比较常用的身份验证方式是 JWT (JSON Web Token) 令牌。JWT 是一种开放标准,它定义了一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息。由于 Token 是经过数字签名的,因此可以被验证和信任。JWT 非常适合用于身份验证和服务器到服务器授权。与 Session 不同,JWT 的 Token 是保存在用户端的,即摆脱了对服务器的依赖。在进行某些需要验证身份的业务中,用户需要把令牌一并提交。

JWT令牌组成如图所示:

JWT令牌

详细的 JWT 工作方式讲解

7.1 JWT引入

djangorestframework-simplejwt库可用于 JWT ,要使用这个库,首先执行pip install djangorestframework-simplejwt==5.2.2,修改配置文件,使用 JWT 为默认验证机制,同时配置token的有效期:

# backend/settings.py
...
from datetime import timedelta

...
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 2,
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
)
}

SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=3),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
}

Token 一旦泄露,任何人都可以获得该令牌的所有权限。出于安全考虑,Token 的有效期通常不应该设置得太长。

更多配置项请查看官方文档

在路由中添加Token的获取和刷新地址

# backend/urls.py

from django.conf import settings
from django.conf.urls.static import static
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from article import views
from comment.views import CommentViewSet
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)


router = DefaultRouter()
router.register(r'article', views.ArticleViewSet)
router.register(r'category', views.CategoryViewSet)
router.register(r'tag', views.TagViewSet)
router.register(r'avatar', views.AvatarViewSet)
router.register(r'comment', CommentViewSet)

urlpatterns = [
path('api/', include(router.urls)),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh', TokenRefreshView.as_view(), name='token_refresh'),
]

if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

7.2 测试

浏览器打开页面 http://127.0.0.1:8000/api/token/ ,填入你的用户名和密码,点击 POST 即可得到 Access Token 和 Refresh Token 。如:

HTTP 200 OK
Allow: POST, OPTIONS
Content-Type: application/json
Vary: Accept

{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY4MDA3OTMwMiwiaWF0IjoxNjc5OTkyOTAyLCJqdGkiOiI2ZWZmYzRhODJjZDY0ZjZiYjk3MDlmZTUzNDE2N2M1NSIsInVzZXJfaWQiOjF9.A2Z0oqu_TB-eLwCFgBkYMjjE71utzPa492VV9hX_zS0",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjgwMDAzNzAyLCJpYXQiOjE2Nzk5OTI5MDIsImp0aSI6ImYyMDNkNmMxMGY5ZDQ4MmJiNGI4ZjQwNGFhNzJlMjQ3IiwidXNlcl9pZCI6MX0.lN3ThGRBGg0Kz3u1vPrUJoYzlEFgoTNy_fyciPHxNRo"
}

拿到 Token 后,就可以用 Access Token 作为你的身份令牌,进行正常的资源请求了:

Postman 有一个专门的标签页 (Authorization) 用于填写令牌。此标签页的 Type 栏选择 Bearer Token 即可。

当 Access Token 过期后,可使用 Refresh Token 访问 http://127.0.0.1:8000/api/token/refresh/ 再获取一个新的令牌 Access Token ,当 Refresh Token 也过期后,之后浏览器打开页面 http://127.0.0.1:8000/api/token/ ,填入你的用户名和密码,获取新的Token了。

功能与用 Session 相同,并且成功切换到 JWT 方式了,非安全类的请求必须携带 Access Token。

开启 JWT 后,Session 验证就自动失效了。也就是说,除了申请 Token 时会用到账户密码,其他时候的身份验证都不再需要它们了。

Session V.S. JWT: JWT将会话移至客户端意味着摆脱了对服务器端会话的依赖,但这会带来如何安全存储、运输令牌等一系列挑战。不能够一概而论,而是要根据你的项目实际需求。关于这个话题更深入的讨论,请移步Stackoverflow

8. 用户管理

8.1 序列化器

用户管理涉及到对密码的操作,因此新写一个序列化器,覆写 def create(...)def update(...) 方法:

# user_info/serializers.py

from django.contrib.auth.models import User
from rest_framework import serializers


class UserDescSerializer(serializers.ModelSerializer):
"""
文章列表中引用的嵌套用户信息
"""

class Meta:
model = User
fields = [
'id',
'username',
'last_login',
'date_joined'
]

class UserRegisterSerializer(serializers.ModelSerializer):
"""
用户管理序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='user-detail', lookup_field='username')

class Meta:
model = User
fields = ['url', 'id', 'username', 'password']
extra_kwargs = {'password': {'write_only': True}}

def create(self, validated_data):
user = User.objects.create_user(**validated_data)
return user

def update(self, instance, validated_data):
if 'password' in validated_data:
password = validated_data.pop('password')
instance.set_password(password)
return super().update(instance, validated_data)

  • 注意 def update(...) 时,密码需要单独拿出来通过 set_password() 方法加密后存入数据库,而不能以明文的形式保存。
  • 超链接字段的参数有一条 lookup_field,这是指定了解析超链接关系的字段。直观来说,将其配置为 username 后,用户详情接口的地址表示为用户名而不是主键。

8.2 权限、视图与路由

新增权限:

# user_info/permissions.py

from rest_framework.permissions import BasePermission, SAFE_METHODS


class IsSelfOrReadOnly(BasePermission):

def has_object_permission(self, request, view, obj):
if request.method in SAFE_METHODS:
return True

return obj == request.user

新增视图:

# user_info/views.py

from django.contrib.auth.models import User
from rest_framework import viewsets
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly

from user_info.serializers import UserRegisterSerializer
from user_info.permissions import IsSelfOrReadOnly


class UserViewSet(viewsets.ModelViewSet):
"""
用户管理视图
"""
queryset = User.objects.all()
serializer_class = UserRegisterSerializer
lookup_field = 'username'

def get_permissions(self):
if self.request.method == 'POST':
self.permission_classes = [AllowAny]
else:
self.permission_classes = [IsAuthenticatedOrReadOnly, IsSelfOrReadOnly]

return super().get_permissions()

  • 注册用户的 POST 请求是允许所有人都可以操作的,但其他类型的请求(比如修改、删除)就必须是本人才行了,因此可以覆写 def get_permissions(...) 定义不同情况下所允许的权限。 permission_classes 接受列表,因此可以同时定义多个权限,权限之间是 and 关系。
  • 注意这里的 lookup_field 属性,和序列化器中对应起来。

修改路由:

# backend/urls.py

from django.conf import settings
from django.conf.urls.static import static
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from article import views
from comment.views import CommentViewSet
from user_info.views import UserViewSet
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)


router = DefaultRouter()
router.register(r'article', views.ArticleViewSet)
router.register(r'category', views.CategoryViewSet)
router.register(r'tag', views.TagViewSet)
router.register(r'avatar', views.AvatarViewSet)
router.register(r'comment', CommentViewSet)
router.register(r'user', UserViewSet)

urlpatterns = [
path('api/', include(router.urls)),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh', TokenRefreshView.as_view(), name='token_refresh'),
]

if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

可用浏览器打开 http://127.0.0.1:8000/api/user/ 进行用户列表查看和创建用户测试。

可以看到详情地址不是主键值而是用户名了,这就是 lookup_field 发挥的作用。

8.3 自定义动作

视图集除了默认的增删改查外,还可以有其他的自定义动作。

为了测试,先写一个信息更加丰富的用户序列化器:

# user_info/serializers.py

from django.contrib.auth.models import User
from rest_framework import serializers


class UserDescSerializer(serializers.ModelSerializer):
"""
文章列表中引用的嵌套用户信息
"""

class Meta:
model = User
fields = [
'id',
'username',
'last_login',
'date_joined'
]

class UserRegisterSerializer(serializers.ModelSerializer):
"""
用户管理序列化器
"""
url = serializers.HyperlinkedIdentityField(view_name='user-detail', lookup_field='username')

class Meta:
model = User
fields = ['url', 'id', 'username', 'password']
extra_kwargs = {'password': {'write_only': True}}

def create(self, validated_data):
user = User.objects.create_user(**validated_data)
return user

def update(self, instance, validated_data):
if 'password' in validated_data:
password = validated_data.pop('password')
instance.set_password(password)
return super().update(instance, validated_data)


class UserDetailSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = [
'id',
'username',
'first_name',
'last_name',
'email',
'last_login',
'date_joined'
]

接着在视图集中新增自定义动作的代码:

# user_info/views.py

from django.contrib.auth.models import User
from rest_framework import viewsets
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
from rest_framework.decorators import action
from rest_framework.response import Response

from user_info.serializers import UserRegisterSerializer, UserDetailSerializer
from user_info.permissions import IsSelfOrReadOnly


class UserViewSet(viewsets.ModelViewSet):
"""
用户管理视图
"""
queryset = User.objects.all()
serializer_class = UserRegisterSerializer
lookup_field = 'username'

def get_permissions(self):
if self.request.method == 'POST':
self.permission_classes = [AllowAny]
else:
self.permission_classes = [IsAuthenticatedOrReadOnly, IsSelfOrReadOnly]

return super().get_permissions()

@action(detail=True, methods=['get'])
def info(self, request, username=None):
queryset = User.objects.get(username=username)
serializer = UserDetailSerializer(queryset, many=False)
return Response(serializer.data)

@action(detail=False)
def sorted(self, reqeust):
users = User.objects.all().order_by('-username')
page = self.paginate_queryset(users)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)

serializer = self.get_serializer(users, many=True)
return Response(serializer.data)

魔法都在装饰器 @action 里,它的参数可以定义是否为详情的动作、请求类型、url 地址、url 解析名等常规需求。

(venv) > http get http://127.0.0.1:8000/api/user/admin/info/
HTTP/1.1 200 OK
Allow: GET, HEAD, OPTIONS
Connection: close
Content-Length: 183
Content-Type: application/json
Cross-Origin-Opener-Policy: same-origin
Date: Tue, 28 Mar 2023 09:50:04 GMT
Referrer-Policy: same-origin
Server: WSGIServer/0.2 CPython/3.10.6
Vary: Accept
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
"date_joined": "2023-03-27T02:08:41.097951+08:00",
"email": "admin@example.com",
"first_name": "",
"id": 1,
"last_login": "2023-03-27T23:24:54.504055+08:00",
"last_name": "",
"username": "admin"
}

(venv) > http get http://127.0.0.1:8000/api/user/sorted/
HTTP/1.1 200 OK
Allow: GET, HEAD, OPTIONS
Connection: close
Content-Length: 197
Content-Type: application/json
Cross-Origin-Opener-Policy: same-origin
Date: Tue, 28 Mar 2023 09:51:41 GMT
Referrer-Policy: same-origin
Server: WSGIServer/0.2 CPython/3.10.6
Vary: Accept
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"url": "http://127.0.0.1:8000/api/user/test/",
"username": "test"
},
{
"id": 1,
"url": "http://127.0.0.1:8000/api/user/admin/",
"username": "admin"
}
]
}

默认情况下,方法名就是此动作的路由路径。返回的 Json 也正确显示为方法中所封装的数据。

关于自定义动作详见官方文档

本文标题: Django-Vue搭建个人博客(3):后端功能完善
原文链接: https://www.dusaiphoto.com/article/112/ ~ https://www.dusaiphoto.com/article/120/
原文作者: 杜赛
许可协议: 署名-非商业性使用 4.0 国际许可协议
本文对原始作品作了修改,转载请保留原文链接及作者