ReactとDjangoを使用して帳票（PDF）作成アプリを開発

# 概要

本記事ではReactとDjangoを使用して帳票（PDF）作成する機能と、
作成した帳票を閲覧できる機能を実装します。
djangorestframeworkを使用してReactとAPI通信を行います。
PDFの作成にはreportlabライブラリを使用します。

:::note info
以下、バージョン情報など。

OS : MacOS(M1)
DB : SQLite3

Django==4.2.9
django-cors-headers==4.3.1
djangorestframework==3.14.0
reportlab==4.0.8

react@18.2.0
react-router-dom@6.21.1
:::

完成イメージ
![スクリーンショット 2024-01-05 23.12.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/667355/2b129980-41ff-b6d3-d6ae-ae2e38d7b9c6.png)
![スクリーンショット 2024-01-05 23.13.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/667355/d5c33763-a9ab-9672-9ce6-2f2217e9ba80.png)
![スクリーンショット 2024-01-05 23.13.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/667355/3ba7fe27-016a-cf8e-3f6f-a5e9e55eb0d6.png)



# 環境構築
環境構築は特に解説など交えずさらっといきます。

## Djangoの開発環境構築
```bash
python3 -m venv venv

source venv/bin/activate

python -m pip install --upgrade pip

pip install django
pip install django-cors-headers
pip install djangorestframework
pip install reportlab

pip freeze > requirements.txt

django-admin startproject core .

python manage.py startapp pdfapp
```

- django-cors-headersは、Reactからのリクエストがブロックされるのを防ぎ、CORSヘッダーを設定するのに必要

## Djangoの設定ファイルを修正
以下、修正した箇所です。
```python:settings.py
import os

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'pdfapp', #追加
    'rest_framework',#追加
    'corsheaders',#追加
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'corsheaders.middleware.CorsMiddleware', #追加
]

TIME_ZONE = 'Asia/Tokyo'

LANGUAGE_CODE = 'ja'

CORS_ORIGIN_WHITELIST = [
    'http://localhost:3000',  # ReactアプリのURL
]

# ↓PDFを保存する場所
# mediaディレクトリはmanage.pyがあるディレクトリで作成
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
```

## DjangoプロジェクトのURLを修正
以下がURL全体のコードです。
```python:core/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from django.urls import path, include

urlpatterns = [
    path('api/', include('pdfapp.urls')),
    path('admin/', admin.site.urls),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
```

- 今回DjangoはAPIを受け付ける用なので、api/できたリクエストはpdfappのurlsを参照するように設定
- ＋staticの部分はローカル環境で作成したPDFをWEBで見れるようにするのに必要

## pdfappに必要なファイルを作成
- serializers.py
- urls.py

上記の2つのファイルをDjangoアプリ内で作成しておいてください。

## Reactプロジェクトの作成とライブラリのインストール
DjangoのプロジェクトルートディレクトリでReactアプリを作成し必要なライブラリをインストールしましょう。
```bash
npx create-react-app myapp
cd myapp
npm install react-router-dom
npm install axios
```

- react-router-domはルーティングするのに必要なライブラリです
- axiosは、JavaScriptでHTTPリクエストを行うためのライブラリです

## APIの転送設定
APIリクエストをDjangoサーバーに転送できるようにmyapp ディレクトリ内のpackage.jsonにプロキシ設定を追加します

```js:package.json
{
  "name": "myapp",
  
      ----省略----
  
  "browserslist": {
  
      ----省略----
  
  },
  "proxy": "http://localhost:8000"
}
```


現状のディレクトリ構成を2階層まではっ付けておきます。
```bash
.
├── core
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── manage.py
├── media
├── myapp
│   ├── README.md
│   ├── node_modules
│   ├── package-lock.json
│   ├── package.json
│   ├── public
│   └── src
├── package-lock.json
├── package.json
├── pdfapp
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   ├── models.py
│   ├── serializers.py
│   ├── tests.py
│   ├── urls.py
│   └── views.py
├── requirements.txt
└── venv
    ├── bin
    ├── include
    ├── lib
    └── pyvenv.cfg
```

# モデル作成
今回はサンプルとして製品モデルを作成します。
モデルの定義については各人の要件に合わせて修正してください。
モデルを作成したらpython manage.py makemigrationsとpython manage.py migrateしてください。
```python:models.py
from django.db import models

class Product(models.Model):
    product_name = models.CharField(max_length=255)
    description = models.TextField()
    pdf_file = models.FileField(upload_to='pdf/', null=True, blank=True)

    class Meta:
        db_table = 'M_Products'
```
- 先ほど作成したmedia/ディレクトリ内にpdfディレクトリを作成しておいてください
- media/pdfにPDFが保存されていくようにします

# シリアライザーの作成
API通信でデータの送受信をするため、シリアライザーをDjangoアプリ内に作成しましょう。
```python:pdfapp/serializers.py
from rest_framework import serializers
from .models import Product

class ProductModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = '__all__' 
```
- シリアライザは、モデルのデータをJSON形式などの形式に変換するために使用され、またその逆の変換も行います。これにより、DjangoのモデルをRESTfulなAPIを通じてやり取りすることが可能になります
- serializers.ModelSerializerはDjangoのモデルを自動的にシリアライズするための機能を提供します
- model = でこのシリアライザがどのモデルに対して使用されるかを指定
- 全てのフィールドをシリアライズに含めることを指定

# viewsの作成
次にviewsを作成しましょう。
```python:pdfapp/views.py
def create_pdf(product_instance):
    # PDFファイルの名前とパスを設定
    pdf_name = f"product_{product_instance.id}.pdf"
    pdf_path = os.path.join(settings.MEDIA_ROOT, 'pdf', pdf_name)

    c = canvas.Canvas(pdf_path)

    # 各テキストの開始位置
    start_x = 100
    start_y = 800
    line_height = 20

    c.drawString(start_x, start_y, f"プロダクト名: {product_instance.product_name}")
    c.drawString(start_x, start_y - line_height * 1, f"説明: {product_instance.description}")

    c.save()

    return pdf_name, pdf_path
    
class ProductModelViewSet(viewsets.ModelViewSet):
  queryset = Product.objects.all()
  serializer_class = ProductModelSerializer

  def perform_create(self, serializer):
        instance = serializer.save()
        pdf_name, pdf_path = create_pdf(instance)

        # PDFファイルをモデルのpdf_fileフィールドに保存
        with open(pdf_path, 'rb') as pdf_file:
            instance.pdf_file.save(pdf_name, File(pdf_file), save=True)

        instance.save()
```
◎create_pdfについて
- create_pdfは、与えられたproduct_instance（Productモデルのインスタンス）に基づいてPDFファイルを作成し、そのファイル名とパスを返す関数です
- start_xとstart_yはPDF内のテキストの開始位置を指定し、line_heightは行間の高さを指定
- 最後にsaveメソッドを呼び出してPDFファイルを保存し、ファイル名とパスを返します

◎ProductModelViewSetについて
- ProductModel対するCRUD操作を提供するAPIエンドポイントを作成
- querysetとserializer_classは操作するモデルとシリアライザを指定
- perform_createは、新しいProductModelインスタンスが作成された後に呼び出されます
- このメソッドはcreate_pdf関数を使用してPDFを作成し、モデルのpdf_fileフィールドに保存する役割があります
- instance.save()が呼び出されて初めてデータ（PDFファイルがpdf_fileに保存される）はモデルに保存されます

# アプリURLの設定
pdfappのurls.pyを修正します
```python:pdfapp/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ProductModelViewSet

router = DefaultRouter()
router.register(r'product', ProductModelViewSet)

urlpatterns = [
    path('', include(router.urls)),
]

```
- Django REST frameworkのDefaultRouterを使用して、ProductModelViewSetに対するURLルートを自動的に作成しています
- DefaultRouterのインスタンスを作成し、ProductModelViewSetをproductというURLパスに登録
- これにより、ProductModelViewSetが提供するAPIエンドポイント（CRUD操作）に対するURLが自動的に生成される
- これらの設定により、例えばProductモデルのリストを取得するためのGETリクエストは/product/に対して行われ、新しいProductインスタンスを作成するためのPOSTリクエストも同じパスに対して行われます
- 特定のProductインスタンスに対する操作（取得、更新、削除）は、インスタンスのIDをURLに含めることで行うことができます（例：/product/1/）

# React
まずは帳票作成と関係ない部分ですが、WEBページのデザインなどを実装します。
ここについては解説しません。

## デザインなどWEBページの基本部分を作成
src/ディレクトリにcomponentsディレクトリを作成し、このディレクトリにコンポーネントファイルとCSSを定義します。
※本記事でメイン解説するのはProductCreate.jsxとProductList.jsxです。
作成するファイルは以下の通り。
- Header.jsx
- Footer.jsx
- Sidebar.jsx
- MainContent.jsx
- Home.jsx
- ProductCreate.jsx
- ProductList.jsx
- css/style.css

各ファイルの内容を添付しておきます。
ちなみに、デザインはAdminLTEを使用しています。（MITライセンスです）

```public/index.html
    <!-- AdminLTE CSS -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/admin-lte@3.1/dist/css/adminlte.min.css">
    <!-- FontAwsome -->
    <link href="https://use.fontawesome.com/releases/v6.2.0/css/all.css" rel="stylesheet">
    <title>帳票作成アプリ</title>


    <!-- AdminLTE JS（bodyタグ最後部に追加） -->
    <script src="https://cdn.jsdelivr.net/npm/admin-lte@3.1/dist/js/adminlte.min.js"></script>
```
- head内にCSSなど追加
- body内にスクリプト追加

```css:css/style.css
.userName {
  color: white;
}

.navbar-nav {
  height: 40px;
}

.content-wrapper {
  padding: 20px;
}

.customCard {
  padding: 30px;
  border: none;
  box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.05);
}

.custom-file {
  width: auto;
}

.customBtn {
  width: auto;
  float: right;
  min-width: 200px;
  margin-right: 30px;
}

.customMark {
  margin-left: 10px;
  color: red;
  font-size: 20px;
  vertical-align: middle;
}
```

```jsx:Header.jsx
import React from 'react';
import './css/style.css';

function Header() {
    return (
        <nav className="main-header navbar navbar-expand navbar-white navbar-light">
            <ul className="navbar-nav">

            </ul>
      </nav>
    )
}

export default Header;
```

```jsx:Footer.jsx
import React from 'react';
import './css/style.css';

function Footer() {
    return (
        <footer className="main-footer">
            <div className="float-right d-none d-sm-block">
                <b>Version</b> 1.0.0
            </div>
            <strong>Copyright © MJ.</strong> All rights reserved.
      </footer>
    )
}

export default Footer;
```

```jsx:Sidebar.jsx
import React from 'react';
import { Link } from 'react-router-dom';
import './css/style.css';

function Sidebar() {
    return (
      <aside className="main-sidebar sidebar-dark-primary elevation-4">
        <div className="brand-link">
          <img src={process.env.PUBLIC_URL + '/logo512.png'} alt="Logo" className="brand-image img-circle elevation-3" style={{ opacity: 0.8 }} />
          <span className="brand-text font-weight-light">帳票作成アプリ</span>
        </div>
        <div className="sidebar">
          <div className="user-panel mt-3 d-flex">
            <div className="image">
              <img src={process.env.PUBLIC_URL + '/images/userlogo.png'} className="img-circle elevation-2" alt="User Image" />
            </div>
            <div className="info">
              <p className="d-block userName">User Name 1</p>
            </div>
          </div>
          <nav className="mt-2">
            <ul className="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
              <li className="nav-item">
                <Link to="/component1" className="nav-link">
                    <i className="nav-icon fas fa-house"></i>
                    <p>Home</p>
                </Link>
              </li>
              <li className="nav-item">
                <Link to="/component2" className="nav-link">
                    <i className="nav-icon fas fa-file-pdf"></i>
                    <p>帳票作成</p>
                </Link>
              </li>
              <li className="nav-item">
                <Link to="/component3" className="nav-link">
                    <i className="nav-icon fas fa-book"></i>
                    <p>帳票一覧</p>
                </Link>
              </li>
            </ul>
          </nav>
        </div>
      </aside>
    );
  }
  

export default Sidebar;
```

```jsx:MainContent.jsx
import React from 'react';
// Components
import Home from './Home';
import ProductCreate from './ProductCreate';
import ProductList from './ProductList';

function MainContent({ selectedComponent }) {
    switch (selectedComponent) {
      case 'component1':
        return <Home />;
      case 'component2':
        return <ProductCreate />;
      case 'component3':
        return <ProductList />;
      default:
        return <Home />;
    }
  }

  export default MainContent;
```

```jsx:Home.jsx
import React from 'react';
import './css/style.css';

function Home() {
    return (
        <div className="content-wrapper">
          <section className="content-header">
            <div className="container-fluid">
              <div className="row mb-2">
                <div className="col-sm-6">
                  <h1>Title</h1>
                </div>
              </div>
            </div>
          </section>
          <section className="content">
            <div className="container-fluid">
              <p>hello world</p>
            </div>
          </section>
        </div>
    )
}

export default Home;
```

```jsx:ProductCreate.jsx
import React from 'react';
import './css/style.css';

function ProductCreate() {
    return (
        <p>create</p>
    )
}

export default ProductCreate;
```

```jsx:ProductList.jsx
import React from 'react';
import './css/style.css';

function ProductList() {
    return (
        <p>list</p>
    )
}

export default ProductList;
```

```jsx:App.js
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
// Components
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import MainContent from './components/MainContent';
import Footer from './components/Footer';

function App() {
  return (
    <Router>
      <div className="wrapper">
        <Header />
        <Sidebar />
        <Routes>
          <Route path="/" element={<MainContent />} />
          <Route path="/component1" element={<MainContent selectedComponent="component1" />} />
          <Route path="/component2" element={<MainContent selectedComponent="component2" />} />
          <Route path="/component3" element={<MainContent selectedComponent="component3" />} />
        </Routes>
        <Footer />
      </div>
    </Router>
  );
}

export default App;

```

ここまでのコードで以下のような画面が出てくるかと思います。
![スクリーンショット 2024-01-05 22.55.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/667355/744e2927-c9b6-8423-c747-552f2071bf5f.png)

## PDF作成機能実装
ProductCreate.jsxの中身は以下です。
```jsx:ProductCreate.jsx
import React, { useState } from 'react';
import axios from 'axios';
import './css/style.css';

function ProductCreate() {
  　 const [formData, setFormData] = useState({
      productName: '',
      description: '',
    });

    const handleInputChange = (e) => {
      setFormData({ ...formData, [e.target.name]: e.target.value });
    };

    const handleSubmit = async (event) => {
      event.preventDefault();

      const dataToSend = new FormData();
      dataToSend.append('product_name', formData.productName);
      dataToSend.append('description', formData.description);

      try {
        const response = await axios.post('http://localhost:8000/api/product/', dataToSend, {
          headers: {
            'Content-Type': 'multipart/form-data'
          }
        });
        console.log('送信成功:', response.data);
        alert('帳票作成に成功しました。');
        // フォームのリセット
        setFormData({
          productName: '',
          description: '',
        });
      } catch (error) {
        console.error('送信エラー:', error);
        alert('送信に失敗しました。');
      }
    };

    return (
        <div className="content-wrapper">
          <section className="content-header">
            <div className="container-fluid">
              <div className="row mb-2">
                <div className="col-sm-6">
                  <h1><strong>帳票作成</strong></h1>
                </div>
              </div>
            </div>
          </section>
          <section className="content">
            <div className="container-fluid">
              <div className="card customCard">
                <form onSubmit={handleSubmit}>
                  {/* プロダクト名 */}
                  <div className="row col-12 mb-3">
                    <div className="col-lg-3 col-sm12 mb-3">
                      <p><strong>プロダクト名</strong><strong className="customMark">*</strong></p>
                    </div>
                    <div className="col-lg-9 col-sm12">
                      <input
                        className="form-control"
                        type="text"
                        placeholder=""
                        name="productName"
                        value={formData.productName}
                        onChange={handleInputChange}
                        required
                      />
                    </div>
                  </div>

                  {/* 説明 */}
                  <div className="row col-12 mb-3">
                    <div className="col-lg-3 col-sm12 mb-3">
                      <p><strong>説明</strong><strong className="customMark">*</strong></p>
                    </div>
                    <div className="col-lg-9 col-sm12">
                      <textarea
                        className="form-control"
                        rows="5"
                        placeholder=""
                        name="description"
                        value={formData.description}
                        onChange={handleInputChange}
                      ></textarea>
                    </div>
                  </div>

                  <button type="submit" className="btn btn-block btn-success customBtn">帳票を作成する</button>
                </form>
              </div>
            </div>
          </section>
        </div>
    )
}

export default ProductCreate;
```
- axiosライブラリをインポートして、非同期HTTPリクエストを行います
- useStateフックを使用して、フォームのデータ（formData）の状態管理をする
- handleInputChange関数は、フォームの入力フィールドが変更されたときに呼び出されます。この関数はformData状態を更新します
- handleSubmit関数は、フォームの送信ボタンが押されたときに実行されます
- axios.postを使用して、APIエンドポイントにデータを送信します

## PDFの一覧機能
以下がjsxの中身です。
```jsx:ProductList.jsx
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './css/style.css';


function ProductList() {
    const [ledgerItems, setLedgerItems] = useState([]);

    useEffect(() => {
      axios.get('http://localhost:8000/api/product/')
        .then(response => {
          setLedgerItems(response.data);
        })
        .catch(error => {
          console.error('データの取得に失敗しました:', error);
        });
    }, []);
    
    return (
        <div className="content-wrapper">
          <section className="content-header">
            <div className="container-fluid">
              <div className="row mb-2">
                <div className="col-sm-6">
                  <h1><strong>帳票一覧</strong></h1>
                </div>
              </div>
            </div>
          </section>
          <section className="content">
            <div className="container-fluid">
              <div>
                {ledgerItems.map(item => (
                  <div className="card customCard" key={item.id}>
                    <div className="row col-lg-12">
                      <div className="row col-lg-6 col-sm-12">
                        {/* プロダクト名 */}
                        <div className="row listInfo">
                          <div className="col-12">
                            <p><strong>プロダクト名</strong></p>
                          </div>
                          <div className="col-12">
                            <p>{item.product_name}</p>
                          </div>
                        </div>
                      </div>
                      <div className="col-lg-6 col-sm-12">
                        <a href={item.pdf_file} target="_blank" rel="noopener noreferrer">PDFを見る</a>
                      </div>
                    </div>
                  </div>
                ))}
              </div>
            </div>
          </section>
        </div>
    )
}

export default ProductList;
```

- axios.getを使用して、指定されたAPIエンドポイント（http://localhost:8000/api/product/）から製品データを取得
- レスポンスが成功した場合、setLedgerItemsを使用してledgerItems状態を更新
- ledgerItems配列を.mapメソッドを使用して反復処理し、各製品のデータを表示


これでPDFの作成と一覧を見ることができるようになりました。