Django & JavaScriptで非同期通信のアプリを作る

PythonとDjangoを使って簡単なWebアプリケーションを作ってみます。
また非同期通信の練習として、以下の要件を満たしたものにします。

・バナーとテキストを用意して、バナーをクリックしたら画面のリロードが走らずにテキストと画像が書き換わるシンプルなプログラム

一般的なノベルゲームをイメージするとわかりやすいです。

最終的なディレクトリ構成は以下になります。
```
ajax_practice
├── myapp
│   ├── ...
├── db.sqlite3
├── manage.py
├── myproject
│   ├── ...
├── ajax
│   └── ...
├── static
│   └── css
│        └── style.css
│   └── images
│       ├── default_image.jpg
│       └── new_image.jpg
│   └── js
│        └── script.js
├── templates
│   └── index.html
└── requirements.txt
```

また、今回のシンプルなWebアプリケーションではデータベースを使用しないため、モデルは作成しません。


環境：MacOS

### ホームディレクトリに新しい練習用のディレクトリを作成
（別にルートディレクトリでも良いです）

```
% mkdir ajax_practice
% cd ajax_practice
```

## 仮想環境を作成
```
% python -m venv ajax
```
```ajax```はここでは仮想環境の名前で、任意の名前を指定できます。

## 仮想環境を有効化する
```
% source ajax/bin/activate
```
これで仮想環境が起動できました。

```(ajax) ***** $```のようになっていたらOKです。

## Djangoをインストールする

その前に、以下のコマンドで最新バージョンの ```pip``` がインストールされていることを確認します。
```pip```は```Django```のインストールに使うソフトウェアです。

```
% python -m pip install --upgrade pip
```

**requirementsファイルによってパッケージをインストールする**
requirementsファイルは ```pip install``` でインストールする依存関係の一覧が記載されているファイルです。

```ajax_practice/``` フォルダ内に ```requirements.txt``` というファイルを作成します。

```
% touch requirements.txt 
```

ディレクトリは以下になります。

```
ajax_practice
├── ajax
│   └── ...
└───requirements.txt
```

```requirements.txt``` ファイル中に以下のテキストを追加します：

```requirements.txt
Django~=3.2.10
```

そして、下記のコマンドを実行してDjangoをインストールします。

```
$ pip install -r requirements.txt
```

## Djangoプロジェクトの作成

```
$ django-admin startproject myproject .
```

myprojectはプロジェクトの名前で、任意の名前を指定できます。

コマンドの最後にピリオド ```.``` を入力しますが、このピリオドは現在の作業ディレクトリに Django をインストールするということを示しています。

```django-admin.py``` は、必要なディレクトリとファイルを作成するスクリプトです。

## 設定
```myproject/settings.py```にいくつか変更を加えます。

```settings.py
LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'
```

静的ファイルのパスも追加する必要があります。
```STATIC_URL```の下に ```STATIC_ROOT```を追加します。

```settings.py
STATIC_URL = '/static/'
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')]
```

## 新しいアプリケーションの作成
```
% python manage.py startapp myapp
```
上記のコマンドはDjangoプロジェクト内でapp新しいアプリケーションを作成するためのコマンドです。


アプリケーションを作ったら、Djangoにそれを使うように伝える必要があるので、 ```mysite/settings.py``` で設定します。 

```INSTALLED_APPS``` を見つけて以下のように追加します。

```settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'myapp',  # 新しいアプリケーションを追加
]
```


## Djangoプロジェクトの設定:
まず、Djangoプロジェクトの```settings.py```ファイルを編集して、テンプレートのディレクトリを設定します。TEMPLATESセクションにあるDIRSの部分を以下のように編集します。

```settings.py
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        ...
    },
]
```

## テンプレートの作成
プロジェクトのルートディレクトリに```templates```というディレクトリを作成します。その中に```index.html```というファイルを作成します。

```templates/index.html
<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" type="text/css" href="/static/css/style.css">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
</head>
<body>
    <p id="text">かつて、遥か彼方の宇宙に、ひとつの美しい星がありました。</p>
    <img id="image" src="/static/images/default_image.jpg">
    <div class="banner" id="banner"></div>
    <script src="/static/js/script.js"></script>
</body>
</html>
```

同時にcssファイルも用意しておきます。
```static/css/style.css
/* style.css */
.banner {
  position: fixed;
  bottom: 0;
  width: 100%;
  height: 20%; /* 画面の下半分を占めるように設定 */
  background-color: rgba(0,0,0,0.8); /* 背景色をより濃い黒色に設定 */
  cursor: pointer; /* カーソルを指アイコンにする */
}

#text {
  position: fixed;
  bottom: 0; /* バナーと同じ位置に配置 */
  color: white; /* テキストの色を白に設定 */
  z-index: 2; /* テキストを前面に表示 */
}
```

## ビューの作成:
```myapp/views.py```ファイルに以下のようにビューを作成します。

※ストーリーはChatGPTに書かせました。

```myapp/views.py
from django.http import JsonResponse
from django.shortcuts import render

def change_text(request):
    if request.is_ajax():
        story = [
            "かつて、遥か彼方の宇宙に、ひとつの美しい星がありました。",
            "そこには、様々な生命が共存し、平和な時代が続いていました。",
            "しかし、ある日、暗黒の力が星を覆い始めました。",
            "暗黒の力は、星の生命たちを次々と闇へと変えていきました。",
            "星の生命たちが絶望する中、一筋の光が現れました。",
            "それは、星の守護神が遣わしたとされる、勇者の光でした。",
            "勇者は、星を救うため、一人で暗黒の力に立ち向かう決意をしました。",
            "長い戦いが続き、勇者は何度も傷つきながらも、決して諦めませんでした。",
            "そしてついに、勇者は暗黒の力を封じ、星に平和を取り戻しました。",
            "それ以降、星の生命たちは勇者を祝福し、平和な時代が再び訪れました。"
        ]
        new_image_url = "/static/images/new_image.jpg" if index == 5 else "/static/images/default_image.jpg"
        return JsonResponse({'story': story, 'new_image_url': new_image_url})

def index(request):
    return render(request, 'index.html')

```

## URLの設定:
```myproject/urls.py```ファイルにURLの設定を追加します。

```myproject/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('myapp.urls')),
]
```


```myapp/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('change_text/', views.change_text, name='change_text'),
]
```

## staticディレクトリの作成 & 静的ファイルの配置
静的ファイル (画像ファイルなど) は、プロジェクトのルートディレクトリにある```static```ディレクトリに配置します。

まず、Djangoプロジェクトのルートディレクトリに```static```という名前のディレクトリを作成します。

このディレクトリに ```default_image.jpg``` ファイル（デフォルトの画像）と ```new_image.jpg``` ファイル（変更先の画像）を配置します。

```
% mkdir static
% mkdir static/images
% touch static/images/default_image.jpg
% touch static/images/new_image.jpg
```

そして、それぞれの画像ファイルには何でも良いので、画像を用意しておきます。


## JavaScriptファイルの作成
さらに ```static``` ディレクトリの下に``` js ```ディレクトリを作成し、その中に``` script.js ```ファイルを作成します。

```
% mkdir static/js
% touch static/js/script.js
```

非同期でテキストを表示させるために Ajax を用います。

```script.js
$(document).ready(function() {
  let index = 0;
  let intervalId = null;
  let typingSpeed = 40;
  let isTyping = false;
  let story = [
    {text: "かつて、遥か彼方の宇宙に、ひとつの美しい星がありました。", image: "/static/images/default_image.jpg"},
    {text: "そこには、様々な生命が共存し、平和な時代が続いていました。", image: "/static/images/default_image.jpg"},
    {text: "しかし、ある日、暗黒の力が星を覆い始めました。", image: "/static/images/dark_image.jpg"},
    {text: "暗黒の力は、星の生命たちを次々と闇へと変えていきました。", image: "/static/images/dark_image.jpg"},
    {text: "星の生命たちが絶望する中、一筋の光が現れました。", image: "/static/images/light_image.jpg"},
    {text: "それは、星の守護神が遣わしたとされる、勇者の光でした。", image: "/static/images/light_image.jpg"},
    {text: "勇者は、星を救うため、一人で暗黒の力に立ち向かう決意をしました。", image: "/static/images/hero_image.jpg"},
    {text: "長い戦いが続き、勇者は何度も傷つきながらも、決して諦めませんでした。", image: "/static/images/hero_image.jpg"},
    {text: "そしてついに、勇者は暗黒の力を封じ、星に平和を取り戻しました。", image: "/static/images/peace_image.jpg"},
    {text: "それ以降、星の生命たちは勇者を祝福し、平和な時代が再び訪れました。", image: "/static/images/peace_image.jpg"}
  ];

  function typeWriter(text, callback) {
    let i = 0;
    isTyping = true;
    intervalId = setInterval(function() {
      $("#text").append(text.charAt(i));
      i++;
      if (i === text.length) {
        clearInterval(intervalId);
        isTyping = false;
        typingSpeed = 40;
        callback();
      }
    }, typingSpeed);
  }

  function loadStory() {
    if (index === story.length) {
      return;
    }
    clearInterval(intervalId);
    $("#text").empty();
    typeWriter(story[index].text, function() {});
    $("#image").attr('src', story[index].image);
    index++;
  }

  $("#banner").click(function() {
    console.log("Banner clicked!");
    if (isTyping) {
      console.log("Typing... speeding up.");
      clearInterval(intervalId);
      typingSpeed /= 4;
      typeWriter(story[index - 1].text.slice($("#text").text().length), function() {});
    } else {
      console.log("Loading next story...");
      loadStory();
    }
  });

  loadStory();
});
```

※このままだとエラーになるので、```settings.py```の冒頭に以下を足します。
```settings.py
import os  # 追加

from pathlib import Path
```


## Webサーバーを起動します

```
% python manage.py runserver
```

ブラウザで```http://127.0.0.1:8000/```を開きます。

これで、PythonとDjangoを使ってバナーをクリックした際にテキストや画像が非同期で書き換わるWebアプリケーションが完成しました！

![2b5d1e6273dec26b7a28f31e43bc6c1e.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1962714/05634436-97ff-7445-9332-760025e344c3.jpeg)