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

作成: 2019年02月25日

更新: 2020年08月23日

やりたいこと

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

どうやって実現するか

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

投稿フォーム

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

forms.py
from 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.py
def 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.py
from 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.py
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/'

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

views.py
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})

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

views.py
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)

このままではだれでも投稿できるので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.html
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>'; } };

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

view.html
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">'; }

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

view.html
marked.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
追記
公開してみた
https://banatech.jpn.ph/blog
さらに追記
現在はほぼ同じ方法を使ってLaravelに移行しています(このページ)
Djangoでのブログに関するコード(github)
https://github.com/bana118/banatech_django/tree/master/banaTECH/blog