4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-01-05

概要

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

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

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
スクリーンショット 2024-01-05 23.13.22.png
スクリーンショット 2024-01-05 23.13.40.png

環境構築

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

Djangoの開発環境構築

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の設定ファイルを修正

以下、修正した箇所です。

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全体のコードです。

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アプリを作成し必要なライブラリをインストールしましょう。

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にプロキシ設定を追加します

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

現状のディレクトリ構成を2階層まではっ付けておきます。

.
├── 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してください。

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アプリ内に作成しましょう。

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を作成しましょう。

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を修正します

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/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;
}
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;
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;
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;
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;
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;
ProductCreate.jsx
import React from 'react';
import './css/style.css';

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

export default ProductCreate;
ProductList.jsx
import React from 'react';
import './css/style.css';

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

export default ProductList;
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

PDF作成機能実装

ProductCreate.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の中身です。

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;

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

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?