🐫

【Django】ブログアプリを作ろう!3

2025/03/12に公開

はじめに

この記事を書くきっかけは、Djangoの勉強を始めて、いざブログアプリを作ろうとしたらどうにもうまくいかなかったことにあります。
初学者なので間違っている部分、効率の悪い部分などあるかもしれませんがご了承ください。
勉強しながら、ブログアプリを作りながらこれを書いているので、おそらくなぞっていけば私と同じように進められるはずです。

こちらの記事の続編です。ブログアプリに、さらに新しい機能をつけてみましょう!

要件

今回追加する機能はログイン認証機能です。自分の記事が他人に編集されてしまってはたまったものではありません。

追加で作りたいブログアプリのページ・機能は以下の通りです。

  • サインアップ(/signup)

    • ユーザ名
      • 必須
      • 最大20文字
    • メールアドレス
      • 必須
      • メール形式のバリデーション
      • 各ユーザでユニークであること
      • メールアドレスのドメインはexample.comを使うこと
    • アイコン画像
      • 項目はあるが必須入力ではない
      • バリデーション(入力した場合)
      • 画像サイズは横400px×縦400px
      • 拡張子はpng/gifのみ
    • パスワード
      • 必須
      • バリデーション(Django標準機能にある)
      • エラー要素
        • ユーザ情報と似すぎている(ユーザ名やメールアドレスと一緒)
        • 8文字未満
        • よく使われるパスワード(aaaaaaaa等)
        • 数字のみのパスワード
        • パスワード確認と異なる
    • パスワード確認
      • 必須
  • サインイン(/login)

    • メールアドレス
      • 必須
      • 登録済みのメールアドレス
    • パスワード
      • 必須
      • 登録済みのパスワード
  • ログアウト(/logout)
    - クリックするとログアウトして未ログイン向けトップページへリダイレクトする機能

  • トップページ(/)

    • 未ログインの場合はログイン(welcome)画面
    • ログイン済みの場合はブログ一覧を表示
  • ユーザ詳細ページ(/users/{id})

    • ユーザアイコン/ユーザ名を表示
    • ユーザの投稿したブログ一覧を表示
  • ユーザ一覧ページ(/users)

    • 全ユーザとユーザが登録した直近3件のブログ詳細ページへのリンクを表示
  • ユーザ削除確認ページ(/users/{id}/delete_confirm)

    • 物理削除
    • 削除実行時にアイコン画像ファイルも削除

そのほか、考慮すべき事項は以下の通り。

  • ログイン済ユーザしかブログシステムの利用はできない
  • ユーザ登録時にアイコン画像の登録を行う
    • アイコン画像が未選択の場合は、デフォルト画像を登録する
  • ブログ記事の削除/編集は投稿したユーザしかできない
  • ユーザ削除はログインしている本人しかできない
  • 詳細ページ/一覧ページのブログに投稿者と投稿者のアイコンを表示する
  • ヘッダーにログインユーザの詳細ページに遷移するリンクを用意
  • ログイン中はログアウトできるボタンを用意
  • ユーザの削除はログインユーザしかできない

Dockerの準備

仮想環境をDockerでやっているので、その準備です。
他の環境をお使いの方は、そちらに合わせて読み替えてください。

ディレクトリ構成

ディレクトリ構成の確認をしましょう。
前回の内容を終えた時点で、以下のようになっているはずです。

Django
    ┣ articles
    ┃  ├ migrations
    ┃  │  ├ __init__.py
    ┃  │  ├ 0001_initial.py
    ┃  │  └ 0002_articles_image.py
    ┃  ├ templates
    ┃  │  └ articles
    ┃  │     ├ base.html
    ┃  │     ├ articles_list.html
    ┃  │     ├ article_create.html
    ┃  │     ├ article_create_confirm.html
    ┃  │     ├ article_detail.html
    ┃  │     ├ article_edit.html
    ┃  │     ├ article_edit_confirm.html
    ┃  │     └ article_delete.html
    ┃  ├ __init__.py
    ┃  ├ admin.py
    ┃  ├ apps.py
    ┃  ├ forms.py
    ┃  ├ models.py
    ┃  ├ urls.py
    ┃  └ views.py
    ┣ blog
    ┃  ├ __init__.py
    ┃  ├ asgi.py
    ┃  ├ settings.py
    ┃  ├ urls.py
    ┃  └ wsgi.py
    ┣ media
    ┣ manage.py
    ┗ requirements.txt

今回の内容を終えると、以下のディレクトリ構成になりますので、さらっと確認しておきましょう。

Django
    ┣ articles
    ┃  ├ migrations
    ┃  │  ├ __init__.py
    ┃  │  ├ 0001_initial.py
    ┃  │  ├ 0002_articles_image.py
    ┃  │  └ 0003_articles_owner.py
    ┃  ├ templates
    ┃  │  └ articles
    ┃  │     ├ base.html
    ┃  │     ├ articles_list.html
    ┃  │     ├ article_create.html
    ┃  │     ├ article_create_confirm.html
    ┃  │     ├ article_detail.html
    ┃  │     ├ article_edit.html
    ┃  │     ├ article_edit_confirm.html
    ┃  │     └ article_delete.html
    ┃  ├ __init__.py
    ┃  ├ admin.py
    ┃  ├ apps.py
    ┃  ├ forms.py
    ┃  ├ models.py
    ┃  ├ urls.py
    ┃  └ views.py
    ┣ blog
    ┃  ├ __init__.py
    ┃  ├ asgi.py
    ┃  ├ settings.py
    ┃  ├ urls.py
    ┃  └ wsgi.py
    ┣ media
    ┃  └ icons
    ┃     └ default
    ┃        └ icon.png
    ┣ users
    ┃  ├ migrations
    ┃  │  ├ __init__.py
    ┃  │  └ 0001_initial.py
    ┃  ├ templates
    ┃  │  └ articles
    ┃  │     ├ user_signup.html
    ┃  │     ├ user_login.html
    ┃  │     ├ users_list.html
    ┃  │     ├ user_detail.html
    ┃  │     └ user_delete.html
    ┃  ├ __init__.py
    ┃  ├ admin.py
    ┃  ├ apps.py
    ┃  ├ forms.py
    ┃  ├ models.py
    ┃  ├ urls.py
    ┃  └ views.py
    ┣ utils
    ┃  └ mixin.py
    ┣ manage.py
    ┗ requirements.txt

設定まわり

アプリ作成

まずはユーザー認証関連のアプリを作りましょう。

manage.pyのあるディレクトリに移動し、ターミナルで(Dockerをお使いの方はコンテナ内で)以下を実行してください。

python3 manage.py startapp users

これでusersというアプリが作成されました。

blog/settings.py

blogディレクトリ内のsettings.pyにアプリを追記しましょう。

INSTALLED_APPS = [
    'users.apps.UsersConfig',
        ・
        ・
        ・
]

これで読み込んでくれるようになります。

末尾あたりに、色々追記しましょう。

SITE_ID = 1
LOGIN_URL = '/login'
LOGIN_REDIRECT_URL = 'articles:articles_list'
LOGOUT_REDIRECT_URL = '/login'
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'

それぞれ、

  • Djangoプロジェクトの識別値
  • ログインしていない人がリダイレクトされるURL
  • ログインした人がリダイレクトされるURL
  • ログアウト後にリダイレクトされるURL
  • セッションをキャッシュで保存する設定

を指しています。

modelの定義 - カラム

ログイン機能に使うカラムは以下としましょう。

カラム 概要 オプション
username ユーザー名 文字数制限:20
email メールアドレス 文字数制限:255
ユニーク
icon アイコン画像 拡張子制限:png/gif
サイズ制限:400×400
is_active 使えるユーザーか否か True/False
is_admin 管理者か否か True/False

users/models.py

続いてusers/models.pyを編集しましょう。

Userクラス

まずはモジュールのインポートです。

from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
from django.core.validators import FileExtensionValidator
from django.db import models

それぞれ、

  • AbstractBaseUserはフィールドのカスタマイズをするため
  • BaseUserManagerは管理者がコマンドでユーザーを作成するのに必要なため
  • FileExtensionValidatorは拡張子のバリデーション用
  • modelsはモデルを扱うため

必要になってきます。

カラムを実装していきます。

class User(AbstractBaseUser):
    username = models.CharField(
        verbose_name='ユーザー名',
        max_length=20
    )
    email = models.EmailField(
        verbose_name='メールアドレス',
        max_length=255,
        unique=True
    )
    icon = models.ImageField(
        verbose_name='アイコン画像',
        upload_to=get_image_path,
        default='icons/default/icon.png',
        validators=[
            FileExtensionValidator(['png', 'gif']),
            validate_bad_size
        ]
    )
    is_active = models.BooleanField(
        default=True
    )
    is_admin = models.BooleanField(
        default=False
    )

get_image_path, validate_bad_size, UserManager()はこれから実装するので未定義のままで問題ないです。

クラス内末尾に諸々を書き加えます。

  • ユーザー識別の値を指定

    USERNAME_FIELD = 'email'
    

    USERNAME_FIELDはログイン時に何を使うかの指定だと思ってください。そのため、ユニークな値である必要があり、emailunique=Trueにしてあります。

  • 必須項目を指定

    REQUIRED_FIELDS = ['username']
    
  • ユーザー管理用のクラスを使うよう指示

    objects = UserManager()
    

    objectsAbstractBaseUserを継承したクラスを使うのに必要になるものです。

  • 権限があることを知らせる

    def has_perm(self, perm, obj=None):
        return self.is_admin
    
  • アプリのモデルに接続できるようにする

    def has_module_perms(self, app_label):
        return self.is_admin
    
  • 管理画面へログインできるようにする

    @property
    def is_staff(self):
        return self.is_admin
    

アイコン画像のファイル名を変更する関数

ハッシュ値と日付を使うので、追加でインポートします。

import hashlib
from datetime import datetime
def get_image_path(instance, filename):
    pre_hash = filename + str(datetime.now())
    md5_pre_hash = hashlib.md5(pre_hash.encode()).hexdigest()
    extension = str(filename).split('.')[-1]
    hashed = '.'.join([md5_pre_hash, extension])
    return f'icons/{hashed}'

ハッシュ値を使って、ファイル名を変更する関数です。残念ながら私はハッシュ値をあまり理解していないので、どういう処理がなされているかの解説ができないです。気になる方は調べてみてください。

アイコン画像のバリデーション関数

バリデーションエラー時の文言表示用に、モジュールのインポートから。

from django.core.exceptions import ValidationError

前回作ったarticles/models.pyで書いた関数とほぼ同じです。コピペして、必要箇所だけ変えましょう。

def validate_bad_size(icon):
    if not (icon.width == 400 and icon.height == 400):
        raise ValidationError('画像サイズは 400px × 400px にしてください')

ユーザー管理用クラス

先ほど作ったUserクラスより上に書き加えます。

class UserManager(BaseUserManager):

この中に3つの関数を作ります。

  • ユーザー作成関数
  • スーパーユーザー作成関数
  • これらの関数で使うための関数

です。

関数で使うための関数

def _create_user(self, email, username, password, **extra_fields):
  • エラー処理

    if not username:
        raise ValueError('ユーザー名は必須項目です')
    if not email:
        raise ValueError('メールアドレスは必須項目です')
    
  • ユーザーの定義

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

ユーザー作成関数

def create_user(self, email, username, password=None, **extra_fields):
  • ユーザーの定義

    extra_fields.setdefault('is_active', True)
    extra_fields.setdefault('is_admin', False)
    
    return self._create_user(
        email=email,
        username=username,
        password=password,
        **extra_fields,
    )
    

スーパーユーザー作成関数

def create_superuser(self, email, username, password, **extra_fields):
  • ユーザーの定義

    extra_fields['is_active'] = True
    extra_fields['is_admin'] = True
    
    return self._create_user(
        email=email,
        username=username,
        password=password,
        **extra_fields,
    )
    

blog/settings.py

blogディレクトリ内のsettings.pyの末尾に以下を追記します。

AUTH_USER_MODEL = 'users.Users'

usersアプリの、先ほど作ったUsersクラスを指定したので、これでモデルを認識してくれます。

さっさとマイグレーションをしてしまいたいところですが、まだadmin.pyを作っていないのでエラーが出ます。

なので、先にadmin.pyを作っていきます。

users/admin.py

articles/admin.pyは放置で大丈夫です。

ユーザー自身がユーザー登録できるフォームのクラス

/usr/local/lib/python3.11/site-packages/django/contrib/auth/forms.pyから、

class UserCreationForm(forms.ModelForm):をコピペしてきましょう。

環境によってディレクトリは違うと思うので、ご自身でdjango/contrib/auth/forms.pyを探してきてください。

とは書きましたが、コピペしてきたものを記載しておきます。以下をコピペしていただいても結構です。

class UserCreationForm(forms.ModelForm):
    """
    A form that creates a user, with no privileges, from the given username and
    password.
    """

    error_messages = {
        "password_mismatch": _("The two password fields didn’t match."),
    }
    password1 = forms.CharField(
        label=_("Password"),
        strip=False,
        widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
        help_text=password_validation.password_validators_help_text_html(),
    )
    password2 = forms.CharField(
        label=_("Password confirmation"),
        widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
        strip=False,
        help_text=_("Enter the same password as before, for verification."),
    )

    class Meta:
        model = User
        fields = ("username",)
        field_classes = {"username": UsernameField}

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self._meta.model.USERNAME_FIELD in self.fields:
            self.fields[self._meta.model.USERNAME_FIELD].widget.attrs[
                "autofocus"
            ] = True

    def clean_password2(self):
        password1 = self.cleaned_data.get("password1")
        password2 = self.cleaned_data.get("password2")
        if password1 and password2 and password1 != password2:
            raise ValidationError(
                self.error_messages["password_mismatch"],
                code="password_mismatch",
            )
        return password2

    def _post_clean(self):
        super()._post_clean()
        # Validate the password after self.instance is updated with form data
        # by super().
        password = self.cleaned_data.get("password2")
        if password:
            try:
                password_validation.validate_password(password, self.instance)
            except ValidationError as error:
                self.add_error("password2", error)

    def save(self, commit=True):
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password1"])
        if commit:
            user.save()
        return user

まず必要なものをインポートします。

from django import forms
from django.core.exceptions import ValidationError

from .models import User

先ほどコピペしたものから、適宜書き換えを行います。

  • error_messages = { ... }
  • def __init__(self, *args, **kwargs):
  • def _post_clean(self):

は使わないので、まず削除しましょう。

  • password1 と password2

    それぞれにおいて、以下は使わないので消しましょう。

    strip=False,
    
    (attrs={"autocomplete": "new-password"})
    
    help_text=password_validation.password_validators_help_text_html(),
    

    label=の後についているアンダーバーも不要なので消してください。

  • Class Meta

    以下は使わないので削除しましょう。

    field_classes = {"username": UsernameField}
    

    fieldsに代入するものを['email']にします。

  • def clean_password2

    以下のように、raise ValidationError内を読みやすくしましょう。

    raise ValidationError('パスワードが一致しません')
    
  • def save

    変える必要はないのでそのまま使いましょう。

ユーザーが自身でユーザー情報をアップデートできるようにするクラス

同じくdjango/contrib/auth/forms.pyからclass UserChangeForm(forms.ModelForm):をコピペしてきましょう。

もしくは、以下をコピペして使っても良いです。

class UserChangeForm(forms.ModelForm):
    password = ReadOnlyPasswordHashField(
        label=_("Password"),
        help_text=_(
            "Raw passwords are not stored, so there is no way to see this "
            "user’s password, but you can change the password using "
            '<a href="{}">this form</a>.'
        ),
    )

    class Meta:
        model = User
        fields = "__all__"
        field_classes = {"username": UsernameField}

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        password = self.fields.get("password")
        if password:
            password.help_text = password.help_text.format("../password/")
        user_permissions = self.fields.get("user_permissions")
        if user_permissions:
            user_permissions.queryset = user_permissions.queryset.select_related(
                "content_type"
            )

必要なものをインポートします。以下を書き加えてください。

from django.contrib.auth.forms import ReadOnlyPasswordHashField

続いて不要なものを削除します。以下は使わないので、丸々消して問題ありません。

  • def __init__(self, *args, **kwargs):

続いて書き換えをしていきます。

  • password

    中身を丸々削除し、以下のようにしてください。

    password = ReadOnlyPasswordHashField()
    
  • class Meta

field_classesを削除し、fieldsに入れる値を設定して以下のようにしてください。

class Meta:
    model = User
    fields = ['email', 'password', 'is_active', 'is_admin']

管理ユーザーのクラス

今度はdjango/contrib/auth/admin.pyからclass UserAdmin(admin.ModelAdmin):をコピペしてきましょう。

関数群は丸々使わないので、初めからコピペしなくて大丈夫です。

class UserAdmin(admin.ModelAdmin):
    add_form_template = "admin/auth/user/add_form.html"
    change_user_password_template = None
    fieldsets = (
        (None, {"fields": ("username", "password")}),
        (_("Personal info"), {"fields": ("first_name", "last_name", "email")}),
        (
            _("Permissions"),
            {
                "fields": (
                    "is_active",
                    "is_staff",
                    "is_superuser",
                    "groups",
                    "user_permissions",
                ),
            },
        ),
        (_("Important dates"), {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("username", "password1", "password2"),
            },
        ),
    )
    form = UserChangeForm
    add_form = UserCreationForm
    change_password_form = AdminPasswordChangeForm
    list_display = ("username", "email", "first_name", "last_name", "is_staff")
    list_filter = ("is_staff", "is_superuser", "is_active", "groups")
    search_fields = ("username", "first_name", "last_name", "email")
    ordering = ("username",)
    filter_horizontal = (
        "groups",
        "user_permissions",
    )

ここから不要なものをさらに削ります。

  • add_form_template
  • change_user_password_template
  • change_password_form

これらは使わないので消しましょう。

  • fieldsets

    ガッツリ書き換えるので細かくは書きませんが、ざっくりと以下のようにしてください。

    fieldsets = [
        (None, {'fields': ('email', 'password')}),
        ('Permissions', {'fields': ['is_admin']}),
    ]
    
  • add_fieldsets

    以下のように書き換えてください。

    add_fieldsets = [(None, {
                'classes': ['wide'],
                'fields': ['email', 'password1', 'password2'],
                }, ), ]
    
  • form と add_form

    どちらもそのままでOKです。

  • list_display

    以下のように書き換えてください。

    list_display = ['email', 'is_admin']
    
  • list_filter

    以下のように書き換えてください。

    list_filter = ['is_admin']
    
  • search_firlds

    以下のように書き換えてください。

    search_fields = ['email']
    
  • ordering

    以下のように書き換えてください。

    ordering = ['email']
    
  • filter_horizontal

    以下のように書き換えてください。

    filter_horizontal = []
    

そのほか

末尾に以下を追加します。

# Userモデルを登録する
admin.site.register(User, UserAdmin)

articles/models.py

記事の情報として著者情報を追加したいので、Articlesクラスに以下を追加します。

owner = models.ForeignKey(
    'users.Users',
    on_delete=models.CASCADE,
    default=1
    )

これで、Userテーブルの主キーを外部キーに指定することができます。

on_delete属性は、外部キーが削除されたときどう機能するかを指定しており、CASCADEを指定すれば、同時にこちらのレコードも削除されるようになります。

マイグレーション

私と同じくdockerを利用している方は、最初に以下のコマンドを打って、コンテナ内に入りましょう。

docker exec -it django-web-1 bash

以下を実行してください。

python3 manage.py makemigrations articles
python3 manage.py makemigrations users

以下のコマンドを実行して、models.pyに書いた内容を反映させましょう!

python3 manage.py migrate

OKがたくさん出てきたら完了です!

ルーティング

blog/urls.py

urlpatternsに以下を追記します。

urlpatterns += static(
    ・
    ・
    ・
    path('users/', include('users.urls')),
)

まだusers/urlsを作っていないので、これから作っていきます。

users/urls.py

articles/urls.pyと同じように書いていきます。

from django.urls import path

from . import views

app_name = "users"

urlpatterns = [
    # ユーザー一覧ページ /users
    path(
        '',
        views.UsersListView.as_view(),
        name='users_list'
        ),
    # ユーザー詳細ページ /users/{id}
    path(
        '<int:pk>/',
        views.UserDetailView.as_view(),
        name='user_detail'
        ),
    # ユーザー削除確認ページ /users/{id}/delete_confirm
    path(
        '<int:pk>/delete_confirm/',
        views.UserDeleteView.as_view(),
        name='user_delete'
        ),
    # ユーザー新規登録ページ /signup
    path(
        'signup/',
        views.UserSignupView.as_view(),
        name="signup"
        ),
    # ログインページ /login
    path(
        'login/',
        views.UserLoginView.as_view(),
        name="login"
        ),
    # ログアウトページ(すぐログインページに移動するので表示するページはない)
    path(
        'logout/',
        views.UserLogoutView.as_view(),
        name="logout"
        ),
]

views.pyを編集する前に、forms.pyを作りましょう。

forms.py

from django import forms

from .models import Users


class SinUpForm(forms.UserCreationForm):
    class Meta:
        model = Users
        fields = (
            'username',
            'email',
            'icon'
        )


class LoginForm(forms.AuthenticationForm):
    class Meta:
        model = Users

users/views.py

views.pyを作っていきます。

サインアップ

from django.contrib.auth import login, authenticate
from django.views.generic.edit import CreateView
from django.urls import reverse_lazy

from .forms import SignupForm
class UserSignupView(CreateView):
    form_class = SignupForm
    template_name = 'users/user_signup.html'
    success_url = reverse_lazy('articles:articles_list')

    def form_valid(self, form):
        response = super().form_valid(form)
        email = form.cleaned_data.get('email')
        password = form.cleaned_data.get('password2')
        user = authenticate(
            email=email,
            password=password
            )
        login(self.request, user)
        return response

ログイン

from django.contrib.auth.views import LoginView

from .forms import LoginForm
class UserLoginView(LoginView):
    form_class = LoginForm
    template_name = 'users/user_login.html'

    def get(self, request, *args, **kwargs):
        request.session['user'] = self.request.user
        return super().get(request, *args, **kwargs)

ログアウト

from django.contrib.auth.views import LogoutView
class UserLogoutView(LogoutView):
    success_url = reverse_lazy('users:user_login')

    def get(self, request, *args, **kwargs):
        del request.session['user']
        return super().get(request, *args, **kwargs)

ユーザー一覧

from django.views.generic.base import TemplateView
from articles.models import Articles
from .models import Users
class UsersListView(TemplateView):
    template_name = 'users/users_list.html'
    context_object_name = 'articles_list'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        users = Users.objects.all()
        users_list = []
        for user in users:
            articles = Articles.objects.filter(
                owner=user
                ).order_by('-updated_at')[:3]
            user_data = {
                'id': user.id,
                'name': user.username,
                'icon': user.icon,
                'articles': articles,
            }
            users_list.append(user_data)

        context['users_list'] = users_list
        return context

ユーザー詳細

from django.views.generic.list import ListView
class UserDetailView(ListView):
    model = Articles
    template_name = 'users/user_detail.html'
    context_object_name = 'articles_list'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        user = Users.objects.get(id=self.kwargs['pk'])
        context['user'] = user
        return context

    def get_queryset(self, **kqargs):
        queryset = super().get_queryset(**kqargs)

        user = Users.objects.get(id=self.kwargs['pk'])
        queryset = queryset.filter(owner=user.id).order_by('updated_at')
        return queryset

ユーザー削除

UserDeleteViewを作る前に、Mixinを作っておきましょう。

utils/mixins.py

manage.pyの階層に、utils/mixins.pyを作成します。

mixinとは、クラスベースビューに使う便利機能のようなもの(だそうです)。

以下の記事を参考にしてください。
djangoのMixinについて自分なりにまとめる #Django - Qiita

まず必要なものをインポートします。

from django.contrib.auth.mixins import UserPassesTestMixin

from articles.models import Articles
from users.models import Users

記事の著者以外が削除できないようなMixinを作ります。

class OnlyYouMixin(UserPassesTestMixin):
    raise_exception = True

    def test_func(self):
        user = self.request.user

        if self.model == Users:
            return user.id == self.kwargs['pk']
        elif self.model == Articles:
            owner = Articles.objects.get(pk=self.kwargs['pk']).owner
            return user.id == owner.id
        else:
            return False

users/views.py

ユーザー削除

from django.views.generic.edit import DeleteView
class UserDeleteView(DeleteView):
    model = Users
    template_name = 'users/user_delete.html'
    success_url = reverse_lazy('articles:articles_list')

blog/urls.py

ルーティングを追加します。

urlpatterns = [
    ・
    ・
    ・
    path('signup', UserSignupView.as_view(), name='signup'),
    path('login', UserLoginView.as_view(), name='login'),
    path('logout', UserLogoutView.as_view(), name='logout'),
]

users/template/users

続いてusersのテンプレートを作成していきます。

base.html

article/base.htmlをコピペし、不要な部分を削除するのがいいと思います。

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Blogアプリ</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
</head>

<body>
    <nav class="navbar navbar-expand-lg navbar-light bg-light">
        <div class="container-fluid">
            <a href="{% url 'articles:articles_list' %}" class="navbar-brand">Blogアプリ</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
                data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false"
                aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>
        </div>
    </nav>
    <div class="container">{% block content %} {% endblock %}</div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
        crossorigin="anonymous"></script>
</body>

</html>

ユーザー登録画面 user_signup.html

{% extends 'users/base.html' %}

{% block content %}

{% load widget_tweaks %}

<div class="container w-50">
    {% if form.errors %}
    <ul class="alert alert-danger list-unstyled">
        <li style="list-style-type: none;">
            {{ form.errors }}
        </li>
    </ul>
    {% endif %}
    <div class="card m-3">
        <div class="card-body">
            <form method="post" enctype="multipart/form-data">
                {% csrf_token %}
                {% for field in form %}
                <div class="form-group">
                    {{ field.label }}
                    <div class="mb-4">
                        {% render_field field class="form-control" %}
                    </div>
                </div>
                {% endfor %}
                <input class="btn btn-success" type="submit" name="create" value="アカウント作成">
                <input class="btn btn-warning" type="submit" name="back" value="戻る">
            </form>
        </div>
    </div>
</div>

{% endblock %}

localhost:8000/signupにアクセスすればユーザー登録画面が表示されるはずです。

ログイン画面 users_login.html

{% extends 'users/base.html' %}

{% block content %}

{% load widget_tweaks %}

<div class="container w-50">
    {% if form.errors %}
    <ul class="alert alert-danger list-unstyled">
        <li style="list-style-type: none;">
            {{ form.errors }}
        </li>
    </ul>
    {% endif %}
    <div class="card m-3">
        <div class="card-body">
            <form method="post" enctype="multipart/form-data">
                {% csrf_token %}
                {% for field in form %}
                <div class="form-group">
                    {{ field.label }}
                    <div class="mb-4">
                        {% render_field field class="form-control" %}
                    </div>
                </div>
                {% endfor %}
                <input class="btn btn-success" type="submit" name="login" value="ログイン">
            </form>
        </div>
    </div>
</div>

{% endblock %}

http://localhost:8000/loginにアクセスすればログイン画面が表示されるはずです。

ユーザー一覧画面 users_list.html

{% extends 'users/base.html' %}

{% block content %}

<h2 class="m-2 border-bottom">ユーザー一覧</h2>
<table class="table table-striped table-hover">
    <tbody>
        {% for user in users_list %}
        <tr>
            <td>
                <a href="{% url 'users:user_detail' user.id %}">
                    <img class="m-2" src="{{ user.icon.url }}" alt="" width='48px'>
                    {{ user.name }}
                </a>
            </td>
            <td class="align-middle">
                {% if user.articles %}
                <ul class="list-group">
                    {% for article in user.articles %}
                    <li class="d-flex justify-content-between list-group-item align-items-center">
                        <div class="d-flex justify-content-start align-items-center" style="overflow: hidden;">
                            <img src="/media/{{ article.image }}" alt="" style="height: 70px; max-width: 300px;">
                            <p class="m-2">
                                {{ article.title }}
                            </p>
                        </div>
                        <div>
                            <a class="btn btn-info" href="{% url 'articles:article_detail' article.pk %}">詳細</a>
                        </div>
                    </li>
                    {% endfor %}
                </ul>
                {% else %}
                <p class="m-0">このユーザーはまだ記事を投稿していません</p>
                {% endif %}
            </td>
        </tr>
        {% endfor %}
    </tbody>
</table>

{% endblock %}

http://localhost:8000/users/にアクセスすればユーザー一覧画面が表示されるはずです。

ユーザー詳細画面 user_detail.html


ユーザー削除画面 user_delete.html


<!--

テンプレート

続いてテンプレートも更新していきましょう!

記事の一覧画面 articles_list.html

アイキャッチ画像を表示する場所を作ります。表見出しとセルを以下のようにしてみてください。

<thead>
    <tr>
        <th scope="col"></th>
        <th scope="col">タイトル</th>
        <th scope="col">本文</th>
        <th scope="col">作成日時</th>
        <th scope="col">最終更新日時</th>
        <th scope="col"></th>
    </tr>
</thead>
<tbody>
    {% for article in articles_list %}
    <tr>
        <td>
            {% if article.image %}
            <img src="{{ article.image.url }}" alt="">
            {% endif %}
        </td>
        <td>
            <div class="overflow-hidden" style="height: 70px; max-width: 300px;">{{ article.title }}</div>
        </td>
        <td>
            <div class="overflow-hidden" style="height: 70px; max-width: 300px;">{{ article.article_body }}</div>
        </td>
        <td>
            {{ article.created_at }}
        </td>
        <td>
            {{ article.updated_at }}
        </td>
        <td>
            <a class="btn btn-primary" href="{% url 'articles:article_detail' article.pk %}">詳細</a>
        </td>
    </tr>
    {% endfor %}
</tbody>

記事の登録画面 article_create.html

formタグの上に、エラーを表示させる場所を作ります。

{% if form.errors and not_error is False %}
<ul class="alert alert-danger list-unstyled">
    <li style="list-style-type: none;">
        {{ form.errors }}
    </li>
</ul>
{% endif %}

エラーがない場合は不要なエリアなので、if文で囲みましょう。
ちなみにnot_error is Falseがないと、確認画面から戻ってきた時にもエラーを吐かれてしまいます。

文字以外のファイル(=画像)を送信したいので、formタグに以下の記述を追加します。

enctype="multipart/form-data"

このままinputを増やしてもいいのですが、せっかくなのでループ処理に変えてみましょう。

これだけで3つのinputを生成してくれます。

{% for field in form %}
<div class="form-group">
    {{ field.label }}
    <div class="mb-4">
        {% render_field field class="form-control" %}
    </div>
</div>
{% endfor %}

記事の登録内容確認画面 article_create_confirm.html

画像の表示エリアを作ってあげます。

{% extends 'articles/base.html' %} {% block content %}
<h2 class="m-2 border-bottom">投稿内容確認</h2>
<p class="m-3">以下の内容で投稿します</p>
<div class="mx-3 card">
    <div class="card-body">
        <h3 class="card-title">{{ form.title.value }}</h3>
        <img class="card-img-top w-50" src="{{ show_image_path }}" alt="">
        <p class="card-text">{{ form.article_body.value }}</p>
        <small class="card-text text-body-secondary text-muted">文字数: {{ form.article_body.value | length }}</small>
    </div>
</div>
<form method="post" action="{% url 'articles:article_create_confirm' %}" class="m-3" enctype="multipart/form-data">
    {% csrf_token %}
    {% for field in form %} {{ field.as_hidden }} {% endfor %}
    <input class="btn btn-success" type="submit" name="create" value="投稿">
    <input class="btn btn-warning" type="submit" name="back" value="戻る">
</form>
{% endblock %}

記事の詳細画面 article_detail.html

同じく、画像の表示エリアを作ります。

{% extends 'articles/base.html' %} {% block content %}
<h2 class="m-2 border-bottom">投稿詳細</h2>
<div class="card m-3">
    <div class="card-body d-flex flex-column">
        <h3 class="card-title order-1">{{ article.title }}</h3>
        <img class="card-img-top order-0" src="{{ article.image.url }}" alt="" width="300px">
        <p class="card-text order-2">{{ article.article_body }}</p>
        <small class="card-text text-body-secondary text-muted order-3">
            最終更新日時:<time>{{ article.updated_at }}</time></small>
        <small class="card-text text-body-secondary text-muted order-4">
            作成日時:<time>{{ article.created_at }}</time></small>
        <div class="card-text order-5">
            <a href="{% url 'articles:article_edit' article.pk %}" class="btn btn-success">編集</a>
            <a href="{% url 'articles:article_delete' article.pk%}" class="btn btn-danger">削除</a>
        </div>
    </div>
</div>
{% endblock %}

記事の編集画面 article_edit.html

記事作成画面と同じく、フォームをfor文で書いてみましょう。

{% extends 'articles/base.html' %} {% load widget_tweaks %} {% block content %}
<h2 class="m-2 border-bottom">投稿を編集</h2>

{% if form.errors %}
<ul class="alert alert-danger list-unstyled">
    <li style="list-style-type: none;">
        {{ form.errors }}
    </li>
</ul>
{% endif %}

<form class="m-3" action="{% url 'articles:article_edit_confirm' article_id %}" method="post"
    enctype="multipart/form-data">
    {% csrf_token %}
    {% for field in form %}
    <div class="form-group">
        {{ field.label }}
        <div class="mb-4">
            {% render_field field class="form-control" %}
        </div>
    </div>
    {% endfor %}
    <input class="btn btn-success" type="submit" name="confirm" value="投稿内容確認">
</form>
{% endblock %}

記事の編集内容確認画面 article_edit_confirm.html

画像の表示エリアを作ってあげます。

{% extends 'articles/base.html' %} {% block content %}
<h2 class="m-2 border-bottom">編集内容確認</h2>
<p class="m-3">以下の内容に更新します</p>
<div class="mx-3 card">
    <div class="card-body">
        <h3 class="card-title">{{ form.title.value }}</h3>
        <img class="card-img-top w-50" src="{{ show_image_path }}" alt="">
        <p class="card-text">{{ form.article_body.value }}</p>
        <small class="card-text text-body-secondary text-muted">文字数: {{ form.article_body.value | length }}</small>
    </div>
</div>
<form method="post" action="{% url 'articles:article_edit_confirm' article_id %}" class="m-3"
    enctype="multipart/form-data">
    {% csrf_token %}
    {% for field in form %} {{ field.as_hidden }} {% endfor %}
    <input class="btn btn-success" type="submit" name="create" value="更新">
    <input class="btn btn-warning" type="submit" name="back" value="戻る">
</form>
{% endblock %}

記事の削除確認画面 article_delete.html

最後も、画像の表示エリアの作成です。

<h2 class="m-2 border-bottom">投稿削除確認</h2>
<p class="m-3">以下の投稿を削除します</p>
<div class="card m-3">
    <div class="card-body d-flex flex-column">
        <h3 class="card-title order-1">{{ article_delete.title }}</h3>
        <img class="card-img-top order-0" src="{{ article_delete.image.url }}" alt="" width="300px">
        <p class="card-text order-2">{{ article_delete.article_body }}</p>
        <small class="card-text text-body-secondary text-muted order-3">
            最終更新日時:<time>{{ article_delete.updated_at }}</time></small>
        <small class="card-text text-body-secondary text-muted order-4">
            作成日時:<time>{{ article_delete.created_at }}</time></small>
    </div>
</div>
<form method="post" class="m-3">
    {% csrf_token %}
    <input class="btn btn-danger" type="submit" name="create" value="削除">
    <a class="btn btn-warning" href="{% url 'articles:article_detail' article_delete.pk %}">戻る</a>
</form>
{% endblock %}

テスト

  1. 実装機能の洗い出し

前回と同じく、箇条書きで洗い出しましょう。と言いつつ、冒頭で既に出しているので再掲になります。今回実装した機能は以下の通りです。

  • 記事の一覧画面( / と /articles)
    • アイキャッチ画像の表示
  • 記事の登録画面(/articles/create)
    • 画像の入力フォーム
    • 画像のバリデーション
      • 必須
      • 画像サイズ
      • 拡張子
  • 記事の登録内容確認画面(/articles/create_confirm)
    • 画像の表示
  • 記事の編集画面(/articles/{id}/edit)
    • 画像の編集フォーム
      • 編集対象の画像が表示されている
    • 画像のバリデーション
      • 必須
      • 画像サイズ
      • 拡張子
  • 記事の編集内容確認画面(/articles/{id}/edit_confirm)
    • 画像の表示
  • 記事の詳細画面(/articles/{id})
    • アイキャッチ画像の表示
  • 記事の削除確認画面(/articles/{id}/delete_confirm)
    • 削除対象のアイキャッチ画像を表示
    • 削除実行時に画像ファイルも削除
      -->
  1. テストケースの洗い出し

洗い出した機能に求められる要件を洗い出しましょう。スプレッドシートなどでまとめるとリストアップもテストもしやすいと思います。

終わり!

テストも無事終了したら、ブログアプリ作成完了です。

お疲れ様でした!

(自分向けの)ヒント

Dockerの起動

  1. dockerイメージをビルドする
  2. コンテナの構築&起動
docker compose build --no-cache
docker compose up -d
  1. マイグレーション
docker exec -it django-web-1 bash

python3 manage.py makemigrations articles
python3 manage.py makemigrations users
python3 manage.py migrate
  1. ウィジェットインストール(多分必要…?)
pip install django-widgets-improved
  1. 実行画面の確認
open http://localhost:8000

管理者ユーザーの作成

  1. http://localhost/admin にアクセス

  2. ターミナルにて以下のコマンドを実行

python3 manage.py createsuperuser
  1. 適当に以下の名前、メアド、パスワードを設定

    • admin
    • <dummy>admin</dummy>@sample.com
    • adminpass
  2. yを入力すればそのまま登録できる

Dockerの終了

  1. コンテナを終了&削除
  2. イメージの削除(念のため)
  3. ボリュームの削除(念のため)
docker compose down
docker rmi $(docker images -q)
docker volume rm django_db-store

挙動がおかしいとき

docker logs [コンテナ名]

でログを見よう!

参考

Discussion