很多人学习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<pk>\d+)/(?P<slug1>[-\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<pk>\d+)/(?P<slug1>[-\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<pk>\d+)/(?P<slug1>[-\w]+)/publish/$',
views.article_publish, name='article_publish'),
# 删除文章 - 需要登录
re_path(r'^article/(?P<pk>\d+)/(?P<slug1>[-\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<slug>[-\w]+)/$',
views.CategoryDetailView.as_view(), name='category_detail'),
# 展示Tag详情
re_path(r'^tags/(?P<slug>[-\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 %}
<h3>已发表文章</h3>
{# 注释: page_obj不要改。Article可以改成自己对象 #}
<form action="{% url 'blog:article_search' %}" role="search" method="get">
{% csrf_token %}
<div class="input-group col-md-12">
<input type="text" name="q" id="q" class="form-control" placeholder="搜索文章">
<span class="input-group-btn">
<button class="btn btn-default form-control" type="submit" value="submit">
<span class="glyphicon glyphicon-search"></span>
</button>
</span>
</div>
</form>
{% if page_obj %}
<table class="table table-striped">
<thead>
<tr>
<th>标题</th>
<th>类别</th>
<th>发布日期</th>
<th>查看</th>
<th>修改</th>
<th>删除</th>
</tr>
</thead>
<tbody>
{% for article in page_obj %}
<tr>
<td>
{{ article.title }}
</td>
<td>
{{ article.category.name }}
</td>
<td>
{{ article.pub_date | date:"Y-m-d" }}
</td>
<td>
<a href="{% url 'blog:article_detail' article.id article.slug %}"><span class="glyphicon glyphicon-eye-open"></span></a>
</td>
<td>
<a href="{% url 'blog:article_update' article.id article.slug %}"><span class="glyphicon glyphicon-wrench"></span></a>
</td>
<td>
<a href="{% url 'blog:article_delete' article.id article.slug %}"><span class="glyphicon glyphicon-trash"></span></a>
</td>
{% endfor %}
</tr>
</tbody>
</table>
{% else %}
{# 注释: 这里可以换成自己的对象 #}
<p>没有文章。</p>
{% endif %}
{# 注释: 下面代码一点也不要动 #}
{% if is_paginated %}
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
{% endif %}
{% for i in paginator.page_range %}
{% if page_obj.number == i %}
<li class="page-item active"><span class="page-link"> {{ i }} <span class="sr-only">(current)</span></span></li>
{% else %}
<li class="page-item"><a class="page-link" href="?page={{ i }}">{{ i }}</a></li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
{% endif %}
</ul>
{% endif %}
{% endblock %}
效果如下。匿名用户看到界面差不多,唯一不同的是没有修改和删除的链接。草稿箱文章列表界面也差不多,唯一不同是没有发布日期。
#blog/templates/blog/article_create_form.html(创建文章)
{% extends "blog/base.html" %}
{% block content %}
<h3>添加新文章</h3>
<form method="POST" class="form-horizontal" role="form" action="" >
{% csrf_token %}
{% for hidden_field in form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
{% if form.non_field_errors %}
<div class="alert alert-danger col-md-12" role="alert">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% for field in form.visible_fields %}
<div class="form-group col-md-12">
{{ field.label_tag }}
{{ field }}
{% if field.errors %}
{% for error in field.errors %}
<div class="invalid-feedback">
{{ error }}
</div>
{% endfor %}
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
</div>
{% endfor %}
<div class="form-group">
<div class="col-md-12">
<input type="submit" class="btn btn-primary form-control" value="提交">
</div>
</div>
</form>
{% endblock %}
效果如下:
#blog/templates/blog/article_detail.html(文章详情)
{% extends "blog/base.html" %}
{% block content %}
<p>类别:
{% if article.category.parent_category %}
<a href="{% url 'blog:category_detail' article.category.parent_category.slug %}">{{ article.category.parent_category.name }}</a> /
{% endif %}
<a href="{% url 'blog:category_detail' article.category.slug %}">{{ article.category }}</a>
</p>
<h3>{{ article.title }}
{% if article.status == "d" %}
(草稿)
{% endif %}
</h3>
{% if article.status == "p" %}
<p>发布于{{ article.pub_date | date:"Y-m-d" }} 浏览{{ article.views }}次</p>
{% endif %}
<p>{{ article.body }}</p>
<p>标签:
{% for tag in article.tags.all %}
<a href="{% url 'blog:tag_detail' tag.slug %}">{{ tag.name }}</a>,
{% endfor %}
</p>
{% if article.author == request.user %}
{% if article.status == "d" %}
<a href="{% url 'blog:article_publish' article.id article.slug %}">发布</a> |
{% endif %}<a href="{% url 'blog:article_update' article.id article.slug %}">编辑</a> |
<a href="{% url 'blog:article_delete' article.id article.slug %}">删除</a>
{% endif %}
{% endblock %}
最终效果如下。请注意我们对文章的状态做了判断,不同的文章状态显示的内容是不同的。比如草稿文章标题上会多出草稿两个字。如果用户已登录,页面上会出现发布,编辑和删除文章的链接。
小结
本教程介绍了如何利用Django开发一个专业博客,并利用Django自带的通用视图开发了博客管理后台。我们着重分析了Article模型中新增方法的作用以及我们为什么需要使用他们。事实上这个博客目前的功能还是非常初级的,比如没有评论和点赞功能,文本编辑器太简单,没有文章推荐功能,没有用户之间相互关注功能,也没有使用缓存技术,需要完善和升级的地方非常多。小编我将在后续专题中逐一讲解,前提是本文可以搜集20个以上的赞。
小编我写完所有代码只需要3个小时不到,然而完成本文却花了近4个小时。我那么努力,你却连个赞都不给吗?
大江狗
2018.9.8