DjangoでQiita風のブログを作ろう

作成: 2019年02月25日

更新: 2021年06月11日

やりたいこと

Djangoで作ったWebアプリをラズベリーパイで起動させる
でDjangoで作ったアプリを公開することができたので、ブログを作ってみたくなった。
しかし記事を書くたびにサーバーをいじるのは面倒なのでQiitaのようにmarkdownで記事を書き、それを自身のサイトにアップロードすることで記事を追加できるようにしたい。

どうやって実現するか

誰でも投稿できては困るので投稿フォームにはDjangoの管理者用ユーザーネームとパスワードを用いてのみアクセスできるようにする。
投稿フォームではタイトル、カテゴリー、markdownファイル、必要な画像ファイルを入力し、それらと投稿時の日時をデータベースに保存する。ファイルの保存場所はデータベース保存時に自動生成されるidをディレクトリ名とし、そこに保存する。
保存された記事を閲覧するときはmarkdownをmarked.jsとhighlight.jsを用いてパースし、表示する。

投稿フォーム

投稿フォームはDjangoの機能を使って作成する。アプリケーションディレクトリにforms.pyを作成し、以下のようにする。
Articleモデルに関しては後述する。

forms.pyfrom django import forms
from .models import Article

class ArticleForm(forms.ModelForm):
    image = forms.ImageField(required=False, widget=forms.ClearableFileInput(attrs={'multiple': True}))
    class Meta:
        model = Article
        fields = ('title', 'article', 'category_split_space',)

imageは画像を受け取るフォームである。画像なしでも問題なくするためにrequired=Falseとし、複数の画像を渡せるようにforms.ClearableFileInput(attrs={'multiple': True}))とする。class Meta以下はArticleモデル(後述)のfieldをフォームとして使用することを表している。
注意点としてImageFieldを用いる際にはPillowというライブラリが必要なのでインストールしていない場合はpipでインストールしておく。

pip install Pillow

このformをview.pyで以下のように作成してtemplateに渡す。

view.pydef post(request):
    articleForm = ArticleForm()
    return render(request, "post.html", {"articleForm": articleForm})

templateでは以下のようにすることでformが生成される。

post.html<form id="postForm" method="POST" action="/blog/posted" enctype="multipart/form-data">
    {{ articleForm.as_p }}
    {% csrf_token %}
    <button type="submit">投稿</button>
</form>

投稿された記事のデータを保存するためのモデルは以下のように定義する。

models.pyfrom django.db import models
from django.db.models import CharField, FileField, DateTimeField, ManyToManyField, ImageField
from django.utils import timezone
from django.dispatch import receiver
from django.db.models.signals import post_save, pre_save
import os.path

# Create your models here.

class Category(models.Model):
    name = CharField(max_length=128)

def md_file_path(instance, filename):
    fn, ext = os.path.splitext(filename)
    return "article/{id}/{id}{ext}".format(id=instance.pk, ext=ext)

class Article(models.Model):
    title = CharField(max_length=128)
    article = FileField(upload_to=md_file_path)
    post_date = DateTimeField(default=timezone.now)
    #スペース区切りのカテゴリー
    category_split_space = CharField(max_length=128)
    category = ManyToManyField(Category)

#articleの保存ディレクトリ名にprimary keyを使うため
_UNSAVED_FILEFIELD = 'unsaved_filefield'
@receiver(pre_save, sender=Article)
def skip_saving_file(sender, instance, **kwargs):
    if not instance.pk and not hasattr(instance, _UNSAVED_FILEFIELD):
        setattr(instance, _UNSAVED_FILEFIELD, instance.article)
        instance.article = None

@receiver(post_save, sender=Article)
def save_file(sender, instance, created, **kwargs):
    if created and hasattr(instance, _UNSAVED_FILEFIELD):
        instance.article = getattr(instance, _UNSAVED_FILEFIELD)
        instance.save()
        instance.__dict__.pop(_UNSAVED_FILEFIELD)

