很多人学习Django都是从开发个人博客入手的,网上的教程也很多,但后台大多是基于Django自带的admin来实现文章的增删查改,而前台也只是实现了简单地展示文章列表和某篇文章详情。开发一个专业的博客显然不止是那么简单的,小编我今天就带你利用Django开发一个专业点的博客,重点放在开发内容管理后台。我会带你分析每一步的代码思路,帮你了解一个优秀的程序员应该如何思考,并解决遇到的技术问题。本文适合已具备一定Django基础知识的读者。本专题连载,总篇数未知。只有当本文点赞数大于20时,我才会开始本专题下篇文章的更新。本文开发环境为Django 2.0 + Python 3.6。
总体思路
我们的前台需要2个功能性页面,展示文章列表和文章详情,用户无需登录即可查看。后台需要6个功能性页面,需要用户登录后才能访问,且每个用户只能编辑或删除自己创建的文章。这8个功能性页面分别是。
文章列表 - 不需要登录
文章详情 - 不需要登录
创建文章 - 需要登录
修改文章 - 需要登录
删除文章 - 需要登录
查看已发布文章 - 需要登录
草稿箱 - 需要登录
发表文章 (由草稿变发布) - 需要登录
登录后电脑上看到的管理后台大致效果是这样的。
手机上看效果是这样子的。
项目配置settings.py
我们通过python manage.py startapp blog创建一个叫blog的APP,把它加到settings.py里INSATLLED_APP里去,如下所示。我们用户注册登录功能交给了django-allauth, 所以把allauth也进去了。如果你不了解django-allauth,强烈建议阅读django-allauth教程(1): 安装,用户注册,登录,邮箱验证和密码重置(更新)。
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.sites', 'allauth', 'allauth.account', 'allauth.socialaccount', 'allauth.socialaccount.providers.baidu', 'blog', ]
因为我们要用到静态文件如css和图片,我们需要在settings.py里设置STATIC_URL和MEDIA。用户上传的图片会放在/media/文件夹里。
STATIC_URL = '/static/' STATICFILES_DIRS = [os.path.join(BASE_DIR, "static"), ] # specify media root for user uploaded files, MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/'
整个项目的urls.py如下所示。我们把blog的urls.py也加进去了。别忘了在结尾部分加static配置。
from django.conf import settings from django.conf.urls.static import static urlpatterns = [ path('admin/', admin.site.urls), path('accounts/', include('allauth.urls')), path('blog/', include('blog.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
模型models.py
我们需要至少创建3个模型Article(文章), Category(类别)和Tag(标签), 其中类别与文章是单对多的关系,而标签与文章是多对多的关系。我们的Article模型如下所示,包括了文章状态(草稿还是发表), 文章创建,修改和发表日期,以及文章浏览次数。
class Article(models.Model): """文章模型""" STATUS_CHOICES = ( ('d', '草稿'), ('p', '发表'), ) title = models.CharField('标题', max_length=200, unique=True) slug = models.SlugField('slug', max_length=60, blank=True) body = models.TextField('正文') pub_date = models.DateTimeField('发布时间', null=True) create_date = models.DateTimeField('创建时间', auto_now_add=True) mod_date = models.DateTimeField('修改时间', auto_now=True) status = models.CharField('文章状态', max_length=1, choices=STATUS_CHOICES, default='p') views = models.PositiveIntegerField('浏览量', default=0) author = models.ForeignKey(User, verbose_name='作者', on_delete=models.CASCADE) category = models.ForeignKey('Category', verbose_name='分类', on_delete=models.CASCADE, blank=False, null=False) tags = models.ManyToManyField('Tag', verbose_name='标签集合', blank=True) def __str__(self): return self.title
我们现在就要看下这个模型能否满足我们的需求。
我们第1个要求就是要根据文章标题手动生成slug, 并把slug放到url里。slug最大的作用就是便于读者和搜索引擎直接从url中了解文章大概包含了什么内容,显然/article/1/django-blog-demo/比/article/1/包含更多信息。我们还要考虑到slug可能会随文章title的变化而改变导致后续网站上出现很多坏连接,所以我们希望slug只是在首次创建文章时生成,而不会随后续title的变化而改变。除此以外,我们还要解决中文标题无法生成slug的问题。最好的解决方案就是重写模型models的save方法, 代码如下所示。当id或slug为空时,利用unidecode对中文解码,利用slugify方法根据标题手动生成slug。
from django.db import models from django.contrib.auth.models import User from django.urls import reversefrom unidecode import unidecode from django.template.defaultfilters import slugify import datetime class Article(models.Model): """文章模型""" title = models.CharField('标题', max_length=200, unique=True) slug = models.SlugField('slug', max_length=60, blank=True) ........ def save(self, *args, **kwargs): if not self.id or not self.slug: # Newly created object, so set slug self.slug = slugify(unidecode(self.title)) super().save(*args, **kwargs)
我们第2个需求是确保模型各个字段间的数据满足一定的逻辑关系。比如草稿文章(d)不应该有发布日期(pub_date)。当文章状态为发布(p), 而发布日期为空时,发布日期应该为当前时间。当一个模型的各个字段之间并不彼此独立的,而是存在一定的关联性时,我们可以在模型中添加自定义的clean方法来完成数据的清理与验证。
def clean(self): # Don't allow draft entries to have a pub_date. if self.status == 'd' and self.pub_date is not None: self.pub_date = None # raise ValidationError('草稿没有发布日期. 发布日期已清空。') if self.status == 'p' and self.pub_date is None: self.pub_date = datetime.datetime.now()
除此以外我们还要在模型中定义其它有用的方法,比如Django通用视图所需要的get_abosolute_url方法。我们还要定义是浏览次数自增1的viewed的方法和把文章状态由草案变成发布的published方法。一个完整的Article代码模型如下所示:
from django.db import models from django.contrib.auth.models import User from django.urls import reverse from unidecode import unidecode from django.template.defaultfilters import slugify import datetime class Article(models.Model): """文章模型""" STATUS_CHOICES = ( ('d', '草稿'), ('p', '发表'), ) title = models.CharField('标题', max_length=200, unique=True) slug = models.SlugField('slug', max_length=60, blank=True) body = models.TextField('正文') pub_date = models.DateTimeField('发布时间', null=True) create_date = models.DateTimeField('创建时间', auto_now_add=True) mod_date = models.DateTimeField('修改时间', auto_now=True) status = models.CharField('文章状态', max_length=1, choices=STATUS_CHOICES, default='p') views = models.PositiveIntegerField('浏览量', default=0) author = models.ForeignKey(User, verbose_name='作者', on_delete=models.CASCADE) category = models.ForeignKey('Category', verbose_name='分类', on_delete=models.CASCADE, blank=False, null=False) tags = models.ManyToManyField('Tag', verbose_name='标签集合', blank=True) def __str__(self): return self.title def save(self, *args, **kwargs): if not self.id or not self.slug: # Newly created object, so set slug self.slug = slugify(unidecode(self.title)) super().save(*args, **kwargs) def get_absolute_url(self): return reverse('blog:article_detail', args=[str(self.pk), self.slug]) def clean(self): # Don't allow draft entries to have a pub_date. if self.status == 'd' and self.pub_date is not None: self.pub_date = None # raise ValidationError('草稿没有发布日期. 发布日期已清空。') if self.status == 'p' and self.pub_date is None: self.pub_date = datetime.datetime.now() def viewed(self): self.views += 1 self.save(update_fields=['views']) def published(self): self.status = 'p' self.pub_date = datetime.datetime.now() self.save(update_fields=['status', 'pub_date']) class Meta: ordering = ['-pub_date'] verbose_name = "文章" verbose_name_plural = verbose_name
Category和Tag模型如下所示。每个类别可能有母类别,指向的模型是自己。比如Python类包括Python基础和Django基础类。我们通过自定义的has_child方法来判断一个类别是否有子类别。你一定奇怪我们为什么不定义has_parent方法来判断一个类别是否有母类别呢? 因为我们可以通过category.parent_category是否为空来直接判断一个类别是否有母类别。在Tag模型中,我们定义了get_article_count方法来快速统计属于某个tag的文章总数。
class Category(models.Model): """文章分类""" name = models.CharField('分类名', max_length=30, unique=True) slug = models.SlugField('slug', max_length=40) parent_category = models.ForeignKey('self', verbose_name="父级分类", blank=True, null=True, on_delete=models.CASCADE) def get_absolute_url(self): return reverse('blog:category_detail', args=[self.slug]) def has_child(self): if self.category_set.all().count() > 0: return True def __str__(self): return self.name class Meta: ordering = ['name'] verbose_name = "分类" verbose_name_plural = verbose_name class Tag(models.Model): """文章标签""" name = models.CharField('标签名', max_length=30, unique=True) slug = models.SlugField('slug', max_length=40) def __str__(self): return self.name def get_absolute_url(self): return reverse('blog:tag_detail', args=[self.slug]) def get_article_count(self): return Article.objects.filter(tags__slug=self.slug).count() class Meta: ordering = ['name'] verbose_name = "标签" verbose_name_plural = verbose_name
URLConf配置urls.py
每个path都对应一个视图,一个命名的url和我们本文刚开始介绍的一个功能性页面。本项目总体urls.py如下。实际上我们只需要传递文章的pk只即可实现文章的编辑和删除,丹我们还是在url里同时传递了文章的pk和slug, 提高了url的可读性, 便于搜索引擎检索。
from django.urls import path, re_path from . import views # namespace app_name = 'blog' urlpatterns = [ # 所有文章列表 - 不需登录 path('', views.ArticleListView.as_view(), name='article_list'), # 展示文章详情 - 登录/未登录均可 re_path(r'^article/(?P\d+)/(?P [-\w]+)/$', views.ArticleDetailView.as_view(), name='article_detail'), # 草稿箱 - 需要登录 path('draft/', views.ArticleDraftListView.as_view(), name='article_draft_list'), # 已发表文章列表(含编辑) - 需要登录 path('admin/', views.PublishedArticleListView.as_view(), name='published_article_list'), # 更新文章- 需要登录 re_path(r'^article/(?P \d+)/(?P [-\w]+)/update/$', views.ArticleUpdateView.as_view(), name='article_update'), # 创建文章 - 需要登录 re_path(r'^article/create/$', views.ArticleCreateView.as_view(), name='article_create'), # 发表文章 - 需要登录 re_path(r'^article/(?P \d+)/(?P [-\w]+)/publish/$', views.article_publish, name='article_publish'), # 删除文章 - 需要登录 re_path(r'^article/(?P \d+)/(?P [-\w]+)/delete$', views.ArticleDeleteView.as_view(), name='article_delete'), # 展示类别列表 re_path(r'^category/$', views.CategoryListView.as_view(), name='category_list'), # 展示类别详情 re_path(r'^category/(?P [-\w]+)/$', views.CategoryDetailView.as_view(), name='category_detail'), # 展示Tag详情 re_path(r'^tags/(?P [-\w]+)/$', views.TagDetailView.as_view(), name='tag_detail'), # 搜索文章 re_path(r'^search/$', views.article_search, name='article_search'), ]
视图views.py
我们使用Django自带的通用视图ListView, DetailView, CreateView, UpdateView和DeleteView来展现文章列表和详情,并实现文章的增删查改。如果你不懂Django的通用视图,请阅读Django核心基础(3): View视图详解。一旦你使用通用视图,你就会爱上她。对于需要用户登录后才能访问的视图,我们直接使用了login_required装饰器。
本文仅介绍与article相关的视图。各个视图与我们本文初介绍的功能性页面对应关系如下。
文章列表 - ArticleListView - 不需要login_required装饰器
文章详情 - ArticleDetailView - 不需要login_required装饰器
创建文章 - ArticleCreateView - 需要login_required装饰器
修改文章 - ArticleUpdateView - 需要login_required装饰器
删除文章 - ArticleDeleteView - 需要login_required装饰器
查看已发布文章 - PublishedArticleListView - 需要login_required装饰器
草稿箱 - ArticleDraftListView - 需要login_required装饰器
发表文章 (由草稿变发布) - 需要login_required装饰器
from django.views.generic import DetailView, ListView from django.views.generic.edit import CreateView, UpdateView, DeleteView from .models import Article, Category, Tag from django.http import HttpResponseRedirect from django.shortcuts import render, get_object_or_404, redirect from .forms import ArticleForm from django.http import Http404 from django.core.paginator import Paginator from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator from django.urls import reverse, reverse_lazy # Create your views here. class ArticleListView(ListView): paginate_by = 3 def get_queryset(self): return Article.objects.filter(status='p').order_by('-pub_date') @method_decorator(login_required, name='dispatch') class PublishedArticleListView(ListView): template_name = "blog/published_article_list.html" paginate_by = 3 def get_queryset(self): return Article.objects.filter(author=self.request.user). filter(status='p').order_by('-pub_date') @method_decorator(login_required, name='dispatch') class ArticleDraftListView(ListView): template_name = "blog/article_draft_list.html" paginate_by = 3 def get_queryset(self): return Article.objects.filter(author=self.request.user). filter(status='d').order_by('-pub_date') class ArticleDetailView(DetailView): model = Article def get_object(self, queryset=None): obj = super().get_object(queryset=queryset) obj.viewed() return obj @method_decorator(login_required, name='dispatch') class ArticleCreateView(CreateView): model = Article form_class = ArticleForm template_name = 'blog/article_create_form.html' # Associate form.instance.user with self.request.user def form_valid(self, form): form.instance.author = self.request.user return super().form_valid(form) @method_decorator(login_required, name='dispatch') class ArticleUpdateView(UpdateView): model = Article form_class = ArticleForm template_name = 'blog/article_update_form.html' def get_object(self, queryset=None): obj = super().get_object(queryset=queryset) if obj.author != self.request.user: raise Http404() return obj @method_decorator(login_required, name='dispatch') class ArticleDeleteView(DeleteView): model = Article success_url = reverse_lazy('blog:article_list') def get_object(self, queryset=None): obj = super().get_object(queryset=queryset) if obj.author != self.request.user: raise Http404() return obj @login_required() def article_publish(request, pk, slug1): article = get_object_or_404(Article, pk=pk, author=request.user) article.published() return redirect(reverse("blog:article_detail", args=[str(pk), slug1]))
你注意到我们是如何实现登录用户只能查看,修改和删除自己的文章的了吗? 对的,就是你必需学会的get_queryset和get_object方法,详见如何使用Django通用视图的get_queryset, get_context_data和get_object等方法.
我们视图里使用了ArticleForm, 用于文章的创建和编辑。forms.py代码如下。
from django import forms from .models import Article class ArticleForm(forms.ModelForm): class Meta: model = Article exclude = ['author', 'views', 'slug', 'pub_date'] widgets = { 'title': forms.TextInput(attrs={'class': 'form-control'}), 'body': forms.Textarea(attrs={'class': 'form-control'}), 'status': forms.Select(attrs={'class': 'form-control'}), 'category': forms.Select(attrs={'class': 'form-control'}), 'tags': forms.CheckboxSelectMultiple(attrs={'class': 'multi-checkbox'}), }
模板templates
#blog/templates/blog/published_article_list.html(登录后查看自己发表的文章)
{% extends "blog/base.html" %} {% block content %}{% if page_obj %}已发表文章
{# 注释: page_obj不要改。Article可以改成自己对象 #}
标题 | 类别 | 发布日期 | 查看 | 修改 | 删除 |
---|---|---|---|---|---|
{{ article.title }} | {{ article.category.name }} | {{ article.pub_date | date:"Y-m-d" }} | {% endfor %} |
没有文章。
{% endif %} {# 注释: 下面代码一点也不要动 #} {% if is_paginated %}效果如下。匿名用户看到界面差不多,唯一不同的是没有修改和删除的链接。草稿箱文章列表界面也差不多,唯一不同是没有发布日期。
#blog/templates/blog/article_create_form.html(创建文章)
{% extends "blog/base.html" %} {% block content %}{% endblock %}添加新文章
效果如下:
#blog/templates/blog/article_detail.html(文章详情)
{% extends "blog/base.html" %} {% block content %}类别: {% if article.category.parent_category %} {{ article.category.parent_category.name }} / {% endif %} {{ article.category }}
{{ article.title }} {% if article.status == "d" %} (草稿) {% endif %}
{% if article.status == "p" %}发布于{{ article.pub_date | date:"Y-m-d" }} 浏览{{ article.views }}次
{% endif %}{{ article.body }}
标签: {% for tag in article.tags.all %} {{ tag.name }}, {% endfor %}
{% if article.author == request.user %} {% if article.status == "d" %} 发布 | {% endif %}编辑 | 删除 {% endif %} {% endblock %}
最终效果如下。请注意我们对文章的状态做了判断,不同的文章状态显示的内容是不同的。比如草稿文章标题上会多出草稿两个字。如果用户已登录,页面上会出现发布,编辑和删除文章的链接。
小结
本教程介绍了如何利用Django开发一个专业博客,并利用Django自带的通用视图开发了博客管理后台。我们着重分析了Article模型中新增方法的作用以及我们为什么需要使用他们。事实上这个博客目前的功能还是非常初级的,比如没有评论和点赞功能,文本编辑器太简单,没有文章推荐功能,没有用户之间相互关注功能,也没有使用缓存技术,需要完善和升级的地方非常多。小编我将在后续专题中逐一讲解,前提是本文可以搜集20个以上的赞。
小编我写完所有代码只需要3个小时不到,然而完成本文却花了近4个小时。我那么努力,你却连个赞都不给吗?
大江狗
2018.9.8