Djangoでゲーム内イベントを仲間内で共有するシステムを作った話

# はじめに

この記事は [NTTテクノクロス Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ntt-tx) の 15日目 の記事です。

こんにちは、[NTTテクノクロス](https://www.ntt-tx.co.jp/)の森崎と申します。
この記事に記載の内容は個人的な取り組みの内容であり、所属する組織とは関係ありません。

普段は 部内のシステム管理であったり、C++のアプリケーション開発などをやっています。
先日、かねてより興味のあった Web アプリケーション開発について、[Django](https://docs.djangoproject.com/ja/3.1/) を使ったプロジェクトに携わる機会があったので、その勉強と自分の中での定着のために、普段一緒に遊んでいる身内向けに簡単な Web アプリケーションを作って公開した話をします。


# きっかけ

私が普段プレイしているとあるオンラインゲームでは、
1 週間ごとに新しいイベントが開催され、一定の期間が経つと終わっていきます。
これが同時多発的に毎週発生し、段々と何のイベントがいつまでなのかわからなくなってきます。

そこで、有志のギルドメンバーがイベントなどを Discord でまとめてくれていたのですが、

![old.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/905815/1bb6cbcb-262e-c33f-12ae-c24e66926bd3.png)

Discord では会話で流れたり、イベントの期間が表現しづらい、一覧で見れないなど運用に限界がありました。

**イベントが一覧で見れて、内容がわかりやすいまとめがほしい！**

そんな意見に答え、最近業務で Django に触れていたこともあって、勉強がてら **なんでもいいから作りたい欲** に駆られて~~これ幸いとばかりに~~作ってみることにしました。

# 作ったもの

## メイン画面

![main.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/905815/3d182db7-8d71-33ed-c314-5c180ca6f027.png)

せっかくなので某狩猟ゲームのクエストボード風にしてみました。
イベントは基本的に週単位の期間で開催されますが、

- 1 日一回やれば良いこと
- 期間内に行えば良いこと
- 期間内にずっと続くもの

などいくつか性質があります。
そこでイベントごとにラベルを張って分類が見えるようにしました。（この辺はメンバから注文が来た部分）
ついでにメニューバーにラベルと対応するボタンを作って、対応するラベルを持つイベントの表示・非表示を切り替えます。

イベントのモデルは以下のように定義しています。
複雑なリレーションのない超単純モデルです。

```py
class Event(models.Model):
    PERIOD_CHOICE = (
        (1, 'Daily'),
        (2, 'Temporarily'),
        (3, 'Forever'),
        (4, 'Guild'),
        (5, 'Buff')
    )
    title = models.CharField(max_length=200)
    start_date = models.DateField()                         # イベント開始日
    end_date = models.DateField()                           # イベント終了日
    last_update = models.DateField(auto_now=True)
    content = models.TextField()
    period = models.IntegerField(choices=PERIOD_CHOICE)     # イベント内容
    url = models.URLField(blank = True, null = True )       # Link用
    def __str__(self):
        return self.title
```

これを終了日が過ぎていないものを filter で取ってきて、テンプレートに渡すだけです。
データべース からの取り出しからフロントに渡すまでが Django はとても楽にできて良いですね。

```py
def event_view(request):
    today = date.today()
    events = Event.objects.filter(end_date__gte=today)
    for e in events:
        # 最後の編集から７日以内のものは New をつけるフラグ
        if today < e.last_update + timedelta(days=7):
            e.is_new = True
        else:
            e.is_new = False
    context = {
        'events': events,
    }
    return render(request, 'EventViewApp/event_view.html', context)
```

## 追加画面

![add.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/905815/a3a2a9f8-471e-ab96-ff3c-47e3b43556b4.png)

追加はこんな画面にしてみました。
表示したときの見栄えを良くするために、 [Marked.js](https://github.com/chjj/marked) で textarea に書いた内容を markdown として読み込めるようにして
右側にリアルタイムでプレビューを表示しています。

基本的には form を作って、ボタンを押すと内容を Post するだけです。

困ったのがモデルで定義した `period = models.IntegerField(choices=PERIOD_CHOICE) ` のラベル部分をテンプレートに持ってくる部分。
調べてみると以下のように番号でアクセスする仕組みになっているようでした。（なんかもっといい方法がある気がする）

```html
<label for="id_period">期間</label>
{% for period in periods %}
<div class="custom-control custom-radio">
  <input type="radio" class="custom-control-input" name="period" value="{{
  period.0 }}" id="id_{{ period.1 }}" {% if event.period == period.0 %} checked
  {% endif %} />
  <label class="custom-control-label" for="id_{{ period.1 }}">
    {{ period.1 }}
  </label>
</div>
{% endfor %}
```

![calendar.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/905815/b9c7a808-65e6-56cf-63ba-3f90e0a69cbf.png)

また、[FullCalendar](https://fullcalendar.io/) を使って、期間を見やすいようにしました。
色合いは試行錯誤中です。~~帯の色が黄緑で見づらいとか、とりあえずわかればいいやで付けた今日の色が虫歯みたいに見えるので~~これから調整したいですね。
帯をクリックするとモーダルで詳細を表示します。

![modal.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/905815/02e432e6-cf52-0a41-69df-d5c85fbbdbaa.png)

View 側はこんな感じです。データベース から取り出す条件指定の際に `or` を使いたい場合は `django.db.models` に用意されている `Q` でクエリを作って
組み合わせるようにします。
今の所、色指定を直接書き込んでしまっているので、そのうちデータベースから読むように改造したいですね。

```py
def event_calendar(request):
    colors = {
        1: '#3399ff',
        2: '#ff5252',
        3: '#d0e226',
        4: '#18aa1d',
        5: '#138496'
    }
    today = date.today()
    end_date = get_first_date(today)-timedelta(days=7)
    start_date = get_last_date(today)+timedelta(days=7)
    events = [ {
            'title': event.title,
            'id' : event.id,
            'start': event.start_date.strftime("%Y-%m-%d"),
            'end':event.end_date.strftime("%Y-%m-%d"),
            'color': colors.get(event.period),
            }
        for event in  Event.objects.exclude(Q(end_date__lte=end_date) | Q(start_date__gte=start_date))
        ]
    context = {
        'event_data': events
    }
    return render(request, 'EventViewApp/calendar.html', context)
```

さて、ここで困ったのが View.py で持っているデータを Javascript に渡す方法です。
色々とやり方はあるようですが今回は、テンプレートに以下のように記述し

```html
{{ event_data|json_script:"event-data" }}
```

javascript 側で json として parse して取得することにしました。

```js
const eventData = JSON.parse(document.getElementById("event-data").textContent);
```

これをカレンダーに読み込ませておしまい！

```js
const calendar = new FullCalendar.Calendar(calendarEl, {
  headerToolbar: {
    left: "prev,next today",
    center: "title",
    right: "dayGridMonth,timeGridWeek,timeGridDay",
  },
  initialDate: y + "-" + m + "-" + d,
  navLinks: true, // can click day/week names to navigate views
  selectable: true,
  selectMirror: false,
  select: function (arg) {
    var title = prompt("Event Title:");
    if (title) {
      calendar.addEvent({
        title: title,
        start: arg.start,
        end: arg.end,
        allDay: arg.allDay,
      });
    }
    calendar.unselect();
  },
  eventClick: async function (arg) {
    await updateModal(arg.event.id);
    $("#calendarModal").modal("show");
  },
  editable: false,
  dayMaxEvents: false, // allow "more" link when too many events
  events: eventData,
});

calendar.render();
```



# 公開

使い慣れている 某 VPS を借りて、公開することにしました。
今回は django を gunicorn で動かし、nginx から ソケット経由でアクセスします。

## setting.py の変更

settings.py をデプロイ用に変更します。

```settings.py
DEBUG=False
ALLOWED_HOSTS = ['*']
STATIC_URL = '/static/'
STATIC_ROOT = '/usr/share/nginx/html/static'
MEDIA_URL = '/media/'
MEDIA_ROOT = '/usr/share/nginx/html/media'
```

DEBUG=False で起動する時、 `ALLOWED_HOSTS` を指定しないとエラーとなります。
本来は FQDN など設定しますが、とりあえず `*` ですべて通します。

## nginx で読み込む static ファイルを配置

nginx で読みこむ静的ファイルを置く場所を作ります。
settings.py で指定した `STATIC_ROOT` と `MEDIA_ROOT` の場所です。

```
$ mkdir /usr/share/nginx/html/static
$ mkdir /usr/share/nginx/html/media
```

CSS や JS など、static なファイルたちを Django は一撃で然るべき場所にコピーしてくれます。
コピー元は settings.py で指定した `STATIC_URL` です。
~~こんなに楽なのに、よく更新時にこれを叩くのを忘れ、CSS が正しく置かれないので見た目が崩壊して怒られます。~~

```
$ python manage.py collectstatic
```

## gunicornで実行する

gunicorn用のソケットファイルを作成します。

```/etc/systemd/system/gunicorn.socket
[Unit]
Description=gunicorn socket

[Socket]
ListenStream=/run/gunicorn.sock

[Install]
WantedBy=sockets.target
```

gunicorn をサービス化して実行します。
先ほど作ったソケットファイルに対してバインドします。
今回は、pipenv 環境のもとで開発しているので、gunicorn は pipenv の環境下で動かすようにしています。

```/etc/systemd/system/gunicorn.service
[Unit]
Description=gunicorn daemon
Requires=gunicorn.socket
After=network.target

[Service]
PIDFile=/run/gunicorn/pid
User=root
WorkingDirectory=Djangoアプリケーションの場所
ExecStart=/usr/local/bin/pipenv run gunicorn --access-logfile - --workers 3 --bind unix:/run/gunicorn.sock event_view.wsgi:application

[Install]
WantedBy=multi-user.target
```

サービスを実行します。

```
systemctl start gunicorn.socket
```

## nginx の設定調整

sites-available ディレクトリを作成します。

```
mkdir /etc/nginx/sites-available
```

作ったディレクトリを sites-enabled としてシンボリックリンクを張ります。

```
sudo ln -s /etc/nginx/sites-available/ /etc/nginx/sites-enabled
```

http で動かすように設定。
(突貫なのでそのうちhttpsにします)


```/etc/nginx/sites-available/project.cfg
server {
   listen 80;
   server_name サーバのドメイン名かIP;

   location = /favicon.ico { access_log off; log_not_found off; }

   location /static {
       alias /usr/share/nginx/html/static;
   }
   location /media {
       alias /usr/share/nginx/html/media;
   }


   location / {
       proxy_pass http://unix:/run/gunicorn.sock;
   }
}
```

`nginx.conf` を変更して 今作った設定を include します。
下を http のセクションに追記します。

```/etc/nginx/nginx.conf
  include /etc/nginx/sites-enabled/*;
```

設定が終わったら設定ファイルのチェックを行います。

```
$ sudo nginx -t
```

起動し、無事にサイトが表示されれば完了です！

```
$ sudo systemctl start nginx
```

# 終わりに

Django に関してはまだまだ入門に触れたばっかりのひよっこですが、
一緒にああだこうだ言いながら、身内向けですがなんとか Web アプリを公開できたのはよい達成感となりました。

また、技術的な部分とは少しずれてしまうのですが、今回 markdown を知らないメンバもいる中、
markdown が書けるメンバと協力して紹介し、引き込み、~~ちょっと強引に~~ 導入できたのは結果的に良かったかなと思います。
箇条書きや太字など、discord の画面共有を有効活用しながら実演して、きれいに表示できることを見せながら
メンバに布教し、実際に使ってもらえるようになったのは良い経験となりました。

後々は ここから Discord に通知を設定したりとか、便利な計算機とか、色々挑戦していきたいですね。

[NTTテクノクロス Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ntt-tx)、明日は@TACK_TXさんです。お楽しみに！