DjangoとChannelsを使って特定のメンバのみ参加可能なチャットルームを作成したいが、実現方法が分からない。
実装例を踏まえて教えて欲しい。
こんなお悩みを解決します。
DjangoとChannelsによるチャットルームのサンプルはいくつか実装例が出てきますが、ログイン済みかつURLを知っている人であれば誰でも参加できてしまう、という欠点があります。
今後、応用していくことを考え、今回は、チャットルーム作成時に参加者を割り当てる機能を追加することで、特定のメンバのみ参加可能なチャットルームを作成していきたいと思います。
アルゴリズム、環境構築の方法、初期設定などを順番に説明していくので、興味がある方はぜひ最後までご覧ください。
記事の構成
今回は、以下の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)に割り当てられているため。 |
具体例②のように、ホストが参加者として割り当てていたとしても、問題なく動作することを目指します。
今回、実施したいことはイメージできたでしょうか?以降では、具体的な実装内容を提示します。
環境構築と初期設定
まずは、メンバ限定機能のないチャット機能を作り込んでいきます。具体的な手順は以下のようになります。
- Pythonの仮想環境の作成
- 必要なライブラリのインストール
- Djangoプロジェクトの作成
- アカウントアプリとチャットアプリの作成
- Djangoプロジェクトの初期設定
- アカウントアプリの設定
- チャットアプリの設定
以降では、必要な機能をピックアップして解説していくため、実装結果の詳細は、以下の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.py
、chat/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
にアクセスすると以下のようになります。
一通りの操作を行い、正常に動作していれば成功です。
トップページ・ログイン後のページ
チャットルーム一覧・チャットルーム作成・チャットのやり取り
メンバ限定機能
ここまでで、チャットルームごとに部屋を作り、ルーム内のみでチャットができるようになりました。
一方でアクセス権の制御は行っていないため、リンクを知っているユーザがアクセスした場合も、チャットルームへのアクセスやチャットの送受信ができてしまいます。
以降では、事前に指定したユーザのみがアクセスでき、それ以外のユーザはアクセスできないように機能改善を行います。
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
の以下の機能も修正します。
- チャットルームの一覧作成機能
- 理由:ログインユーザが割り当てられていないチャットルームを非表示にするため。
- 該当するクラス:Index
- チャットルームへの入室機能
- 理由:ログインユーザが割り当てられていないチャットルームには、アクセスできないようにするため。
- 該当するクラス: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)
# 以下略
差分一覧は、以下を参照してください。
修正箇所の動作確認
以下のコマンドを実行後、サーバを立ち上げて動作確認します。
# 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のみが参加可能なチャットルームへはアクセスできないことが確認できました。
B視点での動作確認結果
Bからは、どちらのチャットルームにも参加可能で、Aが送信したメッセージも確認できました。
C視点での動作確認結果
Cからは、Aと同様に、BとCのみが参加可能なチャットルームのみが利用でき、AとBのみが参加可能なチャットルームへはアクセスできないことが確認できました。
上記から、おおむね問題なく動作していることが確認できました。
今回の実装結果は以下にまとめてあります。
https://github.com/yuruto-free/members-only-chat-room/tree/02_update_chat_app
まとめ
今回は、メンバ限定チャットルームの作成方法について解説しました。
世の中で多く公開されている事例に少し手を加えるだけで、より実用的なチャットに近づけることができました。
今回の事例を参考に、様々な機能を追加してみてください。