1. 过滤文章 用户需要某个特定范围的文章时,后端需要把返回的数据进行过滤。最简单的过滤方法是修改视图集中的queryset
属性:
from rest_framework import viewsetsfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Articlefrom article.serializers import ArticleSerializerclass 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()
的方式实现过滤:
from rest_framework import viewsetsfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Articlefrom article.serializers import ArticleSerializerclass 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
安装,然后修改配置文件:
... 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' ], }
from rest_framework import viewsetsfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Articlefrom article.serializers import ArticleSerializerclass 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 相关内容:
from rest_framework import viewsetsfrom django_filters.rest_framework import DjangoFilterBackendfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Articlefrom article.serializers import ArticleSerializerclass 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
来做。
from rest_framework import filters, viewsetsfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Articlefrom article.serializers import ArticleSerializerclass 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
增加一个分类的模型,并且将其和博文称为一对多的外键:
from django.db import modelsfrom django.utils import timezonefrom django.contrib.auth.models import Userclass 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 序列化器 编写和修改序列化器:
from rest_framework import serializersfrom article.models import Article, Categoryfrom user_info.serializers import UserDescSerializerclass 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 视图与路由 编写视图:
from rest_framework import viewsets, filtersfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Article, Categoryfrom article.serializers import ArticleSerializer, CategorySerializerclass 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)
编写路由:
from django.urls import path, includefrom rest_framework.routers import DefaultRouterfrom article import viewsrouter = DefaultRouter() router.register(r'article' , views.ArticleViewSet) router.register(r'category' , views.CategoryViewSet) urlpatterns = [ path('api/' , include(router.urls)), ]
2.4 测试 (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/" } (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/" } (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/" } (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/" }
在更新资源时用到了 POST
、PUT
、 PATCH
三种请求方法
POST
:创建新的资源。PUT
: 整体更新特定资源,默认情况下你需要完整给出所有必须的字段。PATCH
: 部分更新特定资源,仅需要给出需要更新的字段,未给出的字段默认不更改。2.5 完善分类详情 现在希望分类的列表页面 不显示其链接的文章,以保持数据简洁,但是详情页面则展示出链接的所有文章,方便接口的使用。因此就需要同一个视图集用到两个不同的序列化器,可以使用 get_serializer_class()
。
from rest_framework import serializersfrom article.models import Article, Categoryfrom user_info.serializers import UserDescSerializerclass 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' , ]
然后修改视图:
from rest_framework import viewsets, filtersfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Article, Categoryfrom article.serializers import ArticleSerializer, CategorySerializer, CategoryDetailSerializerclass 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 并进行数据迁移
from django.db import modelsfrom django.utils import timezonefrom django.contrib.auth.models import Userclass 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 序列化器 from rest_framework import serializersfrom article.models import Article, Category, Tagfrom user_info.serializers import UserDescSerializerclass 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
。因此还需要覆写 TagSerializer
的 create()/update()
方法。
from rest_framework import serializersfrom article.models import Article, Category, Tagfrom user_info.serializers import UserDescSerializerclass 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 视图与路由 编写视图:
from rest_framework import viewsets, filtersfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Article, Category, Tagfrom article.serializers import ArticleSerializer, CategorySerializer, CategoryDetailSerializer, TagSerializerclass 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)
编写路由:
from django.urls import path, includefrom rest_framework.routers import DefaultRouterfrom article import viewsrouter = 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 的序列化器中唯一性的检查:
from django.db import modelsfrom django.utils import timezonefrom django.contrib.auth.models import Userclass 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
from rest_framework import serializersfrom article.models import Article, Category, Tagfrom user_info.serializers import UserDescSerializerclass 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()
方法:
from django.db import modelsfrom django.utils import timezonefrom django.contrib.auth.models import Userfrom markdown import Markdownclass 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
:
from rest_framework import serializersfrom article.models import Article, Category, Tagfrom user_info.serializers import UserDescSerializerclass 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_html
、 toc_html
这两个渲染后的字段是经过加工后的数据,不存在于原始的数据中。为了将这类只读的附加字段添加到接口里,可以用SerializerMethodField()
字段。比如说上面代码中的 body_html
字段,它会自动去调用 get_body_html()
方法,并将其返回结果作为需要序列化的数据。方法中的 obj
参数是序列化器获取到的 model 实例,即文章对象。
1.3 修复视图 渲染后的数据,在文章详情接口 是需要的,但是在列表接口 却没必要,因此视图集根据请求方式动态获取序列化器:
from rest_framework import viewsets, filtersfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Article, Category, Tagfrom article.serializers import ArticleSerializer, CategorySerializer, CategoryDetailSerializer, TagSerializer, ArticleDetailSerializerclass 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
,添加标题图模型:
from django.db import modelsfrom django.utils import timezonefrom django.contrib.auth.models import Userfrom markdown import Markdownclass 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)到前端并暂存,前端将图片的信息以嵌套结构表示到文章接口中,并在适当的时候将其链接到文章数据中,等待新文章完成后一起提交。 提交新文章时,序列化器对标题图进行检查,如果无效则返回错误信息。 from rest_framework import serializersfrom article.models import Article, Category, Tag, Avatarfrom user_info.serializers import UserDescSerializerclass 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 视图与路由 编写视图:
from rest_framework import viewsets, filtersfrom article.permissions import IsAdminUserOrReadOnlyfrom article.models import Article, Category, Tag, Avatarfrom 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
还需要修改配置文件,配置图片存放的路径
... MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media'
最后编写路由:
from django.conf import settingsfrom django.conf.urls.static import staticfrom django.urls import path, includefrom rest_framework.routers import DefaultRouterfrom article import viewsrouter = 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注册到配置文件
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 添加模型 from django.db import modelsfrom django.utils import timezonefrom article.models import Articlefrom django.contrib.auth.models import Userclass 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 makemigrations
和 python manage.py migrate
。
6.2 序列化器 from rest_framework import serializersfrom comment.models import Commentfrom user_info.serializers import UserDescSerializerclass 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
嵌套序列化器让显示的内容更丰富,让评论通过文章接口显示出来:
from rest_framework import serializersfrom article.models import Article, Category, Tag, Avatarfrom user_info.serializers import UserDescSerializerfrom comment.serializers import CommentSerializerclass 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 权限、视图与路由 评论对用户身份的要求比文章宽松,非安全请求 只需要是本人操作就可以了。
因此自定义一个所有人都可查看、仅本人可更改的权限:
from rest_framework.permissions import BasePermission, SAFE_METHODSclass 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(...)
执行,因此能够对用户登录状态做一个预先检查。
编写视图:
from rest_framework import viewsetsfrom comment.models import Commentfrom comment.serializers import CommentSerializerfrom comment.permissions import IsOwnerOrReadOnlyclass CommentViewSet (viewsets.ModelViewSet ): """ 评论视图集 """ queryset = Comment.objects.all () serializer_class = CommentSerializer permission_classes = [IsOwnerOrReadOnly] def perform_create (self, serializer ): serializer.save(author=self.request.user)
注册路由:
from django.conf import settingsfrom django.conf.urls.static import staticfrom django.urls import path, includefrom rest_framework.routers import DefaultRouterfrom article import viewsfrom comment.views import CommentViewSetrouter = 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
字段:
from django.db import modelsfrom django.utils import timezonefrom article.models import Articlefrom django.contrib.auth.models import Userclass 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 makemigrations
和 python manage.py migrate
进行迁移。
修改序列化器:
from rest_framework import serializersfrom comment.models import Commentfrom user_info.serializers import UserDescSerializerclass 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
。注意这个序列化器的 Meta
用 exclude
来定义不需要的字段。由于希望父评论只能在创建时被关联,后续不能更改,因此覆写 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 工作方式讲解 。
7.1 JWT引入 djangorestframework-simplejwt
库可用于 JWT ,要使用这个库,首先执行pip install djangorestframework-simplejwt==5.2.2
,修改配置文件,使用 JWT 为默认验证机制,同时配置token的有效期:
... 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的获取和刷新地址
from django.conf import settingsfrom django.conf.urls.static import staticfrom django.urls import path, includefrom rest_framework.routers import DefaultRouterfrom article import viewsfrom comment.views import CommentViewSetfrom 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(...)
方法:
from django.contrib.auth.models import Userfrom rest_framework import serializersclass 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 权限、视图与路由 新增权限:
from rest_framework.permissions import BasePermission, SAFE_METHODSclass IsSelfOrReadOnly (BasePermission ): def has_object_permission (self, request, view, obj ): if request.method in SAFE_METHODS: return True return obj == request.user
新增视图:
from django.contrib.auth.models import Userfrom rest_framework import viewsetsfrom rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnlyfrom user_info.serializers import UserRegisterSerializerfrom user_info.permissions import IsSelfOrReadOnlyclass 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
属性,和序列化器中对应起来。 修改路由:
from django.conf import settingsfrom django.conf.urls.static import staticfrom django.urls import path, includefrom rest_framework.routers import DefaultRouterfrom article import viewsfrom comment.views import CommentViewSetfrom user_info.views import UserViewSetfrom 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 自定义动作 视图集除了默认的增删改查外,还可以有其他的自定义动作。
为了测试,先写一个信息更加丰富的用户序列化器:
from django.contrib.auth.models import Userfrom rest_framework import serializersclass 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' ]
接着在视图集中新增自定义动作的代码:
from django.contrib.auth.models import Userfrom rest_framework import viewsetsfrom rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnlyfrom rest_framework.decorators import actionfrom rest_framework.response import Responsefrom user_info.serializers import UserRegisterSerializer, UserDetailSerializerfrom user_info.permissions import IsSelfOrReadOnlyclass 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 国际许可协议 本文对原始作品作了修改,转载请保留原文链接及作者