【Django】アソシエーション機能を実装する|簡単な掲示板アプリの作成

Djangoで掲示板アプリを題材に、投稿とユーザーを紐付けるアソシエーション機能を実装します。ForeignKeyを使ってモデル同士を関連付け、ログインユーザーの投稿だけを表示する一覧ページを作成します。また、投稿者本人だけが記事を編集・削除できるように、カスタムデコレータを用いたアクセス制限の方法についても解説します。

作成日: 更新日:

開発環境

  • OS: Windows10
  • Visual Studio Code: 1.73.0
  • Python: 3.10.11
  • Django: 5.0.3

サンプルコード

/app/models.py

/app/models.py
1 from django.db import models
2+from django.contrib.auth.models import User
3 
4 class Board(models.Model):
5     title = models.CharField(max_length=200)
6     content = models.TextField()
7+    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='boards', null=True)
8     created_at = models.DateTimeField(auto_now_add=True)
9     updated_at = models.DateTimeField(auto_now=True)
10 
11

/app/urls.py

/app/urls.py
1     path('<int:pk>/edit/', login_required(views.edit), name='edit'),
2     path('<int:pk>/update/', login_required(views.update), name='update'),
3     path('<int:pk>/delete/', login_required(views.delete), name='delete'),
4+    path('my_boards/', login_required(views.my_boards), name='my_boards'),
5 ]
6

/app/views.py

/app/views.py
1 from django.contrib.auth import logout
2 from django.contrib.auth.views import LoginView
3 from django.contrib.auth.decorators import login_required
4+from functools import wraps
5+
6+def user_owns_board(view_func):
7+    @wraps(view_func)
8+    def wrapper(request, pk):
9+        board = get_object_or_404(Board, pk=pk)
10+        if board.user == request.user:
11+            return view_func(request, pk)
12+        else:
13+            return redirect('index')
14+    return wrapper
15 
16 def index(request):
17     boards = Board.objects.all().order_by('-updated_at')
18     if request.method == 'POST':
19         form = BoardForm(request.POST)
20         if form.is_valid():
21+            form.instance.user = request.user
22             form.save()
23             return redirect('index')
24     else:
25     return render(request, 'show.html', {'board': board})
26 
27 @login_required
28+@user_owns_board
29 def edit(request, pk):
30     board = Board.objects.get(pk=pk)
31     form = BoardForm(instance=board)
32     return render(request, 'edit.html', {'form': form, 'board': board})
33 
34 @login_required
35+@user_owns_board
36 def update(request, pk):
37     board = Board.objects.get(pk=pk)
38     if request.method == 'POST':
39     return render(request, 'edit.html', {'form': form, 'board': board})
40 
41 @login_required
42+@user_owns_board
43 def delete(request, pk):
44     board = get_object_or_404(Board, pk=pk)
45     if request.method == 'POST':
46         return redirect('index')
47     return redirect('index', pk=pk)
48 
49+@login_required
50+def my_boards(request):
51+    user = request.user
52+    boards = user.boards.all()
53+    return render(request, 'my_boards.html', {'boards': boards})
54+
55 # ログインページのビュー
56 class CustomLoginView(LoginView):
57     template_name = 'registration/login.html'
58         'user': user
59     }
60     return render(request, 'accounts/profile.html', context)
61+
62

/templates/index.html

/templates/index.html
1             </tbody>
2         </table>
3         <a href="{% url 'new' %}" class="btn btn-primary">新規投稿作成</a>
4+        <a href="{% url 'my_boards' %}" class="btn btn-primary">自分の投稿一覧</a>
5     </section>
6 {% endblock %}
7

/templates/my_boards.html

/templates/my_boards.html
1+{% extends 'base.html' %}
2+
3+{% block title %}自分の投稿一覧{% endblock %}
4+
5+{% block content %}
6+    <section>
7+        <h2>自分の投稿一覧</h2>
8+        <table class="table">
9+            <colgroup>
10+                <col style="width: 10%">
11+                <col style="width: 50%">
12+                <col style="width: 20%">
13+                <col style="width: 20%">
14+            </colgroup>
15+            <thead>
16+                <tr>
17+                    <th scope="col">掲示板ID</th>
18+                    <th scope="col">タイトル</th>
19+                    <th scope="col">投稿日時</th>
20+                    <th scope="col">更新日時</th>
21+                </tr>
22+            </thead>
23+            <tbody>
24+                {% for board in boards %}
25+                    <tr>
26+                        <td scope="row">{{ board.id }}</td>
27+                        <td><a href="{% url 'show' pk=board.id %}">{{ board.title }}</a></td>
28+                        <td>{{ board.created_at }}</td>
29+                        <td>{{ board.updated_at }}</td>
30+                    </tr>
31+                {% endfor %}
32+            </tbody>
33+        </table>
34+        <a href="{% url 'new' %}" class="btn btn-primary">新規投稿作成</a>
35+        <a href="{% url 'index' %}" class="btn btn-primary">みんなの投稿一覧</a>
36+    </section>
37+{% endblock %}
38

/templates/show.html

/templates/show.html
1             更新日時: {{ board.updated_at }}
2         </p>
3         <a href="{% url 'index' %}" class="btn btn-primary">一覧に戻る</a>
4-        <a href="{% url 'edit' pk=board.id %}" class="btn btn-success">編集する</a>
5-        <form action="{% url 'delete' pk=board.pk %}" method="post" class="d-inline">
6-            {% csrf_token %}
7-            <input type="hidden" name="action" value="delete">
8-            <button type="submit" class="btn btn-danger">削除する</button>
9-        </form>
10+        {% if board.user == request.user %}
11+            <a href="{% url 'edit' pk=board.id %}" class="btn btn-success">編集する</a>
12+            <form action="{% url 'delete' pk=board.pk %}" method="post" class="d-inline">
13+                {% csrf_token %}
14+                <input type="hidden" name="action" value="delete">
15+                <button type="submit" class="btn btn-danger">削除する</button>
16+            </form>
17+        {% endif %}
18     </section>
19 {% endblock %}
20

コード解説

変更点: 投稿(Board)とユーザー(User)の関連付け

/app/models.py
1 from django.db import models
2+from django.contrib.auth.models import User
3 
4 class Board(models.Model):
5     title = models.CharField(max_length=200)
6     content = models.TextField()
7+    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='boards', null=True)
8     created_at = models.DateTimeField(auto_now_add=True)
9     updated_at = models.DateTimeField(auto_now=True)

投稿(Boardモデル)とユーザー(Userモデル)を紐付けるために、Boardモデルにuserフィールドを追加しました。models.ForeignKeyは、他のモデルとの「多対一」の関係を定義します。これにより、1人のユーザーが複数の投稿を作成できる関係が生まれます。

  • User: Djangoに標準で用意されているユーザー認証機能のユーザーモデルを指します。
  • on_delete=models.CASCADE: 関連付けられたユーザーが削除された場合、そのユーザーの投稿も一緒にデータベースから削除される設定です。
  • related_name='boards': ユーザー側から投稿情報を逆参照する(取り出す)際に使う名前を定義します。例えば、あるユーザーオブジェクトからuser.boards.all()のようにして、そのユーザーが作成した全投稿を取得できます。
  • null=True: データベースのこのカラムにNULL(空の値)を許可する設定です。既存の投稿データにはユーザー情報がないため、一時的に許可しています。

変更点: 投稿保存時にログインユーザーを紐付け

/app/views.py
1         if form.is_valid():
2+            form.instance.user = request.user
3             form.save()
4             return redirect('index')

新規投稿が作成されるindexビューの中で、投稿データをデータベースに保存する直前の処理を追加しました。form.save()を実行する前にform.instance.user = request.userと記述することで、これから保存する投稿データ(form.instance)のuserフィールドに、現在ログインしているユーザーの情報(request.user)をセットしています。これにより、誰がその投稿を作成したのかを記録できます。

変更点: 投稿者本人か判定するカスタムデコレータの作成

/app/views.py
1+from functools import wraps
2+
3+def user_owns_board(view_func):
4+    @wraps(view_func)
5+    def wrapper(request, pk):
6+        board = get_object_or_404(Board, pk=pk)
7+        if board.user == request.user:
8+            return view_func(request, pk)
9+        else:
10+            return redirect('index')
11+    return wrapper

投稿の編集や削除といった操作を、投稿者本人だけが行えるようにアクセス制限をかけるための「カスタムデコレータ」を作成しました。デコレータとは、既存の関数に前処理や後処理を追加するための機能です。

user_owns_boardデコレータは、以下の処理を行います。

  1. pk(投稿ID)を元に、データベースから対象の投稿データを取得します。
  2. その投稿のuser(投稿者)と、現在リクエストを送っているrequest.user(ログインユーザー)が一致するかを判定します。
  3. 一致すれば、元のビュー関数(編集や削除の処理)を実行します。
  4. 一致しなければ、トップページにリダイレクトさせ、処理を中断します。

これにより、URLを直接入力して他人の投稿を編集・削除しようとするアクセスを防ぐことができます。

変更点: 編集・削除機能にアクセス制限を適用

/app/views.py
1 @login_required
2+@user_owns_board
3 def edit(request, pk):
4     board = Board.objects.get(pk=pk)
5     form = BoardForm(instance=board)
6     return render(request, 'edit.html', {'form': form, 'board': board})
7 
8 @login_required
9+@user_owns_board
10 def update(request, pk):
11     board = Board.objects.get(pk=pk)
12     if request.method == 'POST':
13     return render(request, 'edit.html', {'form': form, 'board': board})
14 
15 @login_required
16+@user_owns_board
17 def delete(request, pk):
18     board = get_object_or_404(Board, pk=pk)
19     if request.method == 'POST':

先ほど作成したカスタムデコレータ@user_owns_boardを、投稿の編集画面表示(edit)、更新処理(update)、削除処理(delete)の各ビュー関数に適用しました。デコレータは@記号を使って関数の直前に記述するだけで、その関数が実行される前にデコレータ内の処理が自動的に実行されるようになります。これにより、これらの機能は投稿者本人しか利用できなくなりました。

変更点: 自分の投稿一覧ページのビュー作成

/app/views.py
1+@login_required
2+def my_boards(request):
3+    user = request.user
4+    boards = user.boards.all()
5+    return render(request, 'my_boards.html', {'boards': boards})

ログインしているユーザー自身の投稿だけを一覧で表示するための新しいビューmy_boardsを作成しました。

  1. @login_requiredデコレータを使い、このページはログインが必須であることを示します。
  2. user = request.userで、現在ログインしているユーザーの情報を取得します。
  3. boards = user.boards.all()で、そのユーザーに関連付けられた投稿(Board)をすべて取得します。これは、models.pyで設定したrelated_name='boards'があることで可能になります。
  4. 取得した投稿リストをmy_boards.htmlというテンプレートに渡し、画面に表示します。

変更点: 自分の投稿一覧ページへのURL設定

/app/urls.py
1     path('<int:pk>/edit/', login_required(views.edit), name='edit'),
2     path('<int:pk>/update/', login_required(views.update), name='update'),
3     path('<int:pk>/delete/', login_required(views.delete), name='delete'),
4+    path('my_boards/', login_required(views.my_boards), name='my_boards'),
5 ]

新しく作成したmy_boardsビューにアクセスできるように、URLを設定しました。path('my_boards/', ...)を追加することで、/my_boards/というURLにアクセスがあった場合にviews.my_boardsが呼び出されるようになります。name='my_boards'は、テンプレートファイルからこのURLを簡単に呼び出すための名前です。

変更点: トップページに「自分の投稿一覧」へのリンクを追加

/templates/index.html
1             </tbody>
2         </table>
3         <a href="{% url 'new' %}" class="btn btn-primary">新規投稿作成</a>
4+        <a href="{% url 'my_boards' %}" class="btn btn-primary">自分の投稿一覧</a>
5     </section>
6 {% endblock %}

トップページ(index.html)に、「自分の投稿一覧」ページへ移動するためのリンクを追加しました。{% url 'my_boards' %}というテンプレートタグを使うことで、urls.pyname='my_boards'と名付けたURLパスを自動的に生成してくれます。これにより、URLの変更に強い柔軟なリンクを作成できます。

変更点: 自分の投稿一覧ページのテンプレート作成

/templates/my_boards.html
1+{% extends 'base.html' %}
2+
3+{% block title %}自分の投稿一覧{% endblock %}
4+
5+{% block content %}
6+    <section>
7+        <h2>自分の投稿一覧</h2>
8+        <table class="table">
9+            <thead>
10+                <tr>
11+                    <th scope="col">掲示板ID</th>
12+                    <th scope="col">タイトル</th>
13+                    <th scope="col">投稿日時</th>
14+                    <th scope="col">更新日時</th>
15+                </tr>
16+            </thead>
17+            <tbody>
18+                {% for board in boards %}
19+                    <tr>
20+                        <td scope="row">{{ board.id }}</td>
21+                        <td><a href="{% url 'show' pk=board.id %}">{{ board.title }}</a></td>
22+                        <td>{{ board.created_at }}</td>
23+                        <td>{{ board.updated_at }}</td>
24+                    </tr>
25+                {% endfor %}
26+            </tbody>
27+        </table>
28+        <a href="{% url 'new' %}" class="btn btn-primary">新規投稿作成</a>
29+        <a href="{% url 'index' %}" class="btn btn-primary">みんなの投稿一覧</a>
30+    </section>
31+{% endblock %}

自分の投稿だけを表示するための新しいテンプレートファイルmy_boards.htmlを作成しました。このテンプレートは、my_boardsビューから渡された投稿リストboardsを受け取り、{% for %}ループを使って1件ずつテーブル形式で表示します。基本的な構造はindex.htmlと同じですが、表示されるデータが自分の投稿のみに限定されている点が異なります。

変更点: 投稿詳細ページで投稿者のみ編集・削除ボタンを表示

/templates/show.html
1         <a href="{% url 'index' %}" class="btn btn-primary">一覧に戻る</a>
2-        <a href="{% url 'edit' pk=board.id %}" class="btn btn-success">編集する</a>
3-        <form action="{% url 'delete' pk=board.pk %}" method="post" class="d-inline">
4-            {% csrf_token %}
5-            <input type="hidden" name="action" value="delete">
6-            <button type="submit" class="btn btn-danger">削除する</button>
7-        </form>
8+        {% if board.user == request.user %}
9+            <a href="{% url 'edit' pk=board.id %}" class="btn btn-success">編集する</a>
10+            <form action="{% url 'delete' pk=board.pk %}" method="post" class="d-inline">
11+                {% csrf_token %}
12+                <input type="hidden" name="action" value="delete">
13+                <button type="submit" class="btn btn-danger">削除する</button>
14+            </form>
15+        {% endif %}

投稿詳細ページ(show.html)で、編集ボタンと削除ボタンの表示を制御するロジックを追加しました。Djangoのテンプレートタグ{% if %}を使い、「表示している投稿の所有者(board.user)」と「現在ログインしているユーザー(request.user)」が同じであるかを判定しています。条件が真の場合(つまり、本人が自分の投稿を見ている場合)にのみボタンが表示され、他人の投稿を見ている場合にはボタンが表示されなくなります。これにより、ユーザーインターフェース上でも操作を制限できます。

おわりに

今回はForeignKeyを使って投稿とユーザーのモデルを関連付ける方法を学びました。モデルを関連付けたことで、request.userを利用して自分の投稿だけを一覧で表示できるようになりました。さらに、投稿者本人であるかを{% if %}で判定して編集・削除ボタンの表示を切り替える方法も実装しました。最後に、カスタムデコレータを作成し、URLを直接指定された場合でも他人の投稿を操作できないようにする、より安全なアクセス制限の方法についても解説しました。

関連コンテンツ