2021-02-18

2020-10-31

技術ノート

eyecatch
minbase.io 構築シリーズのアプリケーション編その1です。Django を使って技術ブログを構築していきます。まずはモデルを作ります。

はじめに

シリーズ「Amazon EC2, Docker, Django で技術ブログを作った」の第2部、アプリケーション編その1です。いよいよ本筋です。Django で技術ブログを作成していきますが、流れとしてまずはモデルを作ります。テーブル数が多いので記事の構成をどうするか考えましたが、モデルはすべて本記事で紹介することにしました。

当サイトは Docker 上で動作しており、その環境構築に関してはインフラ編その4で紹介しています。詳細は以下をご覧ください。

ソースコードはこちらのリポジトリで公開しています。現状では readme とかまったく整備していません。そのうちがんばります。

requirements.txt を再掲します。以降、これらのモジュールがインストールされている前提で話を進めます。


Django==2.2.16
django-bootstrap4==2.2.0
django-cleanup==5.0.0
django-cloudinary-storage==0.2.3
django-debug-toolbar==2.2
django-environ==0.4.5
django-summernote==0.8.11.6
djangorestframework==3.11.1
google-api-python-client==1.10.0
Janome==0.3.10
oauth2client==4.1.3
Pillow==7.2.0
psycopg2==2.8.5
psycopg2-binary==2.8.5
pyOpenSSL==19.1.0
uWSGI==2.0.19.1

モデル分類

モデルの構成を説明するに当たって、便宜的に分類してみたものが以下の表になります。これはあくまで後付けで分類したもので、深い意味はありません。

分類 説明
ベースモデル すべてのモデルが継承する抽象基底クラス
ブログモデル ブログコンテンツを格納するモデル
サイト管理モデル ブログコンテンツと直接紐づかない管理用のモデル
レポートモデル レポーティング API の受け皿となるモデル

ベースモデル

まずはほぼすべてのモデルに継承している、抽象基底クラスとしてのベースモデルです。

Base


import uuid
・・・

""" ベース | 基底クラス """
class Base(models.Model):
    class Meta:
        abstract = True
        ordering = ['-created_at', '-updated_at']

    """ uuid からハイフンを削除して文字列化 """
    def get_str_uuid():
        return uuid.uuid4().hex

    id = models.CharField(primary_key=True, default=get_str_uuid, max_length=33, editable=False, unique=True)
    created_at = models.DateTimeField(verbose_name='作成日', auto_now_add=True)
    updated_at = models.DateTimeField(verbose_name='更新日', auto_now=True, null=True)

このベースモデルは非常に重要な役割を持っています。それは、デフォルトではインクリメントされた数字である ID を UUID に変換することです。好みの問題ですが、私は記事の ID がインクリメントされた数字であることがあまり好きではありません。ID に順番としての意味を持たせずに日付フィールドでソートしたいですし、明示的に順番がほしい場合は別途IntegerFieldを定義します。また、削除時に欠番が生じる余地も残したくありません。ではどうするかというところで、UUID の出番です。UUID を生成して各レコードのユニーク ID として機能するようにしていきます。

まず uuid モジュールをインポートし、


import uuid

uuid を生成する関数を記述し、


def get_str_uuid():
    return uuid.uuid4().hex

ID をオーバーライドします。


id = models.CharField(primary_key=True, default=get_str_uuid, max_length=33, editable=False, unique=True)

uuid モジュールの使い方についてですが、ユニークな ID がほしいだけなら、このようにします。


>>> import uuid
>>> test = uuid.uuid4()
>>> print(test)

c4d2e04f-c163-4441-9506-0caf3d19ebab

型を見てみると、UUID 型です。


>>> print(type(test))

<class 'uuid.UUID'>

URI 等で汎用的に使えるように加工していきます。hexメソッドは、32文字の16進数文字列で UUID を生成します。ハイフンも削除され文字列化されるので、ユニーク ID としては最適かと思います。


>>> test = uuid.uuid4().hex
>>> print(test)

998ef5fb1cc34311b7c604509abae8da

>>> print(type(test))

<class 'str'>

作成日と更新日はすべてのモデルが持っていてもいいと思うので、べースモデルに書いてしまいます。


created_at = models.DateTimeField(verbose_name='作成日', auto_now_add=True)
updated_at = models.DateTimeField(verbose_name='更新日', auto_now=True, null=True)

これで、Base を継承したモデルは ID が UUID 化し、作成日と更新日が自動で設定されるようになりました。

ブログモデル

ブログの骨子となる、ブログモデルです。ひとつずつ見ていきます。

Category

カテゴリは「技術ノート」「雑記・メモ」「まとめ記事」のような、そもそもの分類を表します。表示順を明示的に管理したいので、ソート用のindexフィールドを定義しています。また URI に日本語を使うのがイヤなので、slugフィールドを定義しています。


""" カテゴリ | Post と1対多で紐づく """
class Category(Base):
    class Meta:
        db_table = 'category'
        verbose_name = 'カテゴリー'
        verbose_name_plural = 'カテゴリー'
        ordering = ['index']

    index = models.IntegerField(verbose_name='並び順', null=True, blank=True)
    name = models.CharField(verbose_name='カテゴリ名', max_length=255)
    slug = models.SlugField(verbose_name='スラッグ', max_length=255, null=True, blank=True)
    description = models.TextField(verbose_name='説明', blank=True, null=True)

    def __str__(self):
        return '{}: {}'.format(self.index, self.name)

Tag

タグは「AWS」「Python」「Django」のような、ひとつの記事に対して複数つけられる、フィルタ用の識別子です。記事とタグは多対多で紐づきます。また、集計のときにしか使っていませんが、親カテゴリーという概念があります。あとは、カテゴリと同じようにslugフィールドを定義しています。


""" タグ | Post と多対多で紐づく """
class Tag(Base):
    class Meta:
        db_table = 'tag'
        verbose_name = 'タグ'
        verbose_name_plural = 'タグ'
        ordering = ['category', 'slug']

    name = models.CharField(verbose_name='タグ名', max_length=255)
    slug = models.SlugField(verbose_name='スラッグ', max_length=255, null=True, blank=True)
    category = models.ForeignKey(Category, verbose_name='親カテゴリー', null=True, blank=True, on_delete=models.SET_NULL)

    def __str__(self):
        return '{}: {}'.format(self.category, self.name)

Post

ブログの核となる、記事モデルです。ポイントを見ていきます。


""" 記事 | メイン """
class Post(Base):
    class Meta:
        db_table = 'post'
        verbose_name = '記事'
        verbose_name_plural = '記事'
        ordering = ['-created_at']

    """ ダミー日時の指定 """
    default_datetime = timezone.make_aware(timezone.datetime(year=1970, month=1, day=1, hour=0))

    is_public = models.BooleanField(verbose_name='公開フラグ', default=False)
    published_at = models.DateTimeField(verbose_name='投稿日', default=default_datetime, help_text='Null を回避するため、公開フラグを False で保存した場合はダミー日時がセットされます。その後、公開したときに日時が上書きされます。')
    title = models.CharField(verbose_name='タイトル', max_length=128)
    subtitle = models.CharField(verbose_name='サブタイトル', max_length=128, null=True, blank=True)
    category = models.ForeignKey(Category, verbose_name='カテゴリー', null=True, blank=True, on_delete=models.SET_NULL)
    tag = models.ManyToManyField(Tag, verbose_name='タグ', blank=True)
    related_posts = models.ManyToManyField('self', verbose_name='関連記事', blank=True)
    eyecatch = models.ImageField(verbose_name='アイキャッチ画像', upload_to='image/post/eyecatch/%Y/%m/%d/', default='image/post/eyecatch/default/default.png',null=True, blank=True)
    description = models.TextField(verbose_name='説明', blank=True, null=True)
    text = models.TextField(verbose_name='本文', blank=True, null=True)

    """ 投稿日をセット """
    def save(self, *args, **kwargs):
        if self.is_public and self.published_at == self.default_datetime:
            self.published_at = timezone.now()
        super().save(*args, **kwargs)

    """ メタキーワード """
    def get_keyword(self):
        meta_keyword = ','.join(tag.name for tag in self.tag.all())
        return meta_keyword

    def __str__(self):
        if self.subtitle:
            return '{}: {} - {}'.format(self.category, self.title, self.subtitle)
        else:
            return '{}: {}'.format(self.category, self.title)

記事モデルでとりわけ大事なのは、公開済みかどうかを表す公開フラグis_publicでしょう。記事モデルを扱うクエリセットでは必ず気にする必要が出てきます。本記事では触れませんが、制御としてはis_public=Trueなら記事一覧で参照可能、is_public=Falseならログイン済みの場合のみ下書き一覧で参照可能、という実装にしています。よくあるパターンですね。


is_public = models.BooleanField(verbose_name='公開フラグ', default=False)

公開フラグに関連して、もう一つ大事な概念に「投稿日」があります。投稿された順でソートするのに使います。どういう実装になっているかですが、少し特殊なことをしています。以下が関連部分です。


・・・
""" ダミー日時の指定 """
default_datetime = timezone.make_aware(timezone.datetime(year=1970, month=1, day=1, hour=0))

is_public = models.BooleanField(verbose_name='公開フラグ', default=False)
published_at = models.DateTimeField(verbose_name='投稿日', default=default_datetime, help_text='・・・')
・・・

""" 投稿日をセット """
def save(self, *args, **kwargs):
    if self.is_public and self.published_at == self.default_datetime:
        self.published_at = timezone.now()
    super().save(*args, **kwargs)

こういった実装の場合、is_publicのデフォルト値をFalseにしておき、手動でis_public=Trueとしたときにpublished_atに現在の日付が設定されるようにsaveメソッドをオーバーライドする方法が考えられます。以下のような形です。


・・・
is_public = models.BooleanField(verbose_name='公開フラグ', default=False)
published_at = models.DateTimeField(verbose_name='投稿日', blank=True, null=True)
・・・

""" 投稿日をセット """
def save(self, *args, **kwargs):
    if self.is_public and not self.published_at:
        self.published_at = timezone.now()
    super().save(*args, **kwargs)

このままでも十分機能しますし、特に問題があるわけではないのですが、実装を進めていく中で、投稿日順でページネーションを実装しようとしたときに問題が顕在化しました。当サイトでは、ページネーションを実装するのにget_previous_by_[DateTimeField]get_next_by_[DateTimeField]を使っています。ここからが問題なのですが、コイツらは指定する日付フィールドにnull=Trueが定義されていると使用できないようです。published_atは下書き状態では null であることが前提なので、このままだと投稿日順でページネーションが実装できません。そこで考えたのが、投稿日にあらかじめデフォルト日付をセットしておく方法です。

実装を見ていきます。まずデフォルト日付を定義します。今回は以下のようにきっちり JST の 1970-01-01 にしていますが、実際は null を回避したいだけです。


from django.utils import timezone
・・・

default_datetime = timezone.make_aware(timezone.datetime(year=1970, month=1, day=1, hour=0))

published_atのデフォルト値がdefault_datetimeになるようにします。管理サイトに登録したときのことを考えて、ヘルプテキストも書いておいたほうが丁寧ですね。


published_at = models.DateTimeField(
    verbose_name='投稿日',
    default=default_datetime,
    help_text='Null を回避するため、公開フラグを False で保存した場合はダミー日時がセットされます。その後、公開したときに日時が上書きされます。'
)

saveメソッドをオーバーライドします。公開済み、かつ投稿日がデフォルト値の場合は現在時刻で上書きするようにしています。


def save(self, *args, **kwargs):
    if self.is_public and self.published_at == self.default_datetime:
        self.published_at = timezone.now()
    super().save(*args, **kwargs)

管理サイトで記事を書きあげたとき、記事を公開するためにis_public(公開フラグ)をオンにして保存ボタンを押下しますが、その際、初めて公開する記事であれば条件に合致するので、デフォルト日付を現在日付で上書きして公開します。一度公開した記事を下書きにひっこめていて再度公開するといった場合は、すでに投稿日がデフォルト値ではなくなっているので条件外となり、投稿日はそのままとなります。

記事詳細ページのメタキーワードは、付与されたタグの一覧をカンマ区切りテキストに変換して使うようにしています。


def get_keyword(self):
    meta_keyword = ','.join(tag.name for tag in self.tag.all())
    return meta_keyword

よく見かける機能として、related_posts(関連記事)があります。自分自身(記事モデル)にリレーションを張って、多対多で関連する記事を表示できるようにしています。


related_posts = models.ManyToManyField('self', verbose_name='関連記事', blank=True)

あとは基本的にブログ投稿用のモデルとしてよくある感じなのですが、もしかしたらあまり見ないかもしれない特色としてサブタイトルを実装しています。


title = models.CharField(verbose_name='タイトル', max_length=128)
subtitle = models.CharField(verbose_name='サブタイトル', max_length=128, null=True, blank=True)

当サイトはシリーズものが多いので、タイトルだけだと管理面でなにかと煩雑になります。そこで思い切ってサブタイトルを追加しました。記事詳細ページの見出し等ですっきり表示できています。表示は以下のようにしてコントロールしています。


def __str__(self):
    if self.subtitle:
        return '{}: {} - {}'.format(self.category, self.title, self.subtitle)
    else:
        return '{}: {}'.format(self.category, self.title)

Image

画像モデルです。記事本文に挿入する画像を管理します。管理サイト上、記事モデルにインラインで紐づける前提のモデルです。


""" 画像 | 記事の本文で利用 """
class Image(Base):
    class Meta:
        db_table = 'image'
        verbose_name = '画像'
        verbose_name_plural = '画像'
        ordering = ['index']

    index = models.IntegerField(verbose_name='並び順', null=True, blank=True)
    post = models.ForeignKey(Post, verbose_name='記事', on_delete=models.PROTECT)
    title = models.CharField('タイトル', max_length=255, blank=True, help_text='画像の alt 属性として利用されます')
    image = models.ImageField(verbose_name='画像', upload_to='image/post/text/%Y/%m/%d/', null=True, blank=True, help_text='保存後、本文挿入用 HTML を生成します')

    def __str__(self):
        return '本文挿入用 HTML: <img src="" data-src="{}" class="lozad w-100" alt="{}">'.format(self.image.url, self.title)

管理サイトで保存ボタンを押下したときに HTML タグが表示されるようにしています。これをコピーして本文にペーストすることで画像を挿入します。その際、titlealtタグに利用されます。


def __str__(self):
    return '本文挿入用 HTML: <img src="" data-src="{}" class="lozad w-100" alt="{}">'.format(self.image.url, self.title)

admin.pyはこう。


from django.contrib import admin
・・・

class ImageInline(admin.StackedInline):
    model = Image
    ordering = ('index',)
    extra = 3
    readonly_fields = ('created_at', 'updated_at')
・・・

class PostAdmin(admin.ModelAdmin):
    model = Post
    inlines = [ImageInline]
    ・・・

Link

リンクモデルです。記事詳細ページ下部の「参考リンク」が格納されています。こちらも管理サイト上、記事モデルにインラインで紐づける前提のモデルです。


from urllib.parse import urlparse
・・・

""" 外部リンク | 記事の本文で利用 """
class Link(Base):
    class Meta:
        db_table = 'link'
        verbose_name = '参考リンク'
        verbose_name_plural = '参考リンク'
        ordering = ['index']

    index = models.IntegerField(verbose_name='並び順', null=True, blank=True)
    post = models.ForeignKey(Post, verbose_name='記事', on_delete=models.PROTECT)
    link = models.URLField(verbose_name='URL', max_length=255, blank=True, help_text='URL を指定してください')
    description = models.TextField(verbose_name='説明', blank=True, null=True)

    def __str__(self):
        self.domain = urlparse(self.link).netloc
        return 'ドメイン : {} | URL: {}'.format(self.domain, self.link)

admin.pyはこう。Image モデルとおんなじです。


from django.contrib import admin
・・・

class LinkInline(admin.StackedInline):
    model = Link
    ordering = ('index',)
    extra = 3
    readonly_fields = ('created_at', 'updated_at')
・・・

class PostAdmin(admin.ModelAdmin):
    model = Post
    inlines = [ImageInline, LinkInline]
    ・・・

真面目にリンクを書くことで、あとあと最強の参考リンク集になることうけあいです。

サイト管理モデル

当サイトでは Django に付属する sites フレームワークを利用しています。settings.pyに必要な設定を書くことで、Site モデル内の1レコードに対して1サイトを紐づけることができます。Site はdomainnameのみの簡単なモデルですが、これに自作のモデルをインラインで紐づけることで、サイト全体を管理するためのモデルとして実装しています。まずは sites フレームワークを利用するための前準備から見ていきます。

settings.pyですが、まずINSTALLED_APPSに sites フレームワークを登録します。


INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.contrib.sites', # --- コイツを入れる必要がある
    'blog.apps.BlogConfig', # --- 自分のサイト
    ・・・
]

Site モデルで扱うためのユニーク ID を指定します。settings.pyに以下を追記します。


SITE_ID = 1

この設定でmigrateすることで、Site モデルの中に以下のような ID=1 のレコードが生成されているはずです。


>>> from django.contrib.sites.models import Site
>>> test = Site.objects.all()
>>> test.values()

<QuerySet [{'id': 1, 'domain': 'example.com', 'name': 'example.com'}]>

これで前準備は完了です。紐づけるモデルを作りましょう。

SiteDetail

サイト詳細モデルです。メタデータ、Google アナリティクスのトラッキングコード、Google アドセンスの広告コード、GitHub の URL 等をモデルで管理できるようにしています。


""" サイト詳細 | サイトのインラインで利用 """
class SiteDetail(Base):
    class Meta:
        db_table = 'sitedetail'
        verbose_name = 'サイト詳細'
        verbose_name_plural = 'サイト詳細'

    site = models.OneToOneField(Site, verbose_name='サイト', on_delete=models.PROTECT)
    keyword = models.TextField(verbose_name='メタキーワード', blank=True, null=True, default='・・・')
    description = models.TextField(verbose_name='メタデスクリプション', blank=True, null=True, default='・・・')
    google_analytics_html = models.TextField(verbose_name='Google Analitics', blank=True)
    google_adsence_html = models.TextField(verbose_name='Google AdSense', blank=True)
    github = models.CharField(verbose_name='GitHub URL', max_length=255, blank=True)

    def __str__(self):
        return '「{}」の基本情報を編集します。'.format(self.site.name)

Site に紐づけます。ほかの項目はご自由にどうぞ。


site = models.OneToOneField(Site, verbose_name='サイト', on_delete=models.PROTECT)

次にadmin.pyですが、Site モデルに対してインラインで表示されるようにします。


class SiteDetailInline(admin.StackedInline):
    model = SiteDetail
・・・

class SiteAdmin(admin.ModelAdmin):
    model = Site
    inlines = [SiteDetailInline]

AboutSite

サイト紹介モデルです。サイトの紹介文、サイトイメージ、管理者の紹介文等を管理します。これも Site に紐づけます。


""" このサイトについて | サイトのインラインで利用 """
class AboutSite(Base):
    class Meta:
        db_table = 'about_site'
        verbose_name = 'このサイトについて'
        verbose_name_plural = 'このサイトについて'

    site = models.OneToOneField(Site, verbose_name='サイト', on_delete=models.PROTECT)
    author_image = models.ImageField(verbose_name='管理者のイメージ', upload_to='image/site/author_image/', blank=True, null=True)
    site_image = models.ImageField(verbose_name='サイトのイメージ', upload_to='image/site/site_image/', blank=True, null=True)
    author_name = models.CharField(verbose_name='管理者の名前', max_length=255, blank=True, null=True)
    author_text = models.TextField(verbose_name='管理者の説明', blank=True, null=True)
    site_text = models.TextField(verbose_name='サイトの説明', blank=True, null=True)

    def __str__(self):
        return '「{}」の紹介や管理者の紹介を編集します。'.format(self.site.name)

admin.pyです。同じく Site モデルに対してインラインで表示されるようにします。


class AboutSiteInline(admin.StackedInline):
    model = AboutSite
・・・

class SiteAdmin(admin.ModelAdmin):
    model = Site
    inlines = [SiteDetailInline, AboutSiteInline]

PrivacyPolicy

プライバシーポリシー管理用のモデルです。中身は単一のテキストフィールドのみです。これも Site に紐づけます。なぜこのように紐づけるモデルを複数に分割しているかというと、それぞれの「更新日」をきっちり管理するためです。プライバシーポリシーなどは更新日を明示したほうがよいでしょう。


""" プライバシーポリシー | サイトのインラインで利用 """
class PrivacyPolicy(Base):
    class Meta:
        db_table = 'privacy_policy'
        verbose_name = 'プライバシーポリシー'
        verbose_name_plural = 'プライバシーポリシー'

    site = models.OneToOneField(Site, verbose_name='サイト', on_delete=models.PROTECT)
    text = models.TextField(verbose_name='本文', blank=True, null=True)

    def __str__(self):
        return '「{}」のプライバシーポリシーを編集します。'.format(self.site.name)

admin.pyです。同じく Site モデルに対してインラインで表示されるようにします。


class PrivacyPolicyInline(admin.StackedInline):
    model = PrivacyPolicy
・・・

class SiteAdmin(admin.ModelAdmin):
    model = Site
    inlines = [SiteDetailInline, AboutSiteInline, PrivacyPolicyInline]

これで、以下のような感じでサイト全体の設定を1画面で管理することが可能となりました。

SiteAdmin 念のための注意事項ですが、当サイトは summernote を使っていて、Site モデルのインラインでも複数取り入れています。が、当記事では summernote の実装に関する説明は省いています。この記事の内容をトレースした場合、テキストフィールドの見え方が上記の画像とは一部異なりますので、その点はご了承ください。

Snippet

当サイトではスニペット一覧ページを実装しています。


""" スニペット | 本文編集用 """
class Snippet(Base):
    class Meta:
        db_table = 'snippet'
        verbose_name = 'スニペット'
        verbose_name_plural = 'スニペット'

    index = models.IntegerField(verbose_name='並び順', null=True, blank=True)
    name = models.CharField(verbose_name='名前', max_length=255, blank=True, null=True)
    text = models.TextField(verbose_name='内容', blank=True, null=True)

    def __str__(self):
        return '{}: {}'.format(self.index, self.name)

記事の本文を編集する際、前述のように summernote という WYSIWYG エディタを使っているのですが、結局 HTML を直接書くことのほうが多くなっています。毎回同じ HTML コードをコピペするのに自分の PC にテキストをまとめておいて・・・ということはしたくなかったので、コードの断片を一覧化してすぐにコピペできるようにしました。その実装については別記事で触れたいと思います。モデル自体はシンプルですね。

レポートモデル

レポートページにグラフやワードクラウドを表示しているのですが、これはバッチ処理用の API を実装して毎週決まった時間に実行し、その結果を DB に格納することで実現しています。なおテーブルの中身は毎回洗い替えをしています。API の実装内容については別記事に譲りますが、今回の趣旨としてモデル構成を見ていきます。

PopularPost

いわゆる人気記事の一覧です。Google Analytics Reporting API から取得した内容を格納するためのモデルですが、少し工夫しています。


from django_cleanup import cleanup
・・・

""" 人気記事 | Google Analytics Reporting API から取得して格納する """
@cleanup.ignore
class PopularPost(Base):
    class Meta:
        db_table = 'popular_post'
        verbose_name = '人気記事'
        verbose_name_plural = '人気記事'

    title = models.CharField(verbose_name='タイトル', max_length=128)
    link = models.CharField(verbose_name='リンク', max_length=255)
    page_view = models.IntegerField(verbose_name='ページビュー')
    detail_pk = models.CharField(verbose_name='記事の ID', max_length=255, null=True, blank=True)
    detail_is_public = models.BooleanField(verbose_name='記事の公開フラグ', default=False)
    detail_published_at = models.DateTimeField(verbose_name='記事の投稿日', null=True, blank=True)
    detail_category = models.CharField(verbose_name='記事のカテゴリー', max_length=255, null=True, blank=True)
    detail_title = models.CharField(verbose_name='記事のタイトル', max_length=255, null=True, blank=True)
    detail_subtitle = models.CharField(verbose_name='記事のサブタイトル', max_length=255, null=True, blank=True)
    detail_eyecatch = models.ImageField(verbose_name='記事のアイキャッチ画像',null=True, blank=True)

    def save(self, *args, **kwargs):
        post_detail_pk = self.link.split('/')[-2]
        post = Post.objects.get(pk=post_detail_pk)
        self.detail_pk = post.pk
        self.detail_is_public = post.is_public
        self.detail_published_at = post.published_at
        self.detail_category = post.category.name
        self.detail_title = post.title
        self.detail_subtitle = post.subtitle
        self.detail_eyecatch = post.eyecatch
        super().save(*args, **kwargs)

    def __str__(self):
        return '{}'.format(self.title)

以下のフィールドは、Google Analytics Reporting API から直接取得しています。なお、取得対象 URL は API 側で記事詳細ページだけに絞っています。つまり、linkフィールドには "detail" で始まる URL しか格納されません。その前提でモデルを構成しています。


title = models.CharField(verbose_name='タイトル', max_length=128)
link = models.CharField(verbose_name='リンク', max_length=255)
page_view = models.IntegerField(verbose_name='ページビュー')

以下のフィールドは、saveメソッドで値を格納します。記事詳細ページのコンテンツの複写です。


detail_pk = models.CharField(verbose_name='記事の ID', max_length=255, null=True, blank=True)
detail_is_public = models.BooleanField(verbose_name='記事の公開フラグ', default=False)
detail_published_at = models.DateTimeField(verbose_name='記事の投稿日', null=True, blank=True)
detail_category = models.CharField(verbose_name='記事のカテゴリー', max_length=255, null=True, blank=True)
detail_title = models.CharField(verbose_name='記事のタイトル', max_length=255, null=True, blank=True)
detail_subtitle = models.CharField(verbose_name='記事のサブタイトル', max_length=255, null=True, blank=True)
detail_eyecatch = models.ImageField(verbose_name='記事のアイキャッチ画像',null=True, blank=True)

saveメソッドですが、以下の流れで実装しています。

  • API で取得したlinkフィールド(URL)から対象記事の ID を取得
  • 取得した ID で記事モデルから対象記事の情報を取得
  • 対象記事の各プロパティをマッピングして保存

def save(self, *args, **kwargs):
    post_detail_pk = self.link.split('/')[-2]  # --- API で取得した URL から ID を取得
    post = Post.objects.get(pk=post_detail_pk) # --- 取得した ID で記事モデルから対象記事の情報を取得
    self.detail_pk = post.pk                   # --- 対象記事の各プロパティをマッピング
    ・・・
    super().save(*args, **kwargs)              # --- 保存

当サイトではdjango_cleanupという便利なモジュールを使っています。これは使われなくなったメディアファイルを自動で削除してくれる便利なヤツです。settings.pyINSTALLED_APPSに書いておくだけで、サイト全体の不要なメディアファイルを削除してくれます。が、人気記事モデルでは敢えてオフにしています。理由は以下の通りです。流れで説明します。

  1. 人気記事モデルは、バッチ処理で API を実行して取得した内容を格納している
  2. 内容を格納するとき、前回の結果をすべて削除してから格納している(洗い替えしている)
  3. django_cleanupを導入しているので、洗い替えの際にsaveメソッドで複写していたeyecatchフィールドの先にある画像も削除される
  4. eyecatchフィールドが参照しているのは記事モデルの画像なので、前回の結果に紐づいていた記事モデルの画像も削除されてしまい、画像が表示されなくなる事象が発生した
  5. これを回避するために、人気記事モデルだけはdjango_cleanupを適用しないことにした

特定のモデルだけクリーンアップ対象から外すためには、以下のようにデコレータをつけてあげれば OK です。


from django_cleanup import cleanup # --- モジュール読み込んで
・・・
@cleanup.ignore # --- デコレータつける
class PopularPost(Base):
・・・

このモデルがいちばん特殊なことをしています。なにかもっといい方法があるような気もします。

CategoryPost

自作レポーティング API に関しては別記事に譲るつもりなので、ここからは駆け足で紹介するに留めます。CategoryPostはカテゴリごとの記事数を集計した結果を格納するモデルです。各カテゴリが記事をいくつ持っているかです。


""" 自作 Reporting API | 1. カテゴリ別記事数 """
class CategoryPost(Base):
    class Meta:
        db_table = 'category_post'
        verbose_name = 'カテゴリ別 : 記事数'
        verbose_name_plural = 'レポート - カテゴリ別 : 記事数'

    category = models.CharField(verbose_name='カテゴリ名', max_length=255)
    post_count = models.IntegerField(verbose_name='記事数', null=True, blank=True)

    def __str__(self):
        return '{}: {}'.format(self.category, self.post_count)

ちなみにモデルを書くときはverbose_nameverbose_name_pluralを明示的に書いていて、レポートモデルではそれが活きています。verbose_name_pluralは、管理サイトの見出しで使われます。並び順を考慮してプレフィックスを "レポート - " とすることで、以下の図のように管理サイト上ですっきり並びます。

verbose_name_plural

verbose_nameは HTML テンプレート上で参照できるようにテンプレートタグを実装しているのですが、それはまたの機会に取り上げます。いずれにせよ区別して使い分けています。

CategoryTag

CategoryTagはカテゴリごとのタグ数を集計した結果を格納するモデルです。各カテゴリがタグをいくつ持っているかです。この集計に意味があるかどうかは不明です。。


""" 自作 Reporting API | 2. カテゴリ別タグ数 """
class CategoryTag(Base):
    class Meta:
        db_table = 'category_tag'
        verbose_name = 'カテゴリ別 : タグ数'
        verbose_name_plural = 'レポート - カテゴリ別 : タグ数'

    category = models.CharField(verbose_name='カテゴリ名', max_length=255, null=True, blank=True)
    tag_count = models.IntegerField(verbose_name='タグ数', null=True, blank=True)

    def __str__(self):
        return '{}: {}'.format(self.category, self.tag_count)

TagPost

TagPostはタグごとに紐づいている記事数を集計した結果を格納するモデルです。どんな記事が多いか、傾向がわかります。


""" 自作 Reporting API | 3. タグ別記事数 """
class TagPost(Base):
    class Meta:
        db_table = 'tag_post'
        verbose_name = 'タグ別 : 記事数'
        verbose_name_plural = 'レポート - タグ別 : 記事数'

    tag = models.CharField(verbose_name='タグ名', max_length=255, null=True, blank=True)
    post_count = models.IntegerField(verbose_name='記事数', null=True, blank=True)

    def __str__(self):
        return '{}: {}'.format(self.tag, self.post_count)

MonthPost

MonthPostはカテゴリごとの月次記事数の推移です。折れ線グラフを実装したかったので作成しました。


""" 自作 Reporting API | 4. 月別記事数 """
class MonthPost(Base):
    class Meta:
        db_table = 'month_post'
        verbose_name = 'カテゴリ別 : 記事数推移'
        verbose_name_plural = 'レポート - カテゴリ別 : 記事数推移'

    month = models.CharField(verbose_name='年月', max_length=255, null=True, blank=True)
    category = models.CharField(verbose_name='タグ名', max_length=255, null=True, blank=True)
    post_count = models.IntegerField(verbose_name='記事数', null=True, blank=True)

    def __str__(self):
        return '{}: {}'.format(self.month, self.category, self.post_count)

WordCloud

WordCloudは、記事本文から単語を抽出してワードクラウドに表示しています。日本語形態素解析に関してはかなり試行錯誤したので、その過程も記事にできればなと考えています。


""" 自作 Reporting API | 5. ワードクラウド """
class WordCloud(Base):
    class Meta:
        db_table = 'word_cloud'
        verbose_name = 'ワードクラウド'
        verbose_name_plural = 'レポート - ワードクラウド'

    word = models.CharField(verbose_name='ワード', max_length=255, null=True, blank=True)
    word_count = models.IntegerField(verbose_name='出現数', null=True, blank=True)

    def __str__(self):
        return '{}: {}'.format(self.word, self.word_count)

レポートモデル、集計ロジックを紹介せずにモデルだけ紹介してもなんの意味もないな・・・

管理サイトへのモデル登録

これらのモデルは当然、管理サイトで管理・編集・閲覧することになるので、管理サイトへの登録についてもかいつまんで説明します。基本は以下のように、モデルを指定して必要なオプションを設定し、最後に登録します。


class CategoryAdmin(admin.ModelAdmin):
    model = Category
    
    # --- モデルごとの一覧ページでカラムを入れる項目を指定
    list_display = (
      'id', 'created_at', 'updated_at', 'index', 'name', 'slug', 'description'
    )

    # --- デフォルトソートキー
    ordering = ('index',)

    # --- 検索対象カラム
    search_fields = ('name',)

    # --- 通常非表示だけど表示だけしたい読み取り専用項目
    readonly_fields = (
      'id', 'created_at', 'updated_at'
    )
・・・

# 登録
admin.site.register(Category, CategoryAdmin)

あとはいじっているところだけ見ていきましょう。とりわけ記事モデルです。こんな感じです。コメントで雰囲気はわかると思います。


class PostAdmin(admin.ModelAdmin):
    model = Post
    inlines = [ImageInline, LinkInline]
    list_display = (
      'is_public', 'id', 'created_at', 'updated_at', 'published_at',
      'category', 'title', 'subtitle', 'truncate_desc', 'preview'
    )
    list_display_links = ('id', 'preview')
    ordering = ('-created_at',)
    list_filter = ('is_public', 'category')
    search_fields = ('title', 'text')
    filter_horizontal = ('tag', 'related_posts') # --- 多対多フィールドの UI を見やすく変更
    actions = ['bulk_publish', 'bulk_unpublish'] # --- 自作アクションを追加
    readonly_fields = ('id', 'created_at', 'updated_at')

    # 多対多フィールドの表示順をカスタマイズする
    def formfield_for_manytomany(self, db_field, request, **kwargs):
        if db_field.name == 'tag':
            kwargs['queryset'] = Tag.objects.order_by('category__index', 'slug')
        if db_field.name == "related_posts":
            kwargs['queryset'] = Post.objects.order_by('category__index', 'title')
        return super().formfield_for_manytomany(db_field, request, **kwargs)

    # リスト表示の際にアイキャッチ画像のプレビュー画面を表示する
    def preview(self, obj):
        return mark_safe('<img src="{}" style="width:120px;height:auto;">'.format(obj.eyecatch.url))

    # 文字数が多い場合に指定文字数で切り詰める
    def truncate_desc(self, obj):
        count = 48
        if len(obj.description) <= count:
            return obj.description
        else:
            return obj.description[:count] + '…'           

    # 一括で複数記事を公開するアクションを追加する
    def bulk_publish(self, request, queryset):
        queryset.update(is_public=True)

    # 一括で複数記事を非公開にするアクションを追加する
    def bulk_unpublish(self, request, queryset):
        queryset.update(is_public=False)

    truncate_desc.short_description = '説明'
    preview.short_description = 'プレビュー'
    preview.allow_tags = True
    bulk_publish.short_description = '選択された 記事 を公開扱いにする'
    bulk_unpublish.short_description = '選択された 記事 を非公開扱いにする'

管理画面の使い勝手カスタマイズはけっこう楽しいので、試行錯誤しながらいじくり倒してみてはいかがでしょうか。

おわりに

ひとつの記事を長くしてしまうと、息切れしますね。今回、超絶参考にさせていただいたサイトのリンク集を以下にまとめてあるのでご確認ください。次回はどんな感じで記事を書くかまったく決めていませんが、書ける気がしたら書くと思います。