Articleのfieldを一つずつ説明する。

  • title:記事のタイトル
  • article:記事のmarkdownファイル。正確にはmarkdownファイルの保存場所のURL。アップロード場所のURLはmd_file_pathという関数で指定
  • post_date:投稿日時。default=timezone.nowとすることで自動的に投稿したときの日時が記録される
  • category_split_space:Qiitaリスペクトでスペース区切りで入力されたカテゴリーを保存する
  • category:category_split_spaceをスペースで分けたものをCategoryモデルとして保存

Categoryモデルはカテゴリー名だけをfieldに持つモデルであり、Articleとリレーションをはっている。
md_file_pathはmarkdownファイルの保存場所を指定するものであり
"プロジェクトディレクトリ"/media/article/"articleモデルのid"/"articleモデルのid".md
という風に保存される。ディレクトリ名、ファイル名に自動生成されるモデルのidを用いることで後で参照しやすくなる。
しかしこれだけだとモデル保存前(モデルのid生成前)にidを参照しようとしているためidの部分がNoneになってしまう。この問題を回避するためにskip_saving_file、save_fileという2つの関数でモデル保存時の挙動を変更した。
Djangoでファイルアップロードの際にファイル名にprimary keyを使う方法
またmarkdownファイルはmediaディレクトリに保存されるためサーバーで設定が必要。Djangoのデバッグ用サーバーでは以下のように設定。

settings.pyMEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'

そしてformから受け取った情報はviews.pyで以下のように処理する。

views.pydef posted(request):
    form = ArticleForm(request.POST, request.FILES)
    categories = Category.objects.all()
    if form.is_valid():
        article = form.save()
        os.makedirs(settings.BASE_DIR + "/media/article/" + str(article.id) + "/image")
        for image in request.FILES.getlist("image"):
            with open(settings.BASE_DIR + "/media/article/" + str(article.id) + "/image/" + image.name, "wb+") as destination:
                for chunk in image.chunks():
                    destination.write(chunk)
        category_list = article.category_split_space.split()
        for c in category_list:
            #新規カテゴリーを作成
            if len(categories.filter(name=c)) == 0:
                new_category = Category(name=c)
                new_category.save()
                article.category.add(new_category)
            else:
                category = categories.filter(name=c)[0]
                article.category.add(category)
        article.save()
        articles = Article.objects.all()
        return render(request, "blog.html", {"articles": articles})

以下の部分でformの画像ファイルを保存している。
まずos.makedirsでmedia/article/"記事のid"ディレクトリにimageディレクトリを作りそこにフォームから受け取った画像ファイルを書き込んでいる。

views.pyos.makedirs(settings.BASE_DIR + "/media/article/" + str(article.id) + "/image")
        for image in request.FILES.getlist("image"):
            with open(settings.BASE_DIR + "/media/article/" + str(article.id) + "/image/" + image.name, "wb+") as destination:
                for chunk in image.chunks():
                    destination.write(chunk)

このままではだれでも投稿できるのでviews.pyのpost、postedメソッドに@login_requiredというアノテーションをつけ、superuserによるログインを必須にする。

views.py@login_required
def post(request):
    articleForm = ArticleForm()
    return render(request, "post.html", {"articleForm": articleForm})

@login_required
def posted(request):
    form = ArticleForm(request.POST, request.FILES)
    categories = Category.objects.all()
    if form.is_valid():
        article = form.save()
        os.makedirs(settings.BASE_DIR + "/media/article/" + str(article.id) + "/image")
        for image in request.FILES.getlist("image"):
            with open(settings.BASE_DIR + "/media/article/" + str(article.id) + "/image/" + image.name, "wb+") as destination:
                for chunk in image.chunks():
                    destination.write(chunk)
        category_list = article.category_split_space.split()
        for c in category_list:
            #新規カテゴリーを作成
            if len(categories.filter(name=c)) == 0:
                new_category = Category(name=c)
                new_category.save()
                article.category.add(new_category)
            else:
                category = categories.filter(name=c)[0]
                article.category.add(category)
        article.save()
        articles = Article.objects.all()
        return render(request, "blog.html", {"articles": articles})

superuser、loginの設定は下記参照
Django2 でユーザー認証(ログイン認証)を実装するチュートリアル -2- サインアップとログイン・ログアウト | ITエンジニアラボ
Using the Django authentication system | Django documentation | Django

markdownからhtmlへの変換

marked.jsとhilight.jsをCDNなどでダウンロードする。

    <!--CSS-->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.14.2/styles/vs2015.min.css">

    <!--JavaScript-->
    <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
        crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
        crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/0.6.0/marked.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.14.2/highlight.min.js"></script>

保存されたmarkdownファイルをmarked.jsとhilight.jsを用いて以下のようにhtmlへと変換している。
ところどころ装飾にbootstrapを用いている。

view.html{% extends "base.html" %}
{% block title%}
{{ article.title }}--banaTECH--
{% endblock %}

{% block content %}
<div class="container">
    <h1>{{ article.title }}</h1>
    <h2>{{ article.post_date }}</h2>
    <p>
    カテゴリー:
    {% for category in  article.category.all%}
    <a class="btn btn-primary" href="/blog/category/{{ category.name }}">{{ category.name }}</a>
    {% endfor %}
    </p>
    <div id="markdown_content" src="{{article.article.url}}"></div>

</div>
<script>
    $(document).ready(function () {
        var target = $("#markdown_content");
        var renderer = new marked.Renderer()
        renderer.code = function (code, language) {
            if(language.indexOf(":") != -1){
                var lang = language.split(":")[0];
                var fileName = language.split(":")[1].trim();
                return '<pre>' + '<div class="card text-white bg-info" style="display: inline-block;"> <div class="card-body p-0" style="display: inline-block;">' + fileName + ' ' + '</div></div>' + '<code class="hljs">' + hljs.highlightAuto(code, [lang]).value + '</code></pre>';
            }else{
                return '<pre' + '><code class="hljs">' + hljs.highlightAuto(code).value + '</code></pre>';
            }
        };
        renderer.image = function (href, title, text) {
            var fileName = href.split("/").pop();
            return '<img src="/media/article/{{ article.id }}/image/'+ fileName +'" alt="'+ text +'" class="img-fluid">';
        }
        marked.setOptions({
            renderer: renderer,
            sanitize: true,
            breaks: true
        });

        $.ajax({
                url: target[0].attributes["src"].value
            })
            .then(
                function (data) {
                    target.append(marked(data));
                },
                function () {
                    target.append("This content failed to load.");
                }
            );
    });
</script>
{% endblock %}
<div id="markdown_content" src="{{article.article.url}}"></div>

の部分がmarkdownで書かれた記事になる。
以下の部分でQiitaのように:で区切ればファイル名を表示できるようにしている(bootstrapで装飾)

view.htmlrenderer.code = function (code, language) {
            if(language.indexOf(":") != -1){
                var lang = language.split(":")[0];
                var fileName = language.split(":")[1].trim();
                return '<pre>' + '<div class="card text-white bg-info" style="display: inline-block;"> <div class="card-body p-0" style="display: inline-block;">' + fileName + ' ' + '</div></div>' + '<code class="hljs">' + hljs.highlightAuto(code, [lang]).value + '</code></pre>';
            }else{
                return '<pre' + '><code class="hljs">' + hljs.highlightAuto(code).value + '</code></pre>';
            }
        };

以下の部分で画像のパスを修正している。ファイル名は保存してあるものと同じものにする必要があり。

view.htmlrenderer.image = function (href, title, text) {
            var fileName = href.split("/").pop();
            return '<img src="/media/article/{{ article.id }}/image/'+ fileName +'" alt="'+ text +'" class="img-fluid">';
        }

以下の部分でcodeとimageの設定を適用し、ついでにサニタイズとbrタグによる改行をオンにする。

view.htmlmarked.setOptions({
            renderer: renderer,
            sanitize: true,
            breaks: true
        });

CDNの以下の部分を変更することでソースコードのシンタックスハイライトのテーマを変更することができる。

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.14.2/styles/vs2015.min.css">

テーマ一覧
highlight.js demo

まとめ

最終的に以下のようQiitaライクのブログを作れた。もう少しデザインや機能を追加したら公開したい。
article.png

現在はほぼ同じ方法を使ってLaravelに移行しています(このページ)
Djangoでのブログに関するコード(github)
https://github.com/bana118/banatech_django/tree/master/banaTECH/blog