Djangoで簡単なToDoタスク管理ツールを開発する
この記事は何か
-
表題の通り、Djangoで簡単なToDoタスク管理ツールを開発します。技術構成は下記のとおりです
- 言語、FW: Python, Django
- 仮想環境: conda
- DB: PostgreSQL
- CSS: Bootstrap
やっていく!
conda で仮想環境を作成
- conda でまず env を作成します
conda create django-todo-app-env python=3.13.0
- 作成した env を activate します。ターミナルの先頭に
(django-todo-app-env)
と表示されればOK
conda activate django-todo-app-env
(django-todo-app-env) (base) xxxx:django_todo_app xxxx$
プロジェクトの作成
- Djangoをインストール
pip install django
- プロジェクトを作成します。名前は
django_todo_app
とします
django-admin startproject django_todo_app
cd django_todo_app
- 一旦ローカルサーバを起動します。ブラウザでローカルホストを開いて下記のようにDjangoの初期画面が表示されればOK
python manage.py runserver
DBのセットアップ
- conda環境にPostgreSQLを使用するためのパッケージをインストール
conda install -c conda-forge psycopg2
- pg_ctlがインストールされていることを確認します
which pg_ctl
/Users/xxxx/anaconda3/envs/django-todo-app-env/bin/pg_ctl
- 次にDBクラスタを作成します
initdb -D /Users/xxxx/anaconda3/envs/django-todo-app-env/postgresql/data
- DBクラスタができたらDBを起動します
pg_ctl -D /Users/xxxx/anaconda3/envs/django-todo-app-env/postgresql/data -l logfile start
- statusコマンドでrunnningならOK
pg_ctl status
pg_ctl: server is running (PID: 50877)
- DBを作成します。DB名は
django_todo_app_db
、ユーザー名はtestuser
、パスワードはtestpassword
とします
psql postgres
CREATE DATABASE django_todo_app_db;
CREATE USER testuser WITH PASSWORD 'testpassword';
GRANT ALL PRIVILEGES ON DATABASE django_todo_app_db TO testuser;
-
\l
で作成したデータベースが表示されていればOK
postgres=# \l
List of databases
Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges
--------------------+--------+----------+-----------------+-------------+-------------+--------+-----------+---------------------
django_todo_app_db | xxxx | UTF8 | libc | ja_JP.UTF-8 | ja_JP.UTF-8 | | | =Tc/xxxx +
| | | | | | | | xxxx=CTc/xxxx +
| | | | | | | | testuser=CTc/xxxx
-
PostgreSQLコマンドは下記を参照してください
DB接続のセットアップ
- 次にPostgreSQL接続するためにクライアントをインストールします
pip install psycopg
- Djangoが先ほど作成したDBに接続するための設定を行います。そのために
django-environ
をインストールしてDB情報を含めた秘匿情報を.env
ファイルで管理するようにします
pip install django-environ
-
.env
ファイルを作成します
touch .env
-
.env
ファイルに環境変数を設定します
DEBUG=on
SECRET_KEY="xxxxx"
DATABASE_URL=psql://testuser:testpassword@127.0.0.1/django_todo_app_db
-
.env
の設定内容について- DEBUG: 開発環境ではonを設定します
- SECRET_KEY:
django_todo_app_db/settings.py
にベタ打ちされている文字列をこちらに移動します - DATABASE_URL:
psql://${user_name}:${db_password}@${db_host}/${db_name}
のフォーマットになっています
-
settings.py
をdjango-environ
のdocsを参考に更新します
django_todo_app/settings.py
import os
import environ
env = environ.Env(DEBUG=(bool, False))
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
environ.Env.read_env(os.path.join(BASE_DIR, ".env"))
DEBUG = env("DEBUG")
SECRET_KEY = env("SECRET_KEY")
(中略)
DATABASES = {
'default': env.db(),
}
(中略)
LANGUAGE_CODE = "ja"
TIME_ZONE = "Asia/Tokyo"
(中略)
STATIC_URL = "static/"
STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static"),
]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "/login/"
LOGOUT_REDIRECT_URL = "/login/"
LOGIN_REDIRECT_URL = "/tasks/"
-
settings.py
の設定内容について- DEBUG: 開発環境かどうかを表します
- SECRET_KEY:
- DATABASES:
- LANGUAGE_CODE:
- TIME_ZONE:
- STATIC_URL: 静的ファイルのurlを指します
- STATICFILES_DIRS: 静的ファイルの配置パスを表します。今回はプロジェクトルートディレクトリの下に
static
ディレクトリを作ってそちらを配置パスにしています - LOGIN_URL: ログインを行うためのURL(詳しくは後述します)
- LOGOUT_REDIRECT_URL: ログアウトした際にリダイレクトするURL(詳しくは後述します)
- LOGIN_REDIRECT_URL: ログイン後に遷移するURL(詳しくは後述します)
DBマイグレーション(1回目)
- ペンディングになっているマイグレーションがあるので作成したDBに適用します
python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying sessions.0001_initial... OK
ToDoアプリケーションの用意
- まずToDoアプリケーションを作成します
python manage.py startapp todo
- プロジェクトのurlpatternにToDoアプリケーションのurlを追記します
django_todo_app/urls.py
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('todo/urls')),
]
- ToDoアプリケーション内にとりあえず
urls.py
の空ファイルだけを作成しておきます
touch todo/urls.py
モデルの作成
-
models.py
の実装を進めていきます。テーブル構造としてはTask情報を定義していきます
todo/models.py
class Task(models.Model):
STATUS = (
(0, "未着手"),
(1, "進行中"),
(2, "完了"),
(3, "保留"),
)
CATEGORY = (
(0, "管理系"),
(1, "バックエンド開発系"),
(2, "フロントエンド開発系"),
(3, "インフラ系"),
(4, "モバイル系"),
)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(max_length=255)
description = models.TextField()
category = models.IntegerField(choices=CATEGORY)
user = models.ForeignKey(User, on_delete=models.CASCADE)
status = models.IntegerField(choices=STATUS)
due_date = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
-
モデル実装について
- STATUS: こちらはTask情報に対してステータスの情報を持たせているのですが、ステータスの取りうる値を定義しています
- Task: ToDoアプリのタスク情報になります。idはUUIDとして、作成日時のcreated_at, 更新日時のupdated_atを用意しています
- CATEGORY: Task情報に関するカテゴリー情報の取りうる値を定義しています
-
__str__
: モデルのインスタンスを出力した際に表示する文字列を規定しています
-
モデル実装ができたので、マイグレーションファイルを作成します
python manage.py makemigrations
- マイグレーションファイルの内容がモデルで実装した通りになっているか(型情報やNULL制約、インデックス、外部キーの設定など)確認を行います
todo/migrations/0001_initial.py
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Task",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("title", models.CharField(max_length=255)),
("description", models.TextField()),
(
"category",
models.IntegerField(
choices=[
(0, "管理系"),
(1, "バックエンド開発系"),
(2, "フロントエンド開発系"),
(3, "インフラ系"),
(4, "モバイル系"),
]
),
),
(
"status",
models.IntegerField(
choices=[(0, "未着手"), (1, "進行中"), (2, "完了"), (3, "保留")]
),
),
("due_date", models.DateTimeField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
- 問題なさそうならマイグレーションの適用を行います。
Applying todo.0001_initial... OK
と表示されればOK
python manage.py migrate
URL設計とビューの作成
-
ざっとモデルの実装が終わったので今度はURL設計を行います。下記のようにURLを設計しました
URL 画面名 ログイン認証 signup/ サインアップ 不要 login/ ログイン 不要 logout/ ログアウト 不要 tasks/ タスク一覧 必要 tasks/create タスク登録 必要 tasks//update タスク編集 必要 tasks//delete タスク削除 必要 -
上記からToDoアプリケーションのurlpatternを定義します。次に各Viewを作成していきます
todo/urls.py
urlpatterns = [
path("login/", UserLoginView.as_view(), name="login"),
path("logout/", LogoutView.as_view(), name="logout"),
path("signup/", UserRegistrationView.as_view(), name="signup"),
path("tasks/", TaskListView.as_view(), name="task-list"),
path("tasks/create/", TaskCreateView.as_view(), name="task-create"),
path("tasks/<uuid:pk>/update/", TaskUpdateView.as_view(), name="task-update"),
path("tasks/<uuid:pk>/delete/", TaskDeleteView.as_view(), name="task-delete"),
]
ビューの作成
- ビューを作成します。長いですがざっと解説していきます
todo/views.py
class UserLoginView(LoginView):
template_name = "todo/login.html"
form_class = UserLoginForm
success_url = reverse_lazy("task-list")
class UserRegistrationView(CreateView):
template_name = "todo/signup.html"
form_class = UserRegistrationForm
success_url = reverse_lazy("login")
def form_valid(self, form):
user = form.save()
login(self.request, user)
return super().form_valid(form)
class TaskListView(LoginRequiredMixin, ListView):
template_name = "todo/tasks/list.html"
model = Task
def get_queryset(self):
return Task.objects.select_related("user")
class TaskCreateView(LoginRequiredMixin, CreateView):
template_name = "todo/tasks/create.html"
model = Task
form_class = TaskCreateForm
success_url = reverse_lazy("task-list")
def form_valid(self, form):
form.instance.user_id = self.request.user.id
return super().form_valid(form)
class TaskUpdateView(LoginRequiredMixin, UpdateView):
template_name = "todo/tasks/update.html"
model = Task
form_class = TaskUpdateForm
success_url = reverse_lazy("task-list")
class TaskDeleteView(LoginRequiredMixin, DeleteView):
template_name = "todo/tasks/delete.html"
model = Task
success_url = reverse_lazy("task-list")
- ビューについてざっと説明します
- UserLoginView: ログイン処理のためのビューになります。後ほどクライアントから送信される情報を使って認証を行う
UserLoginForm
というフォームクラスを作成するのですが、そのフォームクラスやテンプレート、ログイン成功後のURLを設定しています - UserRegistrationView: ユーザー登録するためのビューです。こちらも
UserRegistrationForm
というユーザー登録を担当するフォームクラスを後ほど実装します。また、form_valid
メソッドを上書きしています。こちらのメソッドはユーザーから送信されたユーザー登録情報が正しい情報であった際に行う処理を記載します。今回はDBに対象ユーザー情報を登録しています - TaskListView: タスク一覧画面のためのビューになります
- TaskCreateView: タスクを登録する画面を提供するためのビューになります。
form_valid
メソッドを上書きしていて、フォーム情報が正しい場合user_id
にログイン中のユーザーIDを設定するようにしています - TaskUpdateView: タスク更新のためのビューになります
- TaskDeleteView: タスク削除のためのビューになります
- UserLoginView: ログイン処理のためのビューになります。後ほどクライアントから送信される情報を使って認証を行う
フォームの作成
- 次にforms.pyを作成します。フォームはユーザーが情報を入力したりその情報を送信した際に内部的にその情報を処理するためのクラスになります
todo/forms.py
class UserLoginForm(AuthenticationForm):
remember_me = forms.BooleanField(required=False, widget=forms.CheckboxInput())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["username"].widget.attrs.update(
{"class": "form-control", "placeholder": "Username"}
)
self.fields["password"].widget.attrs.update(
{"class": "form-control", "placeholder": "Password"}
)
class UserRegistrationForm(UserCreationForm):
email = forms.EmailField(required=True)
class Meta:
model = User
fields = ("username", "password1", "password2", "email")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["email"].widget.attrs.update(
{"class": "form-control", "placeholder": "Email"}
)
self.fields["username"].widget.attrs.update(
{"class": "form-control", "placeholder": "Username"}
)
self.fields["password1"].widget.attrs.update(
{"class": "form-control", "placeholder": "Password"}
)
self.fields["password2"].widget.attrs.update(
{"class": "form-control", "placeholder": "Password"}
)
def save(self, commit=False):
user = super().save(commit=False)
validate_password(self.cleaned_data.get("password1"), user)
user.set_password(self.cleaned_data.get("password1"))
user.save()
return user
class TaskUpdateForm(forms.ModelForm):
class Meta:
model = Task
fields = ["title", "description", "category", "status", "due_date"]
widgets = {
"title": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "タイトルを入力してください",
}
),
"description": forms.Textarea(
attrs={"class": "form-control", "placeholder": "説明を入力してください"}
),
"category": forms.Select(
attrs={
"class": "form-control",
"placeholder": "カテゴリーを入力してください",
}
),
"status": forms.Select(
attrs={
"class": "form-control",
"placeholder": "ステータスを入力してください",
}
),
"due_date": forms.DateTimeInput(
attrs={"class": "form-control", "type": "datetime-local"}
),
}
class TaskCreateForm(forms.ModelForm):
class Meta:
model = Task
fields = ["title", "description", "category", "status", "due_date"]
widgets = {
"title": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "タイトルを入力してください",
}
),
"description": forms.Textarea(
attrs={"class": "form-control", "placeholder": "説明を入力してください"}
),
"category": forms.Select(
attrs={
"class": "form-control",
"placeholder": "カテゴリーを入力してください",
}
),
"status": forms.Select(
attrs={
"class": "form-control",
"placeholder": "ステータスを入力してください",
}
),
"due_date": forms.DateTimeInput(
attrs={"class": "form-control", "type": "datetime-local"}
),
}
-
各フォームクラスの解説
- UserLoginForm: ログイン処理を担当するフォームです。
__init__
を上書きしてcssのデザインを調整したりplaceholderを設定しています。AuthenticationForm
を継承しているのでusername, passwordを使用できますが、それに加えてremember_me
を設定しています - UserRegistrationForm: ユーザー登録に使用するフォームです。username, password1, password2に加えて
email
を設定しています。UserLoginFormと同様にcssのデザインを調整したりplaceholderを設定しています。また、save
メソッドを上書きしてパスワードの検証を行なっています - TaskUpdateForm: タスク更新を担当するフォームです
- TaskCreateForm: タスク登録を担当するフォームです
- UserLoginForm: ログイン処理を担当するフォームです。
テンプレートの作成
- 次にテンプレートを作成します。まず色んなテンプレートから参照する共通テンプレートを作成します。プロジェクトルートに
templates
ディレクトリを作成してそちらを配置パスとします。今回はユーザー登録、ログイン画面用の共通テンプレート(auth_base.html
)とログイン後のタスク管理のための共通テンプレート(base.html
)の2つを用意します
mkdir templates
touch templates/auth_base.html
touch templates/base.html
-
ログイン画面用の共通テンプレートから作成します。ポイントは下記の通りです
-
load static
で静的ファイルをロード - BootstrapのCSSとJSファイルをCDNからインポート
-
block content
で各ページ部分を呼び出す
-
templates/auth_base.html
{% load static %}
<!DOCTYPE html>
<html lang="ja">
<head>
<title>DjangoTodoApp</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
</head>
<body>
<div class="container text-center m-4">
<img class="mb-4" src="{% static 'images/animal_chara_computer_usagi.png' %}" alt="logo" width="100" height="100">
{% block content %}{% endblock %}
<p class="mt-5 mb-3 text-muted">© 2023-2024</p>
</div>
</body>
</html>
- 次にログイン画面、ユーザー登録画面を作成します。最初に空のファイルを作成します
mkdir -p todo/templates/todo
touch todo/templates/todo/login.html
touch todo/templates/todo/signup.html
-
ログイン画面、ユーザー登録画面のポイントは下記の通りです
-
extends "auth_base.html"
で共通テンプレートを指定。block content
内でページの中身の部分を実装 - form要素で
method=post
を指定し、ログイン画面ではurl 'login'
、ユーザー登録画面ではurl 'signup'
を指定 -
csrf_token
でCSRF対策 -
form.errors
でフォームのエラー内容を表示 -
form.username
などでフォームの入力エリアを表示
-
todo/templates/todo/login.html
{% extends "auth_base.html" %}
{% block content %}
<div>
<h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
<form class="w-50 p-3 m-auto" method="post" action="{% url 'login' %}">
{% csrf_token %}
{% if form.errors %}
{{form.errors}}
{% endif %}
<div class="form-group mb-3">
{{ form.username }}
{{ form.username.errors }}
</div>
<div class="form-group mb-3">
{{ form.password }}
{{ form.password.errors }}
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
</form>
</div>
{% endblock content %}
todo/templates/todo/signup.html
{% extends "auth_base.html" %}
{% block content %}
<div>
<h1 class="h3 mb-3 font-weight-normal">Please sign up</h1>
<form class="w-50 p-3 m-auto" method="post" action="{% url 'signup' %}">
{% csrf_token %}
{% if form.errors %}
{{form.errors}}
{% endif %}
<div class="form-group mb-3">
{{ form.email }}
{{ form.email.errors }}
</div>
<div class="form-group mb-3">
{{ form.username }}
{{ form.username.errors }}
</div>
<div class="form-group mb-3">
{{ form.password1 }}
{{ form.password1.errors }}
</div>
<div class="form-group mb-3">
{{ form.password2 }}
{{ form.password2.errors }}
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign up</button>
</form>
</div>
{% endblock content %}
- ログイン画面、ユーザー登録画面は下記のようなイメージになります
- 次にタスク管理ページ用の共通テンプレートを作成します
templates/base.html
{% load static %}
<!DOCTYPE html>
<html lang="ja">
<head>
<title>DjangoTodoApp</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
</head>
<body>
<header class="p-3 mb-3 border-bottom">
<div class="container">
<div class="d-flex justify-content-between align-items-center justify-content-center">
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="{% url 'task-list' %}">DjangoTodoApp</a>
<div class="dropdown text-end">
<a href="#" class="d-block link-body-emphasis text-decoration-none dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<img src="{% static 'images/animal_usagi.png' %}" alt="mdo" width="32" height="32" class="rounded-circle">
</a>
<ul class="dropdown-menu text-small" style="position: absolute; inset: 0px 0px auto auto; margin: 0px; transform: translate(0px, 34px);" data-popper-placement="bottom-end">
<li><a class="dropdown-item" href="#">Profile</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="nav-link px-3">Log out</button>
</form>
</li>
</ul>
</div>
</div>
</div>
</header>
<div class="container-fluid">
{% block content %}{% endblock %}
</div>
</body>
</html>
- 次にタスク一覧、編集、登録画面を作成していきます
todo/templates/todo/tasks/list.html
{% extends "base.html" %}
{% block content %}
<a class="btn btn-primary my-3" href="{% url 'task-create' %}" role="button">Create</a>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">title</th>
<th scope="col">user</th>
<th scope="col">category</th>
<th scope="col">status</th>
<th scope="col">due_date</th>
<th scope="col">edit</th>
<th scope="col">delete</th>
</tr>
</thead>
<tbody>
{% for task in object_list %}
<tr>
<td>{{ task.title }}</td>
<td>{{ task.user.username }}</td>
<td>{{ task.get_category_display }}</td>
<td>{{ task.get_status_display }}</td>
<td>{{ task.due_date|date:"Y/m/d" }}</td>
<td><a class="btn btn-primary" href="{% url 'task-update' pk=task.id %}" role="button">Edit</a></td>
<td>
<form method="post" action="{% url 'task-delete' pk=task.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}
todo/templates/todo/tasks/update.html
{% extends "base.html" %}
{% block content %}
<form class="form-group" method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" class="btn btn-primary" value="Update">
</form>
{% endblock content %}
todo/templates/todo/tasks/create.html
{% extends "base.html" %}
{% block content %}
<form class="form-group" method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" class="btn btn-primary" value="Create">
</form>
{% endblock content %}