広告 プログラミング

【解説】メンバ限定チャットルームの作成【Django Channels】

※本ページには、プロモーション(広告)が含まれています。

悩んでいる人
悩んでいる人

DjangoとChannelsを使って特定のメンバのみ参加可能なチャットルームを作成したいが、実現方法が分からない。

実装例を踏まえて教えて欲しい。

こんなお悩みを解決します。

DjangoとChannelsによるチャットルームのサンプルはいくつか実装例が出てきますが、ログイン済みかつURLを知っている人であれば誰でも参加できてしまう、という欠点があります。

今後、応用していくことを考え、今回は、チャットルーム作成時に参加者を割り当てる機能を追加することで、特定のメンバのみ参加可能なチャットルームを作成していきたいと思います。

アルゴリズム、環境構築の方法、初期設定などを順番に説明していくので、興味がある方はぜひ最後までご覧ください。

記事の構成

今回は、以下の3ステップに分けて説明していきます。

  1. 具体的な振る舞い
  2. 環境構築と初期設定
  3. メンバ限定機能の実装

実装内容よりも作成後の画面を確認したい方は、以下のボタンから対象箇所までスキップしてください。

動作確認結果までスキップする

環境構築と初期設定部分は、他の多くのサイトで解説されているため、メンバ限定機能のみ知りたい方は、以下のボタンから対象箇所までスキップしてください。

メンバ限定機能の実装結果までスキップする

具体的な振る舞い

最初に出来上がりイメージを認識合わせしておいた方が良いため、今回作り上げるチャットルームの概要を解説したいと思います。

特定のメンバのみ参加可能なチャットルームを作成するには、チャットルームの作成者(= ホスト)が、部屋を作成する際に参加者を割り当てる、という手続きを行うことで実現できます。

以降で、具体例とともにイメージを掴んでいただきたいと思います。

具体例①

ここでの登場人物は、A・B・C・Dの4名を想定します。

また、以下に示すように、ホストはA、参加者はB・Cとします。

具体例①

上記の場合、チャットルームへの参加可否は、以下のようになります。

ユーザ参加可否判定理由
A参加可能ホスト(Host)に割り当てられているため。
B参加可能参加者(Participants)に割り当てられているため。
C参加可能参加者(Participants)に割り当てられているため。
D参加不可能ホスト(Host)・参加者(Participants)のいずれにも割り当てられていないため。
ユーザごとの参加可否一覧(具体例①)

具体例②

もう一つ例を示します。

先ほどと同様に、登場人物は、A・B・C・Dの4名を想定します。

また、以下に示すように、ホストはC、参加者はC・Dとします。

具体例②

上記の場合、チャットルームへの参加可否は、以下のようになります。

ユーザ参加可否判定理由
A参加不可能ホスト(Host)・参加者(Participants)のいずれにも割り当てられていないため。
B参加不可能ホスト(Host)・参加者(Participants)のいずれにも割り当てられていないため。
C参加可能参加者(Participants)に割り当てられているため。
D参加可能参加者(Participants)に割り当てられているため。
ユーザごとの参加可否一覧(具体例②)

具体例②のように、ホストが参加者として割り当てていたとしても、問題なく動作することを目指します。

今回、実施したいことはイメージできたでしょうか?以降では、具体的な実装内容を提示します。

環境構築と初期設定

まずは、メンバ限定機能のないチャット機能を作り込んでいきます。具体的な手順は以下のようになります。

  1. Pythonの仮想環境の作成
  2. 必要なライブラリのインストール
  3. Djangoプロジェクトの作成
  4. アカウントアプリとチャットアプリの作成
  5. Djangoプロジェクトの初期設定
  6. アカウントアプリの設定
  7. チャットアプリの設定

以降では、必要な機能をピックアップして解説していくため、実装結果の詳細は、以下のGitHubのリンクを参照してください。

https://github.com/yuruto-free/members-only-chat-room/tree/01_setup_environment

環境構築

ここでは、仮想環境の作成から各種アプリの作成までを対象に解説します。

仮想環境作成

以下のコマンドを実行し、仮想環境を作成します。

# pipコマンドのインストール
sudo apt install python3-pip
# pipenvコマンドのインストール
pip install pipenv
# PATHの追加
export PATH=$(echo ${HOME}/.local/bin):${PATH}
# 仮想環境の構築
pipenv --python $(echo $(python3 -V) | grep -oP "\d+\.\d+")

必要なライブラリのインストール

以下のコマンドを実行し、Django、Channelsをインストールします。

今回は簡易的に済ますためRedisサーバは利用しません。

# 必要なライブラリのインストール
pipenv install django channels daphne

今回、インストールされたライブラリは以下のようになりました。

pipenv graph
Loading .env environment variables...
channels==4.0.0
  - asgiref [required: >=3.5.0,<4, installed: 3.6.0]
  - Django [required: >=3.2, installed: 4.1.5]
    - asgiref [required: >=3.5.2,<4, installed: 3.6.0]
    - sqlparse [required: >=0.2.2, installed: 0.4.3]
daphne==4.0.0
  - asgiref [required: >=3.5.2,<4, installed: 3.6.0]
  - autobahn [required: >=22.4.2, installed: 22.12.1]
    - cryptography [required: >=3.4.6, installed: 39.0.0]
      - cffi [required: >=1.12, installed: 1.15.1]
        - pycparser [required: Any, installed: 2.21]
    - hyperlink [required: >=21.0.0, installed: 21.0.0]
      - idna [required: >=2.5, installed: 3.4]
    - setuptools [required: Any, installed: 65.6.3]
    - txaio [required: >=21.2.1, installed: 22.2.1]
  - twisted [required: >=22.4, installed: 22.10.0]
    - attrs [required: >=19.2.0, installed: 22.2.0]
    - Automat [required: >=0.8.0, installed: 22.10.0]
      - attrs [required: >=19.2.0, installed: 22.2.0]
      - six [required: Any, installed: 1.16.0]
    - constantly [required: >=15.1, installed: 15.1.0]
    - hyperlink [required: >=17.1.1, installed: 21.0.0]
      - idna [required: >=2.5, installed: 3.4]
    - incremental [required: >=21.3.0, installed: 22.10.0]
    - typing-extensions [required: >=3.6.5, installed: 4.4.0]
    - zope.interface [required: >=4.4.2, installed: 5.5.2]
      - setuptools [required: Any, installed: 65.6.3]
pyOpenSSL==23.0.0
  - cryptography [required: >=38.0.0,<40, installed: 39.0.0]
    - cffi [required: >=1.12, installed: 1.15.1]
      - pycparser [required: Any, installed: 2.21]
service-identity==21.1.0
  - attrs [required: >=19.1.0, installed: 22.2.0]
  - cryptography [required: Any, installed: 39.0.0]
    - cffi [required: >=1.12, installed: 1.15.1]
      - pycparser [required: Any, installed: 2.21]
  - pyasn1 [required: Any, installed: 0.4.8]
  - pyasn1-modules [required: Any, installed: 0.2.8]
    - pyasn1 [required: >=0.4.6,<0.5.0, installed: 0.4.8]
  - six [required: Any, installed: 1.16.0]

Djangoプロジェクトの作成

以下のコマンドを実行し、Djangoプロジェクトを作成します。

# 作業用ディレクトリの作成
mkdir src
cd src
# Djangoプロジェクトの作成
pipenv run django-admin startproject config .

アカウントアプリとチャットアプリの作成

以下のコマンドを実行し、アカウントアプリとチャットアプリを作成します。

# アカウントアプリの作成
pipenv run python3 manage.py startapp accounts
# チャットアプリの作成
pipenv run python3 manage.py startapp chat

初期設定

ここでは、Djangoプロジェクトの初期設定、各種アプリの設定を対象に解説します。

Djangoプロジェクトの初期設定

まず、config/settings.pyを以下のように変更します。

# =====
# 追加
# =====
import os
from pathlib import Path

# 中略...

# SECURITY WARNING: keep the secret key used in production secret!
# =====
# 環境変数を読み込むように変更
# =====
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

# =====
# リストの要素を「'*'」に変更
# =====
ALLOWED_HOSTS = ['*']

# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # =====
    # 追加
    # =====
    'django.contrib.humanize',
    # Third-party app
    'channels',
    # local apps
    'accounts',
    'chat',
]

# 中略...

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        # =====
        # リストの要素を「os.path.join(BASE_DIR, 'templates')」に変更
        # =====
        'DIRS': [os.path.join(BASE_DIR, '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',
            ],
            # =====
            # 追加
            # =====
            'libraries': {
                'pagination': 'custom_templatetags.pagination',
            },
        },
    },
]


WSGI_APPLICATION = 'config.wsgi.application'
# =====
# 追加
# =====
ASGI_APPLICATION = 'config.asgi.application'

# 中略...

# =====
# 変更
# =====
LANGUAGE_CODE = 'ja-jp'
TIME_ZONE = 'Asia/Tokyo'

# 中略...

# =====
# 追加
# =====
AUTH_USER_MODEL = 'accounts.User'
LOGIN_REDIRECT_URL = '/'

# channel layerの設定(今回は、Redisの代わりにInMemoryを利用する)
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels.layers.InMemoryChannelLayer',
    },
}

また、Pipfileがあるディレクトリに以下のような.envファイルを作成します。

ここで、SECRET_KEYは、コチラで生成できます。

DJANGO_SECRET_KEY=abcdefghijklmnopqrstuvwxyz0123456789
DJANGO_SUPERUSER_NAME=superuser
DJANGO_SUPERUSER_EMAIL=superuser@local.jp
DJANGO_SUPERUSER_PASSWORD=superuserpassword

次に、config/urls.pyを以下のように変更します。

from django.contrib import admin
from django.urls import path, include # 「include」を追加

urlpatterns = [
    path('admin/', admin.site.urls),
    # accountsアプリを追加
    path('', include('accounts.urls', namespace='accounts')),
    # chatアプリを追加
    path('chat/', include('chat.urls', namespace='chat')),
]

そして、configと同一ディレクトリにcustom_templatetagsというディレクトリを作成し、以下をcustom_templatetags/pagination.pyとして保存します。

from django import template
register = template.Library()

@register.simple_tag
def url_replace(request, field, value):
    url_dict = request.GET.copy()
    url_dict[field] = str(value)

    return url_dict.urlencode()

アカウントアプリの設定

Modelの定義、Viewの定義、URLの定義は、今回のメインではないため、GitHub上の該当ファイルを参照してください。

チャットアプリの設定

ここでは、Modelの定義、View・Formの定義、URLの定義を行います。

その後、チャットを行うための要となるWebsocket通信の設定とConsumerの実装例を示します。

Modelの定義(chat/models.py

チャットルームは、以下のカラムとメソッドを持つモデルとします。

区分名称説明
カラムhostチャットルームのホストとなるユーザ
カラムnameチャットルームの名称
カラムdescriptionチャットルームの説明
カラムcreated_atチャットルームの作成時刻
メソッドset_host引数で与えられたユーザをホストとして登録する。
メソッドis_host引数で与えられたユーザがホストかどうかを判定する。
True:ホストである
False:ホストではない
メソッド__str__チャットルームの名称を返却する。
チャットルームのモデル構成

また、チャットルーム内でやり取りされるメッセージは、以下のカラムを持つモデルとします。

区分名称説明
カラムroomメッセージと紐づくチャットルーム
カラムownerメッセージの所有者
カラムcontentメッセージの内容
カラムcreated_atメッセージの作成時刻
メソッド__str__メッセージの所有者とメッセージの内容(最大32文字)を返却する。
メッセージのモデル構成

これらの実装例を以下に示します。

from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy
import operator
from functools import reduce

User = get_user_model()

# チャットルームの定義
class RoomQueryset(models.QuerySet):
    def filtering(self, keywords='', order='-created_at'):
        # スペース区切りのキーワードのいずれかに該当するものを抽出
        condition = reduce(operator.or_, (models.Q(name__icontains=word) for word in keywords.split()))

        return self.filter(condition).order_by(order).distinct()

class Room(models.Model):
    host = models.ForeignKey(User, on_delete=models.CASCADE)
    name = models.CharField(gettext_lazy('Room name'), max_length=64)
    description = models.TextField(gettext_lazy('Description'), max_length=128)
    created_at = models.DateTimeField(gettext_lazy('Created time'), default=timezone.now)

    objects = RoomQueryset.as_manager()

    def __str__(self):
        return self.__unicode__()
    def __unicode__(self):
        return self.name

    def set_host(self, user=None):
        if user is not None:
            self.host = user

    def is_host(self, user=None):
        return user is not None and self.host.pk == user.pk

# チャットメッセージの定義
class MessageManager(models.Manager):
    def ordering(self, order='created_at'):
        return self.get_queryset().order_by(order)

class Message(models.Model):
    room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='messages')
    owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='messages')
    content = models.TextField(gettext_lazy('Content'))
    created_at = models.DateTimeField(gettext_lazy('Created time'), default=timezone.now)

    objects = MessageManager()

    def __str__(self):
        return self.__unicode__()
    def __unicode__(self):
        name = str(self.owner)
        text = self.content[:32]

        return f'{name}:{text}'
View・Formの定義(chat/views.pychat/forms.py

チャットアプリでは、以下の5つの機能を提供するものとします。

  • チャットルームの一覧作成
  • チャットルームの作成
  • チャットルームの更新
  • チャットルームの削除
  • チャットルームへの入室

それぞれの機能に対するViewは以下のようになります。

また、チャットルームの更新と削除の権限は、ホストのみに与えられているものとします。

from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.urls import reverse_lazy
from . import models, forms

# チャットルームの一覧作成
class Index(LoginRequiredMixin, ListView):
    model = models.Room
    template_name = 'chat/index.html'
    context_object_name = 'rooms'
    pagenate_by = 10

    def get_queryset(self):
        queryset = super().get_queryset()
        form = forms.SearchForm(self.request.GET or None)
        keywords = form.get_keywords()

        return queryset.filtering(keywords=keywords)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # チャットルーム検索用のフォームを追加
        context['search_form'] = forms.SearchForm(self.request.GET or None)

        return context

# チャットルームの作成
class CreateRoom(LoginRequiredMixin, CreateView):
    model = models.Room
    template_name = 'chat/room_form.html'
    form_class = forms.RoomForm
    success_url = reverse_lazy('chat:index')

    def form_valid(self, form):
        form.instance.set_host(self.request.user)

        return super().form_valid(form)

class OnlyRoomHostMixin(UserPassesTestMixin):
    raise_exception = True

    def test_func(self):
        room = self.get_object()

        return room.is_host(self.request.user)

# チャットルームの更新
class UpdateRoom(LoginRequiredMixin, OnlyRoomHostMixin, UpdateView):
    model = models.Room
    template_name = 'chat/room_form.html'
    form_class = forms.RoomForm
    success_url = reverse_lazy('chat:index')

# チャットルームの削除
class DeleteRoom(LoginRequiredMixin, OnlyRoomHostMixin, DeleteView):
    model = models.Room
    success_url = reverse_lazy('chat:index')

    def get(self, request, *args, **kwargs):
        # ignore direct access
        return self.handle_no_permission()

# チャットルームへの入室
class EnterRoom(LoginRequiredMixin, DetailView):
    model = models.Room
    template_name = 'chat/chat_room.html'
    context_object_name = 'room'

また、対応するFormは以下のようになります。

from django import forms
from django.utils.translation import gettext_lazy
from . import models

User = models.User

# チャットルーム検索用のフォーム
class SearchForm(forms.Form):
    keywords = forms.CharField(
        label=gettext_lazy('keywords (split space)'),
        required=False,
        widget=forms.TextInput(attrs={
            'placeholder': gettext_lazy('Enter the room name.'),
            'class': 'form-control',
        }),
    )

    def get_keywords(self):
        init_keywords = ''
        keywords = init_keywords

        if self.is_valid():
            keywords = self.cleaned_data.get('keywords', init_keywords)

        return keywords

# チャットルーム作成/更新用のフォーム
class RoomForm(forms.ModelForm):
    class Meta:
        model = models.Room
        fields = ('name', 'description')
        widgets = {
            'name': forms.TextInput(attrs={
                'placeholder': gettext_lazy('Enter the room name.'),
                'class': 'form-control',
            }),
            'description': forms.Textarea(attrs={
                'rows': 5,
                'cols': 10,
                'style': 'resize: none',
                'placeholder': gettext_lazy('Enter the description.'),
                'class': 'form-control',
            }),
        }
URLの定義(chat/urls.py

今回は、以下のようなパターンを定義しました。

from django.urls import path
from . import views

app_name = 'chat'

urlpatterns = [
    # チャットルームの一覧作成
    path('', views.Index.as_view(), name='index'),
    # チャットルームの作成
    path('create/room', views.CreateRoom.as_view(), name='create_room'),
    # チャットルームの更新
    path('update/room/<int:pk>', views.UpdateRoom.as_view(), name='update_room'),
    # チャットルームの削除
    path('delete/room/<int:pk>', views.DeleteRoom.as_view(), name='delete_room'),
    # チャットルームへの入室
    path('enter/room/<int:pk>', views.EnterRoom.as_view(), name='enter_room'),
]

関連するテンプレートの詳細は、GitHubを参照してください。

ここでは、メインであるchat_room.htmlのみ示します。

{% extends 'base.html' %}
{% load humanize %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-12">
        <div class="row">
            <form action="" id="message-form" class="col-12">
                <textarea placeholder="Enter the message" id="msg" rows="5" class="form-control" style="resize: none;"></textarea>
            </form>
        </div>
        <div class="row mt-1">
            <div class="col-12">
                <button type="submit" class="btn btn-primary btn-block" form="message-form">Send (Ctrl + Enter)</button>
            </div>
        </div>
    </div>
</div>
<div class="row justify-content-center mt-3">
    <div class="col-12">
        <div id="chat-log">
            {% for message in room.messages.ordering %}
            <div class="card">
                <div class="card-body">
                    <div class="row">
                        <div class="col-12">
                            <span class="card-username">{{ message.owner|stringformat:"s" }}</span>
                            <time class="card-datetime" datetime="{{ message.created_at|date:'Y-m-d' }}">(created at {{ message.created_at|date:'Y-m-d H:i:s' }})</time>
                        </div>
                    </div>
                    <div class="row">
                        <div class="col-12 card-content">
                            {{ message.content|linebreaksbr }}
                        </div>
                    </div>
                </div>
            </div>
            {% empty %}
            {% endfor %}
        </div>
    </div>
</div>

<template id="card-template">
    <div class="card">
        <div class="card-body card-font">
            <div class="row">
                <div class="col-12">
                    <span class="card-username"></span>
                    <time class="card-datetime" datetime=""></time>
                </div>
            </div>
            <div class="row">
                <div class="col-12 card-content"></div>
            </div>
        </div>
    </div>
</template>
{% endblock %}

{% block bodyjs %}
<script>
(function() {
    let g_socket = undefined;

    // setup message
    const chatMessageInput = document.querySelector('#chat-message-input');
    const submitChatMessage = document.querySelector('#submit-chat-message');
    submitChatMessage.addEventListener('click', (event) => {
        const message = chatMessageInput.value.trim();

        if (g_socket && message && message.match(/\S/g)) {
            const data = {
                content: message,
            };
            g_socket.send(JSON.stringify(data));
            chatMessageInput.value = '';
        }
    });

    chatMessageInput.focus();
    chatMessageInput.addEventListener('keyup', (event) => {
        // Check Ctrl key and Enter key
        if (event.ctrlKey && (event.key === 'Enter')) {
            submitChatMessage.click();
        }
    });

    // initialization
    const init = () => {
        // create websocket
        const wsScheme = (window.location.protocol === 'https:' ? 'wss' : 'ws');
        const hostname = window.location.host;
        const roomID = '{{ room.pk }}';
        const url = `${wsScheme}://${hostname}/ws/chat/${roomID}`;
        g_socket = new WebSocket(url);

        // message received
        g_socket.onmessage = (event) => {
            const data = JSON.parse(event.data);
            const template = document.querySelector('#card-template');
            const node = template.content.cloneNode(true);
            const isSystem = (data.type !== 'user_message');
            const fontColor = isSystem ? 'red' : 'black';
            // setup target node
            node.querySelector('.card-font').style.color = fontColor;
            node.querySelector('.card-username').textContent = data.username;
            node.querySelector('.card-datetime').textContent = data.datetime;
            node.querySelector('.card-datetime').setAttribute('datetime', data.datetime);
            node.querySelector('.card-content').textContent = data.content;
            document.querySelector('#chat-log').appendChild(node);
        };
        g_socket.onerror = (event) => {
            console.log(event);
        };
        g_socket.onclose = (event) => {
            ;
        };
    };

    document.addEventListener('DOMContentLoaded', init);
}());
</script>
{% endblock %}
Websocket通信の設定とConsumerの実装例

まず、以下の内容でchat/routing.pyを作成します。

from django.urls import path
from . import consumers

websocket_urlpatterns = [
    path('ws/chat/<int:room_id>', consumers.ChatConsumer.as_asgi()),
]

次に、config/asgi.pyを以下のように修正し、websocket通信時に上記で設定したrouting.pyの内容を参照するように指定します。

"""
ASGI config for config project.

It exposes the ASGI callable as a module-level variable named ``application``.

For more information on this file, see
https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
"""

import os

from django.core.asgi import get_asgi_application

# =====
# 追加
# =====
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django_asgi_app = get_asgi_application()

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from chat import routing

# =====
# 変更
# =====
# プロトコルごとの分岐処理
application = ProtocolTypeRouter({
    # httpの場合:これまで通り処理
    'http': django_asgi_app,
    # websocketの場合:ALLOWED_HOSTSの検証、ユーザ認証が実施されていることを確認
    'websocket': AllowedHostsOriginValidator( 
        AuthMiddlewareStack(
            # 問題なければ、routing.pyの内容に基づいてルーティングを実施
            URLRouter(
                routing.websocket_urlpatterns
            )
        )
    )
})

最後に、以下の内容でchat/consumers.pyを作成します。

from channels.generic.websocket import AsyncJsonWebsocketConsumer
from channels.db import database_sync_to_async
from datetime import datetime
from . import models

# ベースとなるConsumerクラス
class _BaseConsumer(AsyncJsonWebsocketConsumer):
    def __init__(self, *args, **kwargs):
        self.prefix = kwargs.pop('prefix', 'base')
        self.room = None
        super().__init__(*args, **kwargs)

    # 現時刻を取得するメソッド
    def get_current_time(self):
        return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    # ユーザ識別用のキーを取得するメソッド
    def get_client_key(self, user):
        return f'user{user.pk}'
    # 接続時の後処理を行うメソッド
    async def post_accept(self, user):
        raise NotImplementedError
    # 切断時の前処理を行うメソッド
    async def pre_disconnect(self, user):
        raise NotImplementedError
    # 切断時の後処理を行うメソッド
    async def post_disconnect(self, user):
        raise NotImplementedError

    # 接続時の処理
    async def connect(self):
        try:
            user = self.scope['user']
            # DBからルーム情報を取得
            pk = int(self.scope['url_route']['kwargs']['room_id'])
            self.room = await database_sync_to_async(models.Room.objects.get)(pk=pk)
            self.group_name = f'{self.prefix}{pk}'

            # ==============================
            # 現時点では、無条件で受け入れる
            # ==============================
            await self.accept()
            await self.channel_layer.group_add(self.group_name, self.channel_name)
            # 接続時の後処理を実行
            await self.post_accept(user)

        except Exception as err:
            raise Exception(err)

    # 切断時の処理
    async def disconnect(self, close_code):
        user = self.scope['user']
        # 切断時の前処理を実行
        await self.pre_disconnect(user)
        await self.channel_layer.group_discard(self.group_name, self.channel_name)
        await self.close()
        # 切断時の後処理を実行
        await self.post_disconnect(user)

# global instance for chat
g_chat_clients = {}

class ChatConsumer(_BaseConsumer):
    def __init__(self, *args, **kwargs):
        kwargs['prefix'] = 'chat-room'
        super().__init__(*args, **kwargs)

    # 接続時の後処理
    async def post_accept(self, user):
        # Send message to group
        await self.channel_layer.group_send(
            self.group_name, {
                'type': 'send_system_message', # send_system_messageメソッドを呼び出す
                'is_connected': True,
                'username': str(user),
                'client_key': self.get_client_key(user),
            }
        )

    # 切断時の前処理
    async def pre_disconnect(self, user):
        # Send message to group
        await self.channel_layer.group_send(
            self.group_name, {
                'type': 'send_system_message', # send_system_messageメソッドを呼び出す
                'is_connected': False,
                'username': str(user),
                'client_key': self.get_client_key(user),
            }
        )
    # 切断時の後処理
    async def post_disconnect(self, user):
        target = g_chat_clients.get(self.group_name, None)

        # target is empty
        if target is not None and len(target) == 0:
            del g_chat_clients[self.group_name]



    # Send message by system on connection or disconnection
    async def send_system_message(self, event):
        try:
            room_name = str(self.room)
            is_connected = event['is_connected']
            username = event['username']
            client_key = event['client_key']
            current_time = self.get_current_time()
            target = g_chat_clients.get(self.group_name, {})

            if is_connected:
                target[client_key] = username
                message_type = 'connect'
                message = f'Join {username} to {room_name}'
            else:
                del target[client_key]
                message_type = 'disconnect'
                message = f'Leave {username} from {room_name}'

            g_chat_clients[self.group_name] = target

            # JSON形式でデータを送信
            await self.send_json(content={
                'type': message_type,
                'username': 'system',
                'datetime': current_time,
                'content': message,
                'members': g_chat_clients[self.group_name],
            })
        except Exception as err:
            raise Exception(err)

    # Receive message from WebSocket
    async def receive_json(self, content):
        try:
            user = self.scope['user']
            message = content['content']
            await self.create_message(user, message)
            await self.channel_layer.group_send(
                self.group_name, {
                    'type': 'send_chat_message', # send_chat_messageメソッドを呼び出す
                    'msg_type': 'user_message',
                    'username': str(user),
                    'message': message,
                }
            )
        except Exception as err:
            raise Exception(err)

    async def send_chat_message(self, event):
        try:
            msg_type = event['msg_type']
            username = event['username']
            message = event['message']
            current_time = self.get_current_time()
            await self.send_json(content={
                'type': msg_type,
                'username': username,
                'datetime': current_time,
                'content': message,
            })
        except Exception as err:
            raise Exception(err)

    @database_sync_to_async
    def create_message(self, user, message):
        try:
            # チャットメッセージをDBに登録
            models.Message.objects.create(
                owner=user,
                room=self.room,
                content=message,
            )
        except Exception as err:
            raise Exception(err)

動作確認

上記の処理が一通り完了したら、一同動作確認をしておきます。

以下のコマンドを実行し、migrate後にスーパーユーザの作成とサーバの起動を行います。

# migrate
pipenv run python3 manage.py makemigrations
pipenv run python3 manage.py migrate
# スーパーユーザのアカウント作成
pipenv run python3 manage.py createsuperuser --noinput
# サーバの起動
pipenv run daphne -b 0.0.0.0 -p 8000 config.asgi:application

サーバを起動し、http://localhost:8000にアクセスすると以下のようになります。

一通りの操作を行い、正常に動作していれば成功です。

トップページ・ログイン後のページ

トップページ
ログインページ
ログイン後のページ

チャットルーム一覧・チャットルーム作成・チャットのやり取り

チャットルーム一覧(チャットルーム作成前)
チャットルーム作成
チャットルーム一覧(チャットルーム作成後)
チャットのやり取り(メッセージ送信前)
チャットのやり取り(メッセージ送信後)

メンバ限定機能

ここまでで、チャットルームごとに部屋を作り、ルーム内のみでチャットができるようになりました。

一方でアクセス権の制御は行っていないため、リンクを知っているユーザがアクセスした場合も、チャットルームへのアクセスやチャットの送受信ができてしまいます。

チャットルーム一覧(fooユーザの画面)
チャットルーム(fooユーザの画面)

以降では、事前に指定したユーザのみがアクセスでき、それ以外のユーザはアクセスできないように機能改善を行います。

Modelの修正

特定のメンバのみ参加可能なチャットルームを作成するために、chat/models.pyに以下の3点を追加します。

  • 参加可能なユーザを保持するカラム(ManyToManyフィールドとして実装)
  • 参加可否を判断するメソッド
  • ログイン時に参加可能なチャットルームのみを返却する機能(Managerに追加)

実装結果は以下のようになります。

class RoomQueryset(models.QuerySet):
    # 修正部分:フィルタリング処理を追加
    def _related_user(self, user=None):
        try:
            queryset = self.filter(models.Q(host=user) | models.Q(participants__in=[user.pk]))
        except:
            queryset = self

        return queryset

    def filtering(self, user=None, keywords='', order='-created_at'):
        words = keywords.split()
        # 修正部分:参加可能なチャットルームのみをフィルタリングする処理を追加
        queryset = self._related_user(user=user)

        if words:
            condition = reduce(operator.or_, (models.Q(name__icontains=word) for word in words))
            queryset = queryset.filter(condition)

        return queryset.order_by(order).distinct()

class Room(models.Model):
    host = models.ForeignKey(User, on_delete=models.CASCADE)
    name = models.CharField(gettext_lazy('Room name'), max_length=64)
    description = models.TextField(gettext_lazy('Description'), max_length=128)
    # 修正部分:participantsを追加
    participants = models.ManyToManyField(User, related_name='rooms', verbose_name=gettext_lazy('Participants'), blank=True)
    created_at = models.DateTimeField(gettext_lazy('Created time'), default=timezone.now)

    objects = RoomQueryset.as_manager()

    def __str__(self):
        return self.__unicode__()
    def __unicode__(self):
        return self.name

    def set_host(self, user=None):
        if user is not None:
            self.host = user

    def is_host(self, user=None):
        return user is not None and self.host.pk == user.pk

    # 修正部分:is_assignedメソッドを追加
    def is_assigned(self, user=None):
        try:
            _ = self.participants.all().get(pk=user.pk)
            return True
        except User.DoesNotExist:
            return self.host == user
        except Exception:
            return False

Formの修正

Modelの修正に伴い、chat/forms.pyのRoomFormも修正します。

class RoomForm(forms.ModelForm):
    class Meta:
        model = models.Room
        # 修正部分:participantsを追加
        fields = ('name', 'description', 'participants')
        widgets = {
            'name': forms.TextInput(attrs={
                'placeholder': gettext_lazy('Enter the room name.'),
                'class': 'form-control',
            }),
            'description': forms.Textarea(attrs={
                'rows': 5,
                'cols': 10,
                'style': 'resize: none',
                'placeholder': gettext_lazy('Enter the description.'),
                'class': 'form-control',
            }),
            # 修正部分:participantsを追加、複数選択を可能とするためにSelectMultipleを指定
            'participants': forms.SelectMultiple(attrs={
                'class': 'form-control',
            }),
        }


    # 修正部分:participantsの候補を定義
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['participants'].queryset = User.objects.filter(is_staff=False)

Viewの修正

Modelの修正に伴い、chat/views.pyの以下の機能も修正します。

  1. チャットルームの一覧作成機能
    • 理由:ログインユーザが割り当てられていないチャットルームを非表示にするため。
    • 該当するクラス:Index
  2. チャットルームへの入室機能
    • 理由:ログインユーザが割り当てられていないチャットルームには、アクセスできないようにするため。
    • 該当するクラス:EnterRoom

2つ目は、URLを直接指定してチャットルームへアクセスした場合もアクセス制限をかけるために実装します。

それぞれ、以下のようになります。

class Index(LoginRequiredMixin, ListView):
    model = models.Room
    template_name = 'chat/index.html'
    context_object_name = 'rooms'
    pagenate_by = 10

    def get_queryset(self):
        queryset = super().get_queryset()
        form = forms.SearchForm(self.request.GET or None)
        keywords = form.get_keywords()

        # 修正部分:引数にuser=self.request.userを追加
        return queryset.filtering(user=self.request.user, keywords=keywords)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['search_form'] = forms.SearchForm(self.request.GET or None)

        return context

# 中略...

# 修正部分:チャットルームへの参加可否を判断するMixinを追加
class OnlyAssignedUserMixin(UserPassesTestMixin):
    raise_exception = True

    def test_func(self):
        room = self.get_object()

        return room.is_assigned(self.request.user)

# 修正部分:OnlyAssignedUserMixinを継承対象として追加
class EnterRoom(LoginRequiredMixin, OnlyAssignedUserMixin, DetailView):
    model = models.Room
    template_name = 'chat/chat_room.html'
    context_object_name = 'room'

Consumerの修正

上記の対応により、Webブラウザからアクセスする場合への対応は完了しました。

ただ、現時点ではWebsocket経由でチャットメッセージの送受信ができてしまうため、最後にConsumerにもアクセス制限機能を実装しておきます。

実装としては、connect時にis_assignedメソッドによるチェックを行う処理を追加するだけとなります。

chat/consumers.pyの修正結果は、以下のようになります。

class _BaseConsumer(AsyncJsonWebsocketConsumer):

    # 中略...

    async def connect(self):
        try:
            user = self.scope['user']
            pk = int(self.scope['url_route']['kwargs']['room_id'])
            self.room = await database_sync_to_async(models.Room.objects.get)(pk=pk)
            self.group_name = f'{self.prefix}{pk}'
            # 修正部分:is_assignedメソッドの実行結果による分岐処理を追加
            is_assigned = await database_sync_to_async(self.room.is_assigned)(user)

            # 参加可能な場合のみacceptする
            if is_assigned:
                await self.accept()
                await self.channel_layer.group_add(self.group_name, self.channel_name)
                await self.post_accept(user)

        except Exception as err:
            raise Exception(err)

    # 以下略

差分一覧は、以下を参照してください。

https://github.com/yuruto-free/members-only-chat-room/commit/44742ce33a11d92b0900e0909b3464a286b3603f

修正箇所の動作確認

以下のコマンドを実行後、サーバを立ち上げて動作確認します。

# migrate
pipenv run python3 manage.py makemigrations
pipenv run python3 manage.py migrate
# サーバの起動
pipenv run daphne -b 0.0.0.0 -p 8000 config.asgi:application

ここでは、以下の2パターンのチャットルームを作成し、動作確認をしました。

注意点として、作成するユーザは一般ユーザ(staff権限OFF)とする必要があります。

パターン①
パターン②

A視点での動作確認結果

Aからは、AとBのみが参加可能なチャットルームのみが利用でき、BとCのみが参加可能なチャットルームへはアクセスできないことが確認できました。

チャットルーム一覧(AとBのみが参加可能なチャットルームが表示されている)
チャットルームへの入室(Aからチャットが送信できている)
チャットルームへの入室(BとCのみが参加可能なチャットルームへは参加できない)

B視点での動作確認結果

Bからは、どちらのチャットルームにも参加可能で、Aが送信したメッセージも確認できました。

チャットルーム一覧(どちらのチャットルームも表示されている)
チャットルームへの入室(チャットの送受信ができている)
チャットルームへの入室(BとCのみが参加可能なチャットルームにも参加できている)

C視点での動作確認結果

Cからは、Aと同様に、BとCのみが参加可能なチャットルームのみが利用でき、AとBのみが参加可能なチャットルームへはアクセスできないことが確認できました。

チャットルーム一覧(BとCのみが参加可能なチャットルームが表示されている)
チャットルームへの入室(チャットの送受信ができている)
チャットルームへの入室(AとBのみが参加可能なチャットルームへは参加できない)

上記から、おおむね問題なく動作していることが確認できました。

今回の実装結果は以下にまとめてあります。

https://github.com/yuruto-free/members-only-chat-room/tree/02_update_chat_app

まとめ

今回は、メンバ限定チャットルームの作成方法について解説しました。

世の中で多く公開されている事例に少し手を加えるだけで、より実用的なチャットに近づけることができました。

今回の事例を参考に、様々な機能を追加してみてください。

スポンサードリンク

-プログラミング
-, ,