Django实战专题: 专业博客开发(1)之内容管理后台开发

版权声明:本文系大江狗原创,请勿直接copy应用于你的出版物或任何公众平台。 https://blog.csdn.net/weixin_42134789/article/details/82702380

很多人学习Django都是从开发个人博客入手的,网上的教程也很多,但后台大多是基于Django自带的admin来实现文章的增删查改,而前台也只是实现了简单地展示文章列表和某篇文章详情。开发一个专业的博客显然不止是那么简单的,小编我今天就带你利用Django开发一个专业点的博客,重点放在开发内容管理后台。我会带你分析每一步的代码思路,帮你了解一个优秀的程序员应该如何思考,并解决遇到的技术问题。本文适合已具备一定Django基础知识的读者。本专题连载,总篇数未知。只有当本文点赞数大于20时,我才会开始本专题下篇文章的更新。本文开发环境为Django 2.0 + Python 3.6。

总体思路

我们的前台需要2个功能性页面,展示文章列表和文章详情,用户无需登录即可查看。后台需要6个功能性页面,需要用户登录后才能访问,且每个用户只能编辑或删除自己创建的文章。这8个功能性页面分别是。

  • 文章列表 - 不需要登录

  • 文章详情 - 不需要登录

  • 创建文章 - 需要登录

  • 修改文章 - 需要登录

  • 删除文章 - 需要登录

  • 查看已发布文章  - 需要登录

  • 草稿箱 - 需要登录

    扫描二维码关注公众号,回复: 3739421 查看本文章
  • 发表文章 (由草稿变发布) - 需要登录

登录后电脑上看到的管理后台大致效果是这样的。

手机上看效果是这样子的。

项目配置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

猜你喜欢

转载自blog.csdn.net/weixin_42134789/article/details/82702380