定期ミーティングの準備を快適に

# はじめに
この記事は「[定期ミーティングの準備を簡単に](https://qiita.com/Integral-07/items/49f4e71dd7aacd51b134)」の続編（開発したプロダクトのアップデート）だが、改めて開発動機からプロジェクトについて書いていこうと思う。

https://github.com/Integral-07/meetingHelper_ver2

# 開発の経緯
大学で行われる定期ミーティング。組織のメンバのグループ分けを手動で名札を並び替えて行っていた。しかし、毎週手動で行うのは負担が大きく、毎年度名札を作り増すのは更に負担だった。そこで、メンバが使い慣れているLINEを用いてグループ分けシステムを開発した。

# 設計の前に
上で書いたグループ分けの機能を実装するにあたって、欠席メンバの特定が必要なことに気が付いた。なぜなら、在籍メンバの集合から欠席メンバの集合を引いた差が出席予定のメンバの集合となるからである。そこで今回は、「グループ作成機能」と「欠席管理機能」を併せて実装することにした。

# 設計

## 技術スタック
|技術スタック  |言語・フレームワーク  |補足         |
|---          |---                 |---          |
|バックエンド  |Django              |             |
|フロントエンド|LINE, tailwindcss   |             |
|ミドルウェア  |PostgreSQL          |Supabase     |
|             |LINE MessagingAPI   |             |
|インフラ      |Docker              |DevContainer|
|デプロイ      |Render              |無料枠※1    |

※1 Renderの無料枠では15分操作がないとスピンダウンして、復帰に50秒かかる
　　↑後の節で対処

## UX
LINEボットで、メッセージの完全一致を条件式としているので、[リッチメニュー](https://developers.line.biz/ja/docs/messaging-api/rich-menus-overview/)を設けることで、簡単に・正確にメッセージを送信できる

## アクセスサイト
開発者が関与しなくても、メンバの編集・削除や諸設定の更新が行えるように、管理者用のサイトを作成する

# 機能
## メンバ登録・削除
該当のLINE公式アカウントに友達登録すると、ユーザのLINEIDをデータベースに登録する
その後は、フィールドの空き状況によって、順次データをセットする

```python:該当コード
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
```

## 欠席連絡
欠席するメンバは該当の週に、欠席連絡を行うことができる

:::note info
この後、グループを作成する関係で、実際には欠席しないものの、司会進行など（ここでは議長と書記）も（形上）欠席連絡を行う必要がある。
そこで、司会進行役などに限りその旨を、[クイックリプライ](https://developers.line.biz/ja/docs/messaging-api/using-quick-reply/)を用いて1タッチで送信できるようにした。
:::

<img img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2778684/ba73131a-33d6-4cdd-a307-be99f6595d04.jpeg">

## 出欠席管理
欠席連絡が行われているメンバをデータベースから取得し、名前とその理由を表示する

:::note alert
GAS(google app script)で開発した前バージョンでは、GoogleSpreadSheetにデータを保存していたため、メンバ情報の取得にとても時間がかかっていた。
⇒SQLを使用することで高速化につながった
:::

:::note alert
GASで開発したバージョンでは、排他制御を行っていなかったため、ユーザの操作次第では、予期せぬバグになる恐れがあった。
⇒GoogleSpreadSheetで管理しないことで、対策
:::

<img img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2778684/e5d8cad8-202c-4f1d-9d61-144ae7832d4f.jpeg">

## グループ作成
欠席連絡を行っていないメンバを、任意のグループ数にランダムに割り振る
作成したグループは`Pillow`を用いて画像にしてリプライする

:::note info
人数の異なる３学年を、それぞれできるだけ均等に割り振りたい
⇒ ラウンドロビン方式で割り振る
:::

:::note info
グループを画像で提供することで、共有しやすい・見やすいという利点がある
:::

:::note warn
データベースを経由してLINEに返送しているが、画像が更新されても画像のURLが同じであるがために、キャッシュが更新されない問題が発生した
⇒ urlにクエリパラメータとして、タイムスタンプを付与することで回避
:::
```python:MakeGroup関数
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

```
```python:グループ作成
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}")

```

## 欠席連絡の定期削除
ミーティング後は、次のミーティングに備えて出欠席状況をリセットする必要がある

:::note info
Djangoでの定期実行には`APScheduler`を採用した
:::


## その他
委員長権限の譲渡や世代交代などの機能も実装済み

#