DjangoでTodoListを作った話

# 作ったきっかけ
djangoでのwebアプリ開発をしたかったので、簡単に作れそうなtodolistwebアプリケーションを作りました。

:::note warn
この記事は開発日記のようなものなのでかなり乱雑です、参考になる部分は少ないと思います
:::
## ファイル構成

```
.
├── README.md
├── containers
│   ├── django
│   │   └── Dockerfile
│   └── postgres
│       └── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── todolist
    ├── accounts
    │   ├── __init__.py
    │   ├── admin.py
    │   ├── apps.py
    │   ├── migrations
    │   │   └── __init__.py
    │   ├── models.py
    │   ├── templates
    │   │   └── registration
    │   │       ├── layout.html
    │   │       ├── login.html
    │   │       ├── logout.html
    │   │       └── signup.html
    │   ├── tests.py
    │   ├── urls.py
    │   └── views.py
    ├── manage.py
    ├── todo
    │   ├── __init__.py
    │   ├── admin.py
    │   ├── apps.py
    │   ├── forms.py
    │   ├── migrations
    │   │   ├── 0001_initial.py
    │   │   └── __init__.py
    │   ├── models.py
    │   ├── static
    │   │   └── todo
    │   │       └── style.css
    │   ├── templates
    │   │   └── todo
    │   │       ├── layout.html
    │   │       ├── task_confirm_delete.html
    │   │       ├── task_detail.html
    │   │       ├── task_form.html
    │   │       └── task_list.html
    │   ├── tests.py
    │   ├── urls.py
    │   └── views.py
    └── todolist
        ├── __init__.py
        ├── asgi.py
        ├── settings.py
        ├── urls.py
        └── wsgi.py
```
# 開発環境構築

### dockercomposeでの環境構築

今回は、djangoとpostgresqlの環境をdockercomposeで構築しました。

- djangoの`Dockerfile`

```.containers/django/Dockerfile
FROM python:3.9

ENV PYTHONUNBUFFERED=1

ENV PYTHONDONTWRITEBYTECODE=1

WORKDIR /code

COPY requirements.txt /code/

RUN pip install --upgrade pip

RUN pip install --no-cache-dir -r requirements.txt

COPY . /code/

```

>PYTHONUNBUFFERED=1では、標準出力、標準エラーのバッファリングを行わないので、リアルタイムでのタイムログが見れます。なくてもいいですが、リアルタイムでエラーがわかるので念のため書きました。

>PYTHONDONTWRITEBYTECODE=1では、pychachファイル(キャッシュファイル)を生成されなくなります。.gitignoreし忘れると面倒なので書きました

- postgresqlの`Dockerfile`

```.containers/postgres/Dockerfile
FROM postgres:15.2

ARG DB_LANG=ja_JP
RUN localedef -i $DB_LANG -c -f UTF-8 -A /usr/share/locale/locale.alias $DB_LANG.UTF-8
ENV LANG $DB_LANG.utf8
```
>postgresqlのver指定の後ろは、一応日本語対応させるために書きました。しなくても日本語対応しているらしいですが、不安なので書きました。

- `docker-compose.yml`

```docker-compose.yml
version: '3'

services:
  web:
    container_name: app
    build:
      context: .
      dockerfile: containers/django/Dockerfile
    working_dir: /code/todolist/
    volumes:
      - .:/code
    ports:
      - "8000:8000"
    command: python manage.py runserver 0.0.0.0:8000

    depends_on:
      - db
    env_file:
      - .env
  db:
    container_name: postgres
    build:
      context: .
      dockerfile: containers/postgres/Dockerfile
    environment:
      - POSTGRES_DB=${DATABASE_NAME}
      - POSTGRES_USER=${DATABASE_USER}
      - POSTGRES_PASSWORD=${DATABASE_PASSWORD}
    volumes:
      - db_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  db_data:

```

- `requirements.txt`の記述

```requirements.txt
Django==4.1
psycopg2 
```
:::note
postgresqlを操作するためにpsycopg2が必要です
:::

- `.env`の記述

```.env
SECRET_KEY= 'プロジェクトファイルに書いてあるsecretkeyを書きました'
DATABASE_NAME= tododb
DATABASE_USER= admin
DATABASE_PASSWORD= todops
DATABASE_HOST= db
DATABASE_PORT = 5432 #PostgreSQLなら
LANG = ja_JP.utf8
LC_CTYPE =  ja_JP.utf8
```
>勉強で作っただけなのでsecretkey書くか迷いました。

:::note
以下のコマンドでシークレットキーは生成できます
```
docker compose run web python manage.py shell
>>>from django.core.management.utils import get_random_secret_key
>>> print(get_random_secret_key())
```
:::

### プロジェクトファイルの設定

todolistというproject名で作成しました。
以下のコマンドで作成しました。

```
docker-compose run web django-admin startproject todolist .
```

1.`setting.py`の編集

```python:todolist/todolist/setting.py
ALLOWED_HOSTS = [
    '0.0.0.0',
    '127.0.0.1',
    'localhost'
]

INSTALLED_APPS = [
#configをつけるとカスタム設定でできるらしい
    'accounts.apps.AccountsConfig',
    'todo',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR,'templates'),
                 os.path.join(BASE_DIR, 'todolist', 'templates'),  # 追加
                 os.path.join(BASE_DIR, 'todolist', 'todo', 'templates'),
                 ],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

DATABASES = {
    'default': {
        # 'ENGINE': 'django.db.backends.sqlite3',
        # 'NAME': BASE_DIR / 'db.sqlite3',
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DATABASE_NAME'),
        'USER': os.environ.get('DATABASE_USER'),
        'PASSWORD': os.environ.get('DATABASE_PASSWORD'),
        'HOST': os.environ.get('DATABASE_HOST'),
        'PORT': os.environ.get('DATABASE_PORT'),
    }
}

#時間を日本に設定
LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'
```


2.`urls.py`の編集

```python:todolist/todolist/urls.py
from django.contrib import admin
from django.urls import path,include
from django.views.generic import RedirectView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('todo/',include('todo.urls')),
    path('accounts/',include('accounts.urls')),
    path('',RedirectView.as_view(url='todo/')),
]
```

### アプリの作成

以下のコマンドでアプリを作成しました。

```
docker compose run web python manage.py startapp todo
```

1.`urls.py`

```python:todolist/todo/urls.py
from django.urls import path
from . import views

app_name = 'todo'

urlpatterns = [
    path('', views.ListView.as_view(), name='list'),
    path('<int:pk>/', views.DetailView.as_view(), name='detail'),
    path('create/',views.CreateView.as_view(),name='create'),
    path('<int:pk>/update/',views.UpdateView.as_view(),name='update'),
    path('<int:pk>/delete/',views.DeleteView.as_view(),name='delete'),
    path('<int:pk>/complete/',views.complete_task,name='complete'),
    path('<int:pk>/reverse/',views.reverse_task,name='reverse'),
]

```

2.`views.py`

```python:todolist/todo/views/py
from django.forms import DateInput
from django.http import HttpResponse
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse_lazy
from django.views import generic
from .models import Task
from .forms import TaskForm
#ログインしていないとviewを表示できないように
from django.contrib.auth.mixins import LoginRequiredMixin

def index(request):
    return HttpResponse("Hello, world. You're at the polls index.")

class ListView(LoginRequiredMixin,generic.ListView):
    model = Task
    template_name = "todo/task_list.html"

    #コンテキストを加工するメソッド
    def get_context_data(self):
        context = super().get_context_data()
        user = self.request.user

        #prioritykeyに対して降順に表示させるためにorder_by('pk')
        context["completed_tasks"] = Task.objects.filter(author = user,completed=True).order_by('pk')
        context["incompleted_tasks"] = Task.objects.filter(author = user,completed=False).order_by('pk')
        return context

class DetailView(LoginRequiredMixin,generic.DetailView):
    model = Task
    fields = '__all__'

class CreateView(LoginRequiredMixin,generic.edit.CreateView):
    template_name = 'todo/task_form.html'
    form_class = TaskForm

class UpdateView(LoginRequiredMixin,generic.edit.UpdateView):
    template_name = 'todo/task_form.html'
    form_class = TaskForm

    #UpdateView is missing a QuerySet. Define UpdateView.model, UpdateView.queryset, or override UpdateView.get_queryset().
    #解決するためにget_querysetメソッドをオーバーライド
    def get_queryset(self):
        return Task.objects.all()

class DeleteView(LoginRequiredMixin,generic.edit.DeleteView):
    template_name = 'todo/task_confirm_delete.html'
    model = Task

    #reverse_lazyはクラスベースビューで主に使う
    #URLパターンが読み込まれていなくても使える
    #必要になったら指定した先にリダイレクトする
    success_url = reverse_lazy('todo:list')

def complete_task(request, pk):
    task = get_object_or_404(Task,pk=pk)
    task.completed = True
    task.save()
    return redirect('todo:list')

def reverse_task(request,pk):
    task = get_object_or_404(Task,pk=pk)
    task.completed = False
    task.save()
    return redirect('todo:list')

```
CreateViewが機能せず困ったことがありましたが、解決しました。
気になった方は[こちら](https://qiita.com/maooz4426/items/79da38841e5f1e18d9bb)をご覧ください。

3.`forms.py`

```python:todolist/todo/forms.py
from django import forms

from django.contrib.admin.widgets import AdminDateWidget

from .models import Task

class TaskForm(forms.ModelForm):
    class Meta:
        model = Task
        fields = ['title', 'author', 'category', 'deadline', ]

        #ウィジェットはフォームとかの部品の種類を指定する要素
        #djangoのフォームウィジェットにHTML属性を付与する(attrs)
        widgets = {
            'title': forms.TextInput(attrs={'class': 'form-control'}),
            'author': forms.Select(attrs={'class': 'form-control'}),
            'category': forms.Select(attrs={'class': 'form-control'}),  # カテゴリが選択肢の場合
            'deadline': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),

        }
```

4.`models.py`

```todolist/todo/models.py
from django.db import models
from django.urls import reverse


#カテゴリモデル
class Category(models.Model):

    #カテゴリ名
    name = models.CharField(max_length=255)
    #カテゴリを設定した人
    author = models.ForeignKey(
        'auth.User',on_delete=models.CASCADE,
    )
    #作成日
    created_at = models.DateTimeField(auto_now_add=True)
    #更新日
    updated_at = models.DateTimeField(auto_now=True)

    #これ入れないと管理画面でcategoryobjectって名前になる
    def __str__(self):
        return self.name

class Task(models.Model):

    #タスク名
    title = models.CharField(max_length=255)
    # タスクを設定した人
    author = models.ForeignKey(
        'auth.User',
        on_delete=models.CASCADE,
    )
    #カテゴリ（外部キー参照)
    category = models.ForeignKey(
        Category, on_delete=models.PROTECT
    )
    #完了確認
    completed = models.BooleanField(default=False)
    #締切日
    deadline = models.DateTimeField()
    #タスク作成日
    created_at = models.DateTimeField(auto_now_add=True)
    #タスク完了日
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('todo:detail', kwargs={'pk': self.pk})

```

マイグレートは以下のコマンドでやりました。
```
docker compose run web puthon manage.py makemigrations
```

```
docker compose run web python manage.py migrate
```
5.`admin.py`

```todolist/todo/admin.py
from django.contrib import admin
from .models import Category,Task

admin.site.register(Category)
admin.site.register(Task)

```

6.`templates`の記述

- `layout.html`

```html: todolist/todo/templates/todo/layout.html
{% load static %}
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>TodoList</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
{#   <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">#}
{#      バージョン5.0.2だとユーザー名の位置がずれたので4.0.0で固定#}
        <link rel='stylesheet' href='https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css' integrity='sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm' crossorigin='anonymous'>
      <link rel="stylesheet" href="{% static 'todo/style.css' %}">
      <style>
       body {padding-top: 80px;}

      </style>
  </head>
  <body>

<!--  ブレイクポイントはsmall -->

<nav class='navbar navbar-expand-sm navbar-dark bg-dark fixed-top'>
            <a class='navbar-brand ' href='{% url "todo:list" %}'>TodoList</a>
            <ul class='navbar-nav'>
{#            ユーザーが認証されているかチェック#}
                {% if user.is_authenticated %}
                    <li class='nav-time'>
                        <span class='navbar-text'>{{ user }} - </span>
                    </li>
                    <li class='nav-item'>
                        <a href='{% url "accounts:logout" %}' class='logout nav-link'>Logout</a>
                    </li>
                {% else %}
                    <a href='{% url "accounts:login" %}' class='login nav-link'>Login</a>
                {% endif %}
            </ul>
</nav>

  <div class="container">
    {% block content%} 
    {% endblock %}
  </div>
  </body>
</html>

```

- `task_list.html`

```html: todolist/todo/templates/todo/task_list.html
{% extends './layout.html' %}

{% block content %}
<div class="header ">
    <h1>Task一覧</h1>
        <a class="btn btn-primary" href="{% url 'todo:create' %}">作成</a>
</div>

<table class="table incompleted_table">
    <thead class="thead-dark">
        <tr>
            <th class="th-custom">タスクNo</th>
            <th class="th-custom">タスク名</th>
            <th class="th-custom">操作</th>
        </tr>
    </thead>
    <tbody>
        {% for task in incompleted_tasks %}
            {% if not task.completed %}
                <tr>
                    <td>{{ task.pk }}</td>
                    <td>{{ task.title }}</td>
                    <td>
                        <a class="btn btn-info" href="{% url 'todo:detail' task.pk %}">詳細</a>
                        <a class='btn btn-secondary' href="{% url 'todo:update' task.pk %}">編集</a>
                        <a class='btn btn-danger' href="{% url 'todo:delete' task.pk %}">削除</a>
                        <a class='btn btn-success' href="{% url 'todo:complete' task.pk %}">完了</a>
                    </td>
                </tr>
            {% endif %}
        {% endfor %}
    </tbody>
</table>

<h1>完了済みタスク一覧</h1>
<table class="table completed_table">
    <thead class="thead-dark">
        <tr>
            <th class="th-custom">タスクNo</th>
            <th class="th-custom">タスク名</th>
            <th class="th-custom">操作</th>
        </tr>
    </thead>
    <tbody>
        {% for task in completed_tasks %}
            {% if task.completed %}
                <tr>
                    <td>{{ task.pk }}</td>
                    <td>{{ task.title }}</td>
                    <td>
                        <a class="btn btn-info" href="{% url 'todo:detail' task.pk %}">詳細</a>
                        <a class='btn btn-secondary' href="{% url 'todo:update' task.pk %}">編集</a>
                        <a class='btn btn-danger' href="{% url 'todo:delete' task.pk %}">削除</a>
                        <a class='btn btn-secondary' href="{% url 'todo:reverse' task.pk %}">元に戻す</a>
                    </td>
                </tr>
            {% endif %}
        {% endfor %}
    </tbody>
</table>
{% endblock %}

```

- `task_form.html`

```html: todolist/todo/templates/todo/task_form.html
{% extends './layout.html' %}

{% block content %}

    <div>
        <form method='post'>{% csrf_token %}
            <div class="mb-3">
                <label class="form-label">タスク名</label>
                {{ form.title }}

            </div>
            <div class="mb-3">
                <label class="form-label">作成者</label>
                {{ form.author }}

            </div>
            <div class="mb-3">
                <label class="form-label">カテゴリ</label>
                {{ form.category }}

            </div>
            <div class="mb-3">
                <label class="form-label">締切</label>
                {{ form.deadline }}

            </div>
{#既存のデータがあったら更新、なかったら作成表示#}
           <div class="btn-container d-flex justify-content-between">

                     <a class="btn btn-secondary" href="JavaScript:history.back()">戻る</a>

                    <button type="submit" class="btn btn-primary">{{ object|yesno:'更新,作成' }}</button>

            </div>

        </form>
    </div>



{% endblock %}
```

- `task_detail.html`

```html: todolist/todo/templates/todo/task_detail.html
{% extends './layout.html' %}

{% block content %}
    <div class='card text-center'>
    <div class="card-header bg-info text-white">
        <h1>{{ task.title }}</h1>
    </div>
    <div class="card-body">
        <p class="card-text">タスクを設定した人: {{ task.author }}</p>
        <p class="card-text">カテゴリ: {{ task.category.name }}</p>
        <p class="card-text">締切: {{ task.deadline }}</p>
    </div>
    </div>


<div class="d-flex justify-content-between">
    <a class = ' btn btn-secondary' href='{% url "todo:list" %}'>一覧</a>
    <a class = 'btn btn-primary' href="{% url 'todo:update' task.pk%}">編集</a>
</div>

{% endblock %}
```

- `task_comfirm_delete.html`
```html: todolist/todo/templates/todo/task_confirm.html
{% extends './layout.html' %}

{% block content %}
    <div class="card text-center">

        <div class="card-header bg-danger text-white" >
            <h1>タスクの削除</h1>
        </div>

        <div class="card-body">
            <div class="card-text">
                <p>{{ task.title }}を削除しますか?</p>
            </div>
        </div>

    </div>

    <div class="d-flex justify-content-between">

        <div>
            <a class="btn btn-secondary" href="JavaScript:history.back()">戻る</a>
        </div>
        <form method='post'>{% csrf_token %}
            <button type='submit' class='submit delete btn btn-danger'>削除</button>
        </form>


    </div>

{% endblock %}
```

### ログイン管理のアプリ作成

以下のコマンドで作成しました
```
docker compose run web python manage.py startapp.py accounts
```

1.`urls.py`

```todolist/todolist/accounts/urls.py
from django.urls import path
from .views import SignupView,CustomLoginView,CustomLogoutView

app_name = 'accounts'
urlpatterns = [
    path('signup/', SignupView.as_view(),name='signup'),
    path('login/',CustomLoginView.as_view(),name='login'),
    path('logout/',CustomLogoutView.as_view(),name='logout'),
]
```

2.`views.py`

```todolist/todolist/accounts/views.py
from django.shortcuts import render
from django.urls import reverse_lazy
from django.views import generic
from django.contrib.auth.forms import UserCreationForm,AuthenticationForm
from django.contrib.auth.views import LoginView as AuthLoginView,LogoutView as AuthLogoutView

class SignupView(generic.CreateView):
    form_class = UserCreationForm
    success_url = reverse_lazy('accounts:login')
    template_name = 'registration/signup.html'

class CustomLoginView(AuthLoginView):
    form_class = AuthenticationForm
    template_name = 'registration/login.html'

#ログインできたら移るurlを指定
    def get_success_url(self):
        return reverse_lazy('todo:list')

class CustomLogoutView(AuthLogoutView):
    template_name = 'registration/logout.html'
```

# コンテナの起動

コンテナのイメージを作成、コンテナの起動を以下のコマンドで行います。

```
docker compose up --build
```
http://localhost:8000/ を検索して

- list画面
![スクリーンショット 2024-05-24 0.28.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/520268ea-6016-eeb0-3534-ae33c6dfde5e.png)

- 詳細画面
![スクリーンショット 2024-05-24 13.57.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/40a65df8-e685-92eb-4c0a-3e1ed999f0ea.png)

- 編集画面
![スクリーンショット 2024-05-24 13.57.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/4e805223-5055-9919-47a2-57a7e90cdbe1.png)

- 削除画面
![スクリーンショット 2024-05-24 13.57.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/8312cdff-1314-7392-7c69-48f47d34fb8f.png)

- ログアウト画面
![スクリーンショット 2024-05-24 13.57.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/23db90da-0f06-2a50-ce3c-91ee4d1444a4.png)

- ログイン画面
![スクリーンショット 2024-05-24 13.57.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3766230/5b08f7a7-bcda-f908-4716-088c09a693de.png)

こんな感じになりました。


# 感想
今回はbootstrapとdjangoにある汎用ビューを使って簡単に作りました。

大変だった点としては、ログイン用の汎用ビューがあると聞いて、それを調べるのと、締切日の設定をカレンダーでやる方法を見つけるのに苦労しました。
また、herokuにデプロイしようと思ったのですが、うまくやり方がわからず、困ったのでまた勉強しようと思います。

反省点としては、かなりディレクトリ構成が複雑で`todolist/todolist/`というパスになってしまったのがとても複雑だなと感じました。ディレクトリ構成がわかりにく問題は今後改善していきたいです。
>後すごく冗長な記事になってしまったのでそれも改善したいです...

https://github.com/maooz4426/TodoList-Django