DRF, Vue.js, Dockerでmarkdownブログを作ったメモ ①

# 今回やる事
DRF, Vue, Dockerでmarkdownで書けるブログを作ったので、主に自分用メモとして残します。
参考程度にしてもらえたらうれしいです。

今回は、記事一覧が返るAPIの作成までまとめます。


# 環境
python 3.7.9
django 2.2.5
vue/cli 4.5.10
vue 2.6.12
docker 20.10.7


# 環境構築

環境構築は以前まとめた記事があるので、今回は省略します。
[DRF & Vue.js & Dockerでの環境構築](https://qiita.com/kRysTasis/items/afdb2fad6ae6a3522437 "DRF & Vue.js & Dockerでの環境構築") 

※上記の記事ではalpineのイメージを使用していますが、今回はUbuntuのイメージを使っているのrequirements.txtでのコマンドがapt-getになります。


## 使用するライブラリ

上記の環境構築で書いたもの以外に今回はこんな感じのものを使用します。


・Markdown（DRF）
　マークダウン記法のものをHTMLに変換してくれるライブラリ

・django-mdeditor（DRF）
　djangoの管理画面でmarkdownエディターが使えるようになるライブラリ

・highlight.js（Vue）
　HTML上のソースコードをハイライト表示してくれるライブラリ

__Markdownとdjango-mdeditorをAPI側のrequirementx.txtに追加しインストールし、
highlight.jsは以下のサイトを参考に導入します。__

https://laboradian.com/how-to-use-highlightjs/



## 実装

それぞれのコンテナに対しては、環境構築の記事を参考に、基本的な設定までは済ませている前提で進めていきます。

それでは記事一覧が返るAPIから作っていきます。


### APIの作成

#### モデル定義

今回は個人ブログなので、ユーザーはsuperuserの自分だけの前提で定義します。


```api/myblog/db/core/models.py
# created_at, updated_atは良く使うので抽象モデルとして定義しておきます。

from django.db import models

class TimeStampModel(models.Model):

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True
```

```api/myblog/db/utils/function.py
# 良く使いそうなメソッドはここにまとめておきます。

def _get_latest_post(queryset):
    return queryset.filter(is_public=True).order_by('created_at')[:5]
```


```api/myblog/models.py
from django.db import models
from django.contrib.auth.models import PermissionsMixin
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.utils.translation import ugettext_lazy as _
from django.core.mail import send_mail
from django.utils.safestring import mark_safe
from .db.core.models import (
    TimeStampModel,
)
from .db.utils.functional import (
    _get_latest_post
)
import os, uuid, logging

from mdeditor.fields import MDTextField
import markdown

logger = logging.getLogger(__name__)

CATEGORIES = [
    {
        'name': '未分類',
        'slug': 'other'
    },
    # ここにデフォのカテゴリを追加します。
]

TAGS = [
    {
        'name': 'django',
        'slug': 'django'
    },
    # ここにデフォのタグを追加します。
]

def createCategories(user):
    logger.debug('カテゴリを生成します。')
    for category in CATEGORIES:
        logger.debug(category)
        try:
            Category.objects.create(
                name=category['name'],
                user=user,
                slug=category['slug']
            )
        except Exception as e:
            logger.error(e)

def createTag(user):
    logger.debug('タグを生成します。')
    for tag in TAGS:
        try:
            Tag.objects.create(
                name=tag['name'],
                user=user,
                slug=tag['slug']
            )
        except Exception as e:
            logger.error(e)


def get_default_blog_name():
    user = mUser.objects.get(is_superuser=True)
    blog = Blog.objects.get(user=user)
    return blog

def get_default_user():
    user = mUser.objects.get(is_superuser=True)
    return user


def get_default_category_name():
    return Category.objects.get(name='未分類')


class AbstractBaseModel(TimeStampModel):

    class Meta:
        abstract = True


class UserManager(BaseUserManager):

    use_in_migrations = True

    def _create_user(self, username, email, password, **extra_fields):

        if not username:
            raise ValueError('Username is required field')

        email = self.normalize_email(email)
        username = self.model.normalize_username(username)
        user = self.model(username=username, email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)

        blog = Blog.objects.create(title='myblog', user=user)
        category = Category.objects.create(name='未分類', user=user, slug='other')
        createCategories(user)
        createTag(user)

        return user

    def create_user(self, username, email, password=None, **extra_fields):

        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(username, email, password, **extra_fields)

    def create_superuser(self, username, email=None, password=None, **extra_fields):

        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)

        if extra_fields.get('is_staff') is not True:
            raise ValueError('Superuser must have is_staff=True')
        if extra_fields.get('is_superuser') is not True:
            raise ValueError('Superuser must have is_superuser=True')
        return self._create_user(username, email, password, **extra_fields)


class mUser(AbstractBaseUser, PermissionsMixin, TimeStampModel):

    username = models.CharField(_('Username'), max_length=70, unique=True, blank=True, null=True)
    email = models.EmailField(_('Email'), max_length=70, unique=True)

    description = models.TextField(_('Description'))

    objects = UserManager()

    USERNAME_FIELD = 'username'
    EMAIL_FIELD = 'email'
    REQUIRED_FIELDS = ['email']

    is_staff = models.BooleanField(
        _('Staff Status'),
        default=False,
        help_text=_(
            'Designates whether the user can log into this admin site.'
        ),
    )

    is_active = models.BooleanField(
        _('Active Flag'),
        default=True,
        help_text=_(
            'Designates whether this user should be treated as active. '
            'Unselect this instead of deleting accounts.'
        ),
    )

    class Meta:
        verbose_name = _('user')
        verbose_name_plural = _('user')

    def __str__(self):
        return self.username

    def email_user(self, subject, message, from_email=None, **kwargs):
        send_mail(subject, message, from_email, [self.email], **kwargs)


class Blog(AbstractBaseModel):

    title = models.CharField(_('Blog Title'), max_length=100)
    user = models.OneToOneField(mUser, on_delete=models.PROTECT, primary_key=True)
    icon = models.ImageField(_('Icon'), upload_to="upload/", blank=True, null=True)

    def __str__(self):
        return self.title

class Category(models.Model):

    name = models.CharField(
        _('Category'),
        max_length=100,
        unique=True
    )
    user = models.ForeignKey(
        mUser,
        on_delete=models.CASCADE,
        default=get_default_user,
    )
    slug = models.SlugField(
        blank=True,
        null=True
    )

    def __str__(self):
        return self.name

    class Meta:
        verbose_name_plural = _('Categories')

    def get_latest_post(self):
        queryset = Post.objects.filter(category=self)
        return _get_latest_post(queryset)


class Tag(models.Model):

    name = models.CharField(
        _('Tag'),
        max_length=100
    )
    user = models.ForeignKey(
        mUser,
        on_delete=models.CASCADE,
        default=get_default_user,
    )
    slug = models.SlugField(
        blank=True,
        null=True
    )

    def __str__(self):
        return self.name

    def get_latest_post(self):
        queryset = Post.objects.filter(tag=self)
        return _get_latest_post(queryset)


class Post(AbstractBaseModel):

    blog = models.ForeignKey(
        Blog,
        on_delete=models.CASCADE,
        default=get_default_blog_name,
    )
    title = models.CharField(
        _('Title'),
        max_length=255
    )
    content = MDTextField(
        _('Content'),
        help_text='markdown'
    )
    category = models.ForeignKey(
        Category,
        on_delete=models.CASCADE,
        default=get_default_category_name
    )
    tags = models.ManyToManyField(
        Tag,
        blank=True,
    )
    thumbnail = models.ImageField(
        _('Thumbnail'),
        upload_to='upload/',
        blank=True,
        null=True
    )
    is_public = models.BooleanField(
        _('Publish or Not'),
        default=False
    )
    slug = models.SlugField(
        blank=True,
        null=True,
        unique=True
    )

    # 固定記事かどうか
    fixed = models.BooleanField(
        _('Fixed post or Not'),
        default=False,
    )
    
    # おすすめ記事かどうか
    pickup = models.BooleanField(
        _('Pickup post or Not'),
        default=False,
    )
    from_other_site = models.CharField(
        _('where from'),
        default='qiita',
        max_length=255,
    )

    def __str__(self):
        return self.title

class Comment(AbstractBaseModel):

    name = models.CharField(
        max_length=255,
        blank=True,
        null=True
    )
    text = models.TextField(
        _('Text')
    )
    email = models.EmailField(
        _('Email'),
        max_length=255,
        blank=True,
        null=True
    )
    post = models.ForeignKey(
        Post,
        on_delete=models.CASCADE
    )
    is_public = models.BooleanField(
        _('Publish or Not'),
        default=False
    )

    def __str__(self):
        return self.name

# ポートフォリオのサムネとリンクなど
class Work(AbstractBaseModel):

    title = models.CharField(
        _('title'),
        max_length=255,
    )
    description = models.TextField(
        _('description')
    )
    link = models.URLField(
        _('URL')
    )
    thumbnail = models.ImageField(upload_to="upload/")

    def __str__(self):
        return self.title

class WorkImage(AbstractBaseUser):

    work = models.ForeignKey(
        Work,
        on_delete=models.CASCADE
    )
    image = models.ImageField(upload_to="upload/")

```

ここで記事モデルのcontentをmdeditorのMDTextFieldにする事で、Djangoの管理画面からマークダウン記述ができ、その場で変換後の確認が出来ます。

```api/myblog/models.py
from mdeditor.fields import MDTextField
    ...

    content = MDTextField(
        _('Content'),
        help_text='markdown'
    )
```

※admin.pyへの記述の説明は特に変わったことはしないので省略。


#### シリアライザー定義

個人ブログなので、記事のシリアライザー以外は単純な構成で事足りると思うのでいっぺんに定義しておきます。

```api/myblog/serializers.py
from rest_framework import serializers
from .models import (
    mUser,
    Blog,
    Category,
    Tag,
    Post,
    Comment,
    Work,
    WorkImage
)

import logging, pytz
from datetime import (
    datetime,
    timezone,
    timedelta
)

from django.utils import timezone as timezone_django

import markdown


logging.basicConfig(
    level = logging.DEBUG,
    format = '''%(levelname)s %'(asctime)s %(pathname)s:%(funcName)s:%(lineno)s
    %(message)s''')

logger = logging.getLogger(__name__)



class DynamicFieldsModelSerializer(serializers.ModelSerializer):

    def __init__(self, *args, **kwargs):
        fields = kwargs.pop('fields', None)

        super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)

        if fields is not None:
            allowed = set(fields)
            existing = set(self.fields)
            for field_name in existing - allowed:
                self.fields.pop(field_name)


class UserSerializer(DynamicFieldsModelSerializer):

    class Meta:
        model = mUser
        fields = [
            'username',
            'email',
            'description',
        ]


class BlogSerializer(DynamicFieldsModelSerializer):

    class Meta:
        model = Blog
        fields = [
            'title',
            'user',
        ]


class CategorySerializer(DynamicFieldsModelSerializer):

    class Meta:
        model = Category
        fields = [
            'name',
            'user',
            'slug',
        ]


class TagSerializer(DynamicFieldsModelSerializer):

    class Meta:
        model = Tag
        fields = [
            'name',
            'user',
            'slug',
        ]


class PostSerializer(DynamicFieldsModelSerializer):

    content = serializers.SerializerMethodField()
    created_at = serializers.SerializerMethodField()
    updated_at = serializers.SerializerMethodField()
    category = CategorySerializer()
    tags = serializers.SerializerMethodField()

    class Meta:
        model = Post
        fields = [
            'id',
            'blog',
            'title',
            'content',
            'category',
            'tags',
            'thumbnail',
            'is_public',
            'created_at',
            'updated_at',
            'from_other_site',
        ]

    def get_content(self, obj):
        md = markdown.Markdown(
            extensions=[
                'fenced_code',
                'extra',
                'abbr',
                'attr_list',
                'def_list',
                'footnotes',
                'md_in_html',
                'tables',
                'admonition',
                'codehilite',
                'legacy_attrs',
                'legacy_em',
                'nl2br',
                'sane_lists',
                'wikilinks',
                'toc',
                'meta',
                'smarty',
            ],
            extension_configs={
                'toc': {
                    'title': 'Index'
                },
                'smarty': {
                    'smart_angled_quotes': True,
                }
            }
        )
        return md.convert(obj.content)

    def get_created_at(self, obj):
        d = obj.created_at.astimezone(timezone(timedelta(hours=+9)))
        return d.strftime('%Y.%m.%d')

    def get_updated_at(self, obj):
        d = obj.updated_at.astimezone(timezone(timedelta(hours=+9)))
        return d.strftime('%Y.%m.%d %H:%M:%S')

    def get_tags(self, obj):
        return TagSerializer(obj.tags.all(), many=True).data


class CommentSerializer(DynamicFieldsModelSerializer):

    class Meta:
        model = Comment
        fields = [
            'name',
            'text',
            'email',
            'post',
            'is_public',
        ]


class WorkSerializer(DynamicFieldsModelSerializer):

    class Meta:
        model = Work
        fields = [
            'title',
            'description',
            'link',
            'thumbnail',
        ]
```

ここで重要になってくるのが、記事の内容のフィールドの取得メソッドで、
マークダウンで書かれたものをライブラリによって変換してくれるように指定しています。

```api/myblog/serializers.py
    ...

    def get_content(self, obj):
        md = markdown.Markdown(
            extensions=[
                'fenced_code',
                'extra',
                'abbr',
                'attr_list',
                'def_list',
                'footnotes',
                'md_in_html',
                'tables',
                'admonition',
                'codehilite',
                'legacy_attrs',
                'legacy_em',
                'nl2br',
                'sane_lists',
                'wikilinks',
                'toc',
                'meta',
                'smarty',
            ],
            extension_configs={
                'toc': {
                    'title': 'Index'
                },
                'smarty': {
                    'smart_angled_quotes': True,
                }
            }
        )
        return md.convert(obj.content)
```

このライブラリの詳しい内容は外部記事がよく書かれているので参考にしてください。

https://zerofromlight.com/blogs/detail/104/


また、ドキュメントも英語ですが参考になります。

https://python-markdown.github.io/extensions/



また、DRFから返される日時情報のタイムゾーンがUTCのままだったので、日本時間に変換しています。
他のフィールドでも使っているので、共通メソッドを作ったほうが良いかもしれません。

```api/myblog/serializers.py
    def get_created_at(self, obj):
        d = obj.created_at.astimezone(timezone(timedelta(hours=+9)))
        return d.strftime('%Y.%m.%d')
```


#### ルーティング定義

```api/myblog/urls.py
from django.urls import path, include
from . import views, viewsets
from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register('posts', viewsets.PostViewSet)

app_name = 'myblog'
urlpatterns = [
    path('', include(router.urls)),
]
```


#### ViewSets定義

```api/myblog/viewsets.py
class PostViewSet(viewsets.ModelViewSet):

    permission_classes = (permissions.AllowAny,)
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    def list(self, request):
        queryset = self.filter_queryset(
            self.get_queryset().filter(is_public=True).order_by('-created_at'))
        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)
        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)
```


#### それと設定の追加

設定ファイルにページネーション関連の設定を追加します。
とりあえず1ページ6記事まで返ってくるようにしておきます。

```api/api/settings.py
REST_FRAMEWORK = {
    ...
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 6,
    ...
}
```

諸々の実装は終わったので、管理画面から記事を作成します。


![qiita.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324522/3ae102ce-254c-d598-7ec6-11e0eb549289.png)


そうしたら、__http://localhost:8000/api/posts/__にアクセスすると、

![qiita2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324522/25337d78-f2e8-a4f3-8dc0-fe7de8286b09.png)


記事一覧が返ってきました。

次回は記事一覧を表示するところからまとめます。