📷

ReactとDjangoでフォームから画像の登録をしてみた

2024/09/15に公開

元美容師のDjangoポートフォリオリニューアル日記Part.6:画像アップロード機能実装編

こんにちは、やぎです!前回の記事では、サロン情報の編集と削除機能を実装しました。
今回はサロンデータに画像を登録し、サロン一覧画面で表示する機能を追加します。
実は画像の登録表示機能はサロン登録機能実装時に含めるはずだったのですが用件定義で完全に漏れていました。。。ER図も後ほど修正します。
写真を追加することによってサロンの雰囲気がパッとわかるので必須の機能ですよねw

それでは今回も頑張ります!実装開始!!

今回の実装内容

  1. Djangoモデルへ画像フィールドの追加
  2. Djangoのシリアライザー更新
  3. Reactでの画像アップロード機能の実装
  4. Reactでのサロン一覧での画像表示

※今回の実装では、まだ開発段階のためローカルファイルシステムを使用します。本番にデプロイする際はクラウドのファイルシステムに移行する予定です。

バックエンド側(Django)の更新

1.モデルの更新
まずはDjango側で画像を取り扱えるようにコードを追記します。
Salonモデルに画像フィールドを追加するため以下のコードを追記します。

from django.db import models

class Salon(models.Model):
    name = models.CharField(max_length=100)
    address = models.CharField(max_length=200)
    description = models.TextField()
    phone_number = models.CharField(max_length=20, blank=True)
    email = models.EmailField(blank=True)
    image = models.ImageField(upload_to='salon_images/', null=True, blank=True) #追記 imageフィールドを追加
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.name

このフィールドの追加により、各サロンデータに画像を保存できるようになりました。
画像の登録は一旦必須としないのでnull=True、blank=Trueとします。
null=Trueは分かると思いますが、blank=Trueはフォームのバリデーションで空文字でもOKとする設定です。

2.settings.pyの更新
続いてSettings.pyに画像を扱うための設定を追記します。

import os

#その他の設定

BASE_DIR = Path(__file__).resolve().parent.parent #追記

MEDIA_URL = '/media/' #追記
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') #追記

#その他の設定

settings.pyに書かれている内容ですが、職業訓練校では意味もわからず書いていましたw
教科書にこう書けと書いてあるので真似ていた感じです。
というのも、この部分ってpythonを学習する上でのコードとは毛色が違うんですよね。
私と同じ思いの方がいるかもということで、ここではそれぞれのコードの意味を少しだけ解説します。
動作だけ確認したい方や、そんなん知ってるよという方は読み飛ばしてください。

BASE_DIR: プロジェクトのルートディレクトリのパスを定義します。
MEDIA_URL: アップロードされた画像などにアクセスするためのURLの頭の部分を設定します。
MEDIA_ROOT: アップロードされたメディアファイルを保存するディレクトリのパスを定義します。

例えば、salon_images/example.jpg という画像がアップロードされた場合、以下のようになります:

物理的な保存場所: [プロジェクトルート]/media/salon_images/example.jpg
アクセスURL: http://ドメイン名/media/salon_images/example.jpg

Path(file).resolve().parent.parent
ここも意味わからないですよねw
少しずつみていきます!

file: このコードが書かれているファイル(この場合はsettings.py)のパスを指しています。

Path(file): __file__のパスをPathオブジェクトに変換しています。
Pathオブジェクトはパスを操作するためのメソッドです。

.resolve(): パスを絶対パスに置き換えています。

.parent: 現在のファイルの親ディレクトリを指します。

.parent.parent: 親ディレクトリの親、つまり祖父母ディレクトリを指します。
つまりプロジェクトのルートディレクトリのことです!
ルートディレクトリの場所を示す地図をBASE_DIRに格納しているイメージです。

os.path.join(BASE_DIR, 'media')
この関数は、複数のパス要素を結合して1つのパスを作成します。
つまりBASE_DIRに格納したルートディレクトリパスの後ろにmediaをガッチャンコしてくれます。

さらにos.path.join() は、osの際で生まれるパスの差を勝手に補完してくれます。例えば

Unixシステムでは: /home/user/projects/myproject/media
Windowsでは: C:\Users\user\projects\myproject\media
この違いを自動的にosに合わせていい具合に設定してくれるんです。

3.url.pyの更新
url.pyにも画像を扱うための設定を追記します。
ここで以下の追記を行うことでこのアプリで画像のurlを使用することができるようになります。

from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

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

if settings.DEBUG: #追記
  urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

ここでも追記した部分について少しだけ説明します。
if settings.DEBUG
これはsettings.pyでDEBUGをTureにしていればその下のコードをみてねという意味です。
今回は開発段階のためDEBUG = Trueとしていますね。

urlpatterns +=
これはurlpatternsに追加してねという意味です。

static()
これは django.conf.urls.static モジュールからインポートされる関数です。
メディアファイルのURLパターンを自動生成してくれます。

settings.MEDIA_URL
これは settings.py で定義した MEDIA_URL の値です。
つまり '/media/' です。

document_root=settings.MEDIA_ROOT
document_root は static() 関数のパラメータです。
settings.MEDIA_ROOT は settings.py で定義したメディアファイルの保存場所です。
これにより、Djangoは指定されたディレクトリからファイルを探してくれます。

全てを要約すると画像登録によって生成されたURLパターンをurlpatternsに格納します。
これにより、/media/ で始まるURLリクエストが来た時に、Djangoは MEDIA_ROOTディレクトリ内の対応するファイルを探して取り出してくれます。

注意点として、この設定は開発環境(DEBUG=True)での設定です。
本番環境では、専用のWebサーバーに任せるのが一般的です。

4.Pillowをインストールします

pip install Pillow

5.マイグレーションを実行します

python manage.py makemigrations
python manage.py migrate

6.シリアライザーの更新
次に、シリアライザーを更新して、画像フィールドを追加します。
serializers.pyに追記をします。

from rest_framework import serializers
from .models import Salon

class SalonSerializer(serializers.ModelSerializer):
    image = serializers.SerializerMethodField()

    class Meta:
        model = Salon
        fields = ['id', 'name', 'address', 'description', 'phone_number', 'email', 'image', 'created_at', 'updated_at'] #imageを追記

この変更により、APIレスポンスに画像のURLが含まれるようになりました。

以上でDjango側の設定は完了です。
以前の記事でも記載しましたが、views.pyで使用したgenerics.RetrieveUpdateDestroyAPIView
は取得、削除、更新をこれ一つで実施してくれるため更新の必要はありません。
ただ私の場合viewの名前がSalonUpdateだったため、SalonUpdateDeleteViewに変更しました。

Reactでの画像アップロード機能の実装

フロントエンド側では、登録フォームに画像の選択欄、プレビュー機能を追加してサロンデータに画像を登録できるように機能を追加します!

1.SalonForm.jsを更新
まずは作成済みの登録フォームに画像を選択の機能と選択した画像のプレビュー機能をを追加します。
SalonForm.jsには以下の追記をします。

import React, { useState, useEffect } from 'react';
import { Button, TextField } from '@mui/material';

function SalonForm({ salon, onSalonSubmit }) {
  const [formData, setFormData] = useState({
    name: '',
    address: '',
    description: '',
    phone_number: '',
    email: '',
    image: null //追記 フィールドにimageを追加
  });
  const [imagePreview, setImagePreview] = useState(null); //追記 プレビュー画像のURLを保存するためのuseStateを追加

  useEffect(() => {
    if(salon){
      setFormData(salon);
      setImagePreview(salon.image); //既存のサロン画像をプレビューとして表示するために、imagePreviewの状態を設定します
    }
  }, [salon]);

  const handleInputChange = (e) => { //更新 画像ファイルが選択された場合、imageフィールドとimagePreviewを設定します。
    const {name, value, files } = e.target;
    if(name === 'image'){
      setFormData({...formData, [name]: files[0]});
      setImagePreview(URL.createObjectURL(files[0]));  
    }else{
      setFormData({ ...formData, [e.target.name]: e.target.value });
    }
  };

  const handleSubmit = (e) => { //追記 フォームデータをFormDataオブジェクトに変換します。
    e.preventDefault();
    const data = new FormData();
    for (const key in formData) {
      data.append(key, formData[key]);
    }
    onSalonSubmit(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <TextField
        fullWidth
        name="name"
        label="サロン名"
        value={formData.name}
        onChange={handleInputChange}
        margin="normal"
      />
      <TextField
        fullWidth
        name="address"
        label="住所"
        value={formData.address}
        onChange={handleInputChange}
        margin="normal"
      />
      <TextField
        fullWidth
        name="description"
        label="説明"
        value={formData.description}
        onChange={handleInputChange}
        margin="normal"
        multiline
        rows={4}
      />
      <TextField
        fullWidth
        name="phone_number"
        label="電話番号"
        value={formData.phone_number}
        onChange={handleInputChange}
        margin="normal"
      />
      <TextField
        fullWidth
        name="email"
        label="メールアドレス"
        type="email"
        value={formData.email}
        onChange={handleInputChange}
        margin="normal"
      />
      <input //追記 returnレンダリングに画像の選択欄を追加
        accept="image/*"
        type="file"
        name="image"
        onChange={handleInputChange}
      />
      {imagePreview && ( //追記 returnレンダリングに画像のプレビュー表示欄を追加
        <img src={imagePreview} alt="プレビュー" style={{maxWidth: '200px'}} />
      )}
      <Button type="submit" variant="contained" color="primary">
        {salon ? '更新' : '登録'}
      </Button>
    </form>
  );
}

export default SalonForm;

それぞれコードに役割をコメントアウトしています。
続いてApp.jsでapiとの連携を実装します。

4.App.jsの更新
次に、App.js の handleSalonSubmit 関数を更新して、画像を含むフォームデータを送信できるようにします。

const handleSalonSubmit = (salonData) => { 
    if(editingSalon){
      axios.put(`http://localhost:8000/api/salons/${editingSalon.id}/`, salonData, {
        headers: {
          'Content-Type':'multipart/form-data'
        } //更新 画像を含むフォームデータを送信するために、Content-Typeをmultipart/form-dataに設定しています
      })
        .then(response => {
          setSalons(salons.map(salon => salon.id === editingSalon.id ? response.data : salon));
          setIsModalOpen(false);
          setEditingSalon(null);
          setSnackbar({open: true, message: '更新完了', severity: 'success'});
        })
        .catch(error => {
          console.error("エラー発生", error);
          setSnackbar({open: true, message: '更新に失敗しました', severity: 'error'});
        });
    }else{
      axios.post('http://localhost:8000/api/salons/', salonData, {
        headers: {
          'Content-Type': 'multipart/form-data'
        } //更新 putと同様に画像を含むフォームデータを送信するために、Content-Typeをmultipart/form-dataに設定しています
      })
      .then(response => {
        setSalons([...salons, response.data]);
        setIsModalOpen(false);
        setSnackbar({open: true, message: '登録完了', severity: 'success'});
      })
      .catch(error => {
        console.error("エラー発生", error);
        setSnackbar({open: true, message: '登録に失敗しました', severity: 'error'});
      });
    }
  };
  1. サロン一覧での画像表示
    最後に、SalonListコンポーネントを更新して、各サロンの画像を表示するようにしました。

ここではMaterial-UIの Avatarコンポーネントを使用して、サロンの画像を表示します。
またレイアウトの調整はBoxコンポーネントを使用してレイアウトを調整していますが、お好みでOKですね。
画像が存在しない場合、Avatarコンポーネントはデフォルトのアイコンを表示してくれるので便利です。
ついでに編集ボタンと削除ボタンの見た目も変えてみました

import React from 'react';
import { Button, List, ListItem, Typography, Box, Avatar } from '@mui/material'; //追記 MUIからBoxとAvatarをインポート

function SalonList({ salons, onEdit, onDelete }) {
  return (
    <div>
      <Typography variant="h5">サロン一覧</Typography>
      {salons.length === 0 ? (
        <Typography>サロンは登録されていません</Typography>
      ) : (
        <List>
          {salons.map(salon => (
            <ListItem key={salon.id}>
              <Box display="flex" alignItems="center" width="100%"> //追記 Boxでレイアウトを横並びにします
                <Avatar //追記 Avatarで画像の表示をし、画像がない場合はデフォルトの画像を表示します。
                  src={salon.image}
                  alt={salon.name}
                  sx={{ width: 150, height: 100, marginRight: 2 }}
                  variant="rounded"
                />
                <Box flexGrow={1}>
                  <Typography variant="h6">{salon.name}</Typography>
                  <Typography>住所: {salon.address}</Typography>
                  <Typography>電話番号: {salon.phone_number}</Typography>
                  <Typography>メールアドレス: {salon.email}</Typography>
                  <Typography>紹介文: {salon.description}</Typography>
                  <Box mt={1}>
                    <Button onClick={() => onEdit(salon)} variant="outlined" sx={{ marginRight: 1 }}>編集</Button> //更新 編集ボタンと削除ボタンの見た目をvariantで整えました
                    <Button onClick={() => onDelete(salon.id)} variant="outlined" color="error">削除</Button>
                  </Box>
                </Box>
              </Box>
            </ListItem>
          ))}
        </List>
      )}
    </div>
  );
}

export default SalonList;

これで実装は完了です!
実際の画面を見てみましょう!

ローカルサーバーを起動して画面を確認

それではフロントエンドとバックエンドのサーバを立ち上げて機能の確認をします!
まずはサロン一覧の表示確認です!まだサロンに画像は登録していないので各サロンの欄にはMUIで実装したAvatarコンポーネントのデフォルト画像が表示されているはずです。

うまく表示されていますね!それにしても画像がない場合の処理を書かなくても、勝手に処理をしてくれるMUIのAvatarは便利ですね!

続いて、「サロン登録はこちら」ボタンからサロンの登録をしてみます!
登録フォームに画像のアップロードとプレビュー機能がついているはずです。

いい感じです!実際に画像を登録してみます!

ちょっとレイアウトがずれていますが無事にプレビュー機能も動作しています!
では登録してみます!

はい!無事に画像の登録ができました!MUIのAvatar機能も大丈夫そうです!!!
では既存のサロンに編集で画像を追加してみます!
一番上の「ID1の美容室」に画像を追加します。

私のアイコン画像を使います。

更新ボタンをクリック

無事に編集からも画像の登録ができました!!
以上で実装確認も完了です!
では最後に今回のまとめです!

最後に

今回の実装を通じて学んだこと
ファイルアップロードの処理
Djangoでの画像ファイルの処理方法と、Reactでフォームから画像を登録する方法を学びました。

画像のプレビュー表示
URL.createObjectURLを使用して、アップロード前の画像プレビューを実現しました。

Material-UIの活用
Avatarコンポーネントを使用して、サロン一覧に画像を表示する方法を学びました。

今回の実装で画像の登録ができるようになり、サロンの雰囲気を美容師さんが視覚的に認識できるようになりました!
次回は、ユーザーのサインアップやログインなど、認証システムの実装に取り組む予定です。お楽しみに!

やぎ

Discussion