はじめに
この記事は NTTテクノクロス Advent Calendar 2020 の 15日目 の記事です。
こんにちは、NTTテクノクロスの森崎と申します。
この記事に記載の内容は個人的な取り組みの内容であり、所属する組織とは関係ありません。
普段は 部内のシステム管理であったり、C++のアプリケーション開発などをやっています。
先日、かねてより興味のあった Web アプリケーション開発について、Django を使ったプロジェクトに携わる機会があったので、その勉強と自分の中での定着のために、普段一緒に遊んでいる身内向けに簡単な Web アプリケーションを作って公開した話をします。
きっかけ
私が普段プレイしているとあるオンラインゲームでは、
1 週間ごとに新しいイベントが開催され、一定の期間が経つと終わっていきます。
これが同時多発的に毎週発生し、段々と何のイベントがいつまでなのかわからなくなってきます。
そこで、有志のギルドメンバーがイベントなどを Discord でまとめてくれていたのですが、
Discord では会話で流れたり、イベントの期間が表現しづらい、一覧で見れないなど運用に限界がありました。
イベントが一覧で見れて、内容がわかりやすいまとめがほしい!
そんな意見に答え、最近業務で Django に触れていたこともあって、勉強がてら なんでもいいから作りたい欲 に駆られてこれ幸いとばかりに作ってみることにしました。
作ったもの
メイン画面
せっかくなので某狩猟ゲームのクエストボード風にしてみました。
イベントは基本的に週単位の期間で開催されますが、
- 1 日一回やれば良いこと
- 期間内に行えば良いこと
- 期間内にずっと続くもの
などいくつか性質があります。
そこでイベントごとにラベルを張って分類が見えるようにしました。(この辺はメンバから注文が来た部分)
ついでにメニューバーにラベルと対応するボタンを作って、対応するラベルを持つイベントの表示・非表示を切り替えます。
イベントのモデルは以下のように定義しています。
複雑なリレーションのない超単純モデルです。
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 はとても楽にできて良いですね。
def event_view(request):
today = date.today()
events = Event.objects.filter(end_date__gte=today)
for e in events:
# 最後の編集から7日以内のものは 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)
追加画面
追加はこんな画面にしてみました。
表示したときの見栄えを良くするために、 Marked.js で textarea に書いた内容を markdown として読み込めるようにして
右側にリアルタイムでプレビューを表示しています。
基本的には form を作って、ボタンを押すと内容を Post するだけです。
困ったのがモデルで定義した period = models.IntegerField(choices=PERIOD_CHOICE)
のラベル部分をテンプレートに持ってくる部分。
調べてみると以下のように番号でアクセスする仕組みになっているようでした。(なんかもっといい方法がある気がする)
<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 %}
また、FullCalendar を使って、期間を見やすいようにしました。
色合いは試行錯誤中です。帯の色が黄緑で見づらいとか、とりあえずわかればいいやで付けた今日の色が虫歯みたいに見えるのでこれから調整したいですね。
帯をクリックするとモーダルで詳細を表示します。
View 側はこんな感じです。データベース から取り出す条件指定の際に or
を使いたい場合は django.db.models
に用意されている Q
でクエリを作って
組み合わせるようにします。
今の所、色指定を直接書き込んでしまっているので、そのうちデータベースから読むように改造したいですね。
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 に渡す方法です。
色々とやり方はあるようですが今回は、テンプレートに以下のように記述し
{{ event_data|json_script:"event-data" }}
javascript 側で json として parse して取得することにしました。
const eventData = JSON.parse(document.getElementById("event-data").textContent);
これをカレンダーに読み込ませておしまい!
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 をデプロイ用に変更します。
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用のソケットファイルを作成します。
[Unit]
Description=gunicorn socket
[Socket]
ListenStream=/run/gunicorn.sock
[Install]
WantedBy=sockets.target
gunicorn をサービス化して実行します。
先ほど作ったソケットファイルに対してバインドします。
今回は、pipenv 環境のもとで開発しているので、gunicorn は pipenv の環境下で動かすようにしています。
[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にします)
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 のセクションに追記します。
include /etc/nginx/sites-enabled/*;
設定が終わったら設定ファイルのチェックを行います。
$ sudo nginx -t
起動し、無事にサイトが表示されれば完了です!
$ sudo systemctl start nginx
終わりに
Django に関してはまだまだ入門に触れたばっかりのひよっこですが、
一緒にああだこうだ言いながら、身内向けですがなんとか Web アプリを公開できたのはよい達成感となりました。
また、技術的な部分とは少しずれてしまうのですが、今回 markdown を知らないメンバもいる中、
markdown が書けるメンバと協力して紹介し、引き込み、ちょっと強引に 導入できたのは結果的に良かったかなと思います。
箇条書きや太字など、discord の画面共有を有効活用しながら実演して、きれいに表示できることを見せながら
メンバに布教し、実際に使ってもらえるようになったのは良い経験となりました。
後々は ここから Discord に通知を設定したりとか、便利な計算機とか、色々挑戦していきたいですね。
NTTテクノクロス Advent Calendar 2020、明日は@TACK_TXさんです。お楽しみに!