はじめに
この記事は「定期ミーティングの準備を簡単に」の続編(開発したプロダクトのアップデート)だが、改めて開発動機からプロジェクトについて書いていこうと思う。
開発の経緯
大学で行われる定期ミーティング。組織のメンバのグループ分けを手動で名札を並び替えて行っていた。しかし、毎週手動で行うのは負担が大きく、毎年度名札を作り増すのは更に負担だった。そこで、メンバが使い慣れているLINEを用いてグループ分けシステムを開発した。
設計の前に
上で書いたグループ分けの機能を実装するにあたって、欠席メンバの特定が必要なことに気が付いた。なぜなら、在籍メンバの集合から欠席メンバの集合を引いた差が出席予定のメンバの集合となるからである。そこで今回は、「グループ作成機能」と「欠席管理機能」を併せて実装することにした。
設計
技術スタック
技術スタック | 言語・フレームワーク | 補足 |
---|---|---|
バックエンド | Django | |
フロントエンド | LINE, tailwindcss | |
ミドルウェア | PostgreSQL | Supabase |
LINE MessagingAPI | ||
インフラ | Docker | DevContainer |
デプロイ | Render | 無料枠※1 |
※1 Renderの無料枠では15分操作がないとスピンダウンして、復帰に50秒かかる
↑後の節で対処
UX
LINEボットで、メッセージの完全一致を条件式としているので、リッチメニューを設けることで、簡単に・正確にメッセージを送信できる
アクセスサイト
開発者が関与しなくても、メンバの編集・削除や諸設定の更新が行えるように、管理者用のサイトを作成する
機能
メンバ登録・削除
該当のLINE公式アカウントに友達登録すると、ユーザのLINEIDをデータベースに登録する
その後は、フィールドの空き状況によって、順次データをセットする
if event_type == 'follow':
"""
友達追加されたときの処理
データベース上にLINEIDの重複がないか確かめてから
LINEIDをデータベースに追加する
データベースの欠損値によってデータを受け付けてデータベースに追加する
"""
try:
member = Member.objects.get(user_id=line_id)
except:
new_member = Member(user_id=line_id)
new_member.save()
member = new_member
欠席連絡
欠席するメンバは該当の週に、欠席連絡を行うことができる
この後、グループを作成する関係で、実際には欠席しないものの、司会進行など(ここでは議長と書記)も(形上)欠席連絡を行う必要がある。
そこで、司会進行役などに限りその旨を、クイックリプライを用いて1タッチで送信できるようにした。

出欠席管理
欠席連絡が行われているメンバをデータベースから取得し、名前とその理由を表示する
GAS(google app script)で開発した前バージョンでは、GoogleSpreadSheetにデータを保存していたため、メンバ情報の取得にとても時間がかかっていた。
⇒SQLを使用することで高速化につながった
GASで開発したバージョンでは、排他制御を行っていなかったため、ユーザの操作次第では、予期せぬバグになる恐れがあった。
⇒GoogleSpreadSheetで管理しないことで、対策

グループ作成
欠席連絡を行っていないメンバを、任意のグループ数にランダムに割り振る
作成したグループはPillow
を用いて画像にしてリプライする
人数の異なる3学年を、それぞれできるだけ均等に割り振りたい
⇒ ラウンドロビン方式で割り振る
グループを画像で提供することで、共有しやすい・見やすいという利点がある
データベースを経由してLINEに返送しているが、画像が更新されても画像のURLが同じであるがために、キャッシュが更新されない問題が発生した
⇒ urlにクエリパラメータとして、タイムスタンプを付与することで回避
def MakeGroups(_num_groups):
"""
メンバを均等にグループ分けする
:param members: 出席予定のメンバリスト
:param num_groups: 作成するグループ数
:return: グループ分けされた辞書
"""
system = System.objects.get(id=0)
gradeIndex = system.grade_index
gradeIndex_first = gradeIndex % 3 + 1
gradeIndex_second = (gradeIndex + 1) % 3 + 1
group1 = list(Member.objects.filter(absent_reason="", grade_class="GradeClass" + str(gradeIndex_first)))
group2 = list(Member.objects.filter(absent_reason="", grade_class="GradeClass" + str(gradeIndex_second)))
group3 = list(Member.objects.filter(absent_reason="", grade_class="GradeClass" + str(gradeIndex)))
random.shuffle(group1)
random.shuffle(group2)
random.shuffle(group3)
groups = [[] for _ in range(_num_groups)]
# ラウンドロビン方式で各グループにメンバを分配
idx = 0
for member in group1:
groups[idx % _num_groups].append(member)
idx += 1
for member in group2:
groups[idx % _num_groups].append(member)
idx += 1
for member in group3:
groups[idx % _num_groups].append(member)
idx += 1
return groups
num_member = Member.objects.filter(absent_reason="").count()
if message_text.isdigit() and int(message_text) <= num_member and int(message_text) != 0:
#入力値が出席可能なメンバ数以下の整数ならば
try:
num_groups = int(message_text)
groups = MakeGroups(num_groups)
updated_member = Member(user_id=member.user_id, name=member.name, grade_class=member.grade_class, absent_flag=0, groupsep_flag=0, absent_reason=member.absent_reason)
updated_member.save()
media_url = GenerateGroupImage(num_groups, groups)
reply_messages = [
{
"type": "text",
"text": "グループを作成しました"
},
{
"type": "image",
"originalContentUrl": media_url,
"previewImageUrl": media_url
}
]
except Exception as e:
reply_messages = [{"type": "text", "text": f"グループの作成に失敗しました\n{e}"}]
print(f"Error group making fail:{e}")
欠席連絡の定期削除
ミーティング後は、次のミーティングに備えて出欠席状況をリセットする必要がある
Djangoでの定期実行にはAPScheduler
を採用した
その他
委員長権限の譲渡や世代交代などの機能も実装済み