【Django】ブログアプリを作ろう!3
はじめに
この記事を書くきっかけは、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 |
メールアドレス | 文字数制限: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
はログイン時に何を使うかの指定だと思ってください。そのため、ユニークな値である必要があり、email
はunique=True
にしてあります。 -
必須項目を指定
REQUIRED_FIELDS = ['username']
-
ユーザー管理用のクラスを使うよう指示
objects = UserManager()
objects
はAbstractBaseUser
を継承したクラスを使うのに必要になるものです。 -
権限があることを知らせる
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 %}
テスト
- 実装機能の洗い出し
前回と同じく、箇条書きで洗い出しましょう。と言いつつ、冒頭で既に出しているので再掲になります。今回実装した機能は以下の通りです。
- 記事の一覧画面( / と /articles)
- アイキャッチ画像の表示
- 記事の登録画面(/articles/create)
- 画像の入力フォーム
- 画像のバリデーション
- 必須
- 画像サイズ
- 拡張子
- 記事の登録内容確認画面(/articles/create_confirm)
- 画像の表示
- 記事の編集画面(/articles/{id}/edit)
- 画像の編集フォーム
- 編集対象の画像が表示されている
- 画像のバリデーション
- 必須
- 画像サイズ
- 拡張子
- 画像の編集フォーム
- 記事の編集内容確認画面(/articles/{id}/edit_confirm)
- 画像の表示
- 記事の詳細画面(/articles/{id})
- アイキャッチ画像の表示
- 記事の削除確認画面(/articles/{id}/delete_confirm)
- 削除対象のアイキャッチ画像を表示
- 削除実行時に画像ファイルも削除
-->
- テストケースの洗い出し
洗い出した機能に求められる要件を洗い出しましょう。スプレッドシートなどでまとめるとリストアップもテストもしやすいと思います。
終わり!
テストも無事終了したら、ブログアプリ作成完了です。
お疲れ様でした!
(自分向けの)ヒント
Dockerの起動
- dockerイメージをビルドする
- コンテナの構築&起動
docker compose build --no-cache
docker compose up -d
- マイグレーション
docker exec -it django-web-1 bash
python3 manage.py makemigrations articles
python3 manage.py makemigrations users
python3 manage.py migrate
- ウィジェットインストール(多分必要…?)
pip install django-widgets-improved
- 実行画面の確認
open http://localhost:8000
管理者ユーザーの作成
-
http://localhost/admin にアクセス
-
ターミナルにて以下のコマンドを実行
python3 manage.py createsuperuser
-
適当に以下の名前、メアド、パスワードを設定
- admin
- <dummy>admin</dummy>@sample.com
- adminpass
-
y
を入力すればそのまま登録できる
Dockerの終了
- コンテナを終了&削除
- イメージの削除(念のため)
- ボリュームの削除(念のため)
docker compose down
docker rmi $(docker images -q)
docker volume rm django_db-store
挙動がおかしいとき
docker logs [コンテナ名]
でログを見よう!
参考
- ログイン・ログアウト機能の作成(前編)|【Django】PDFからEXCELへ文字起しする業務改善アプリを作ってみよう!|Techpit
- Django Userモデルへの参照方法(AUTH_USER_MODEL, get_user_modelの使い方) - Djangoの学習ができるチュートリアルサイトDjangoBrothers
- 【Django】ユーザーモデルAbstractBaseUserの実装(Twitterっぽく) #Python - Qiita
- 【Django】カスタムユーザー(独自のユーザー)の作り方【AbstractBaseUser編】 | だえうホームページ
- DjangoのFileField/ImageFieldのアップロード先のファイル名を変更する - ymyzk’s blog
- 【Django】models.py:def__str__(self)とは?使用方法と管理画面での表示について | OFFICE54
- ジャンゴ(django)のカスタムユーザーモデル(Custom User Model) - ジャンゴ(django)のプロジェクトで使うユーザーモデル(User Model)を自社サービスに合うユーザーモデルに変更する(Customization)方法について説明します。
- Django による掲示板の開発
- Django ListViewについて(template_name, context_object_name) #Django - Qiita
- djangoのMixinについて自分なりにまとめる #Django - Qiita
- 外部キー(on_delete=models.CASCADE)について調べたらStackOverflowの回答が素敵だった - hogehoge diary
- 【第8回】Djangoで日記アプリを作ろう~認証機能実装編~ - 株式会社ライトコード
- 【Django】DoesNotExist at /admin/login/ って表示されたときの対処法 - Timemo
- 【Django】django-widget-tweaksを使ってフォームにCSSや属性を簡単に追加する方法 | RyougoDesgin
- djangoでログイン状態を判定する機能|Tech Press | テックプレス
- 【Django】ForeignKeyの基本から応用まで実例付で解説
- 【Django】order_byメソッドによるソート方法:降順や複数条件によるソート | OFFICE54
- 【Django】バリデーションエラーのテンプレートへの表示パターン #Python - Qiita
- DjangoのCustomUserを設定する | Fujinote
Discussion