🐍

DRFで構築したAPI経由でExcelの値をDBに入れるまで

に公開

はじめに

業務でExcelデータをDB側へAPI経由で移行させる必要があり、
本格的な開発に入る前に簡単に環境を構築して触ってみた時の忘備録として手順をまとめました。

この記事が少しでも皆様の助けになれれば幸いです。

やりたいこと

Excelのセルに入れた値を、Django REST Framework(DRF)で構築したAPI経由で
DB(PostgreSQL)側へ保存するまでを、
環境セットアップ→実装→データやりとりの順に試してみたい。

完成像イメージ

  • /api/items/ でCRUD処理ができるREST APIの実装をする

  • /api/items/upload_excel/.xlsxを投げると、ヘッダーを持つ表を読み取ってDB側へデータ作成&既存データの場合は更新される(いわゆるUPSERT処理)

  • OpenAPIとSwaggerUIを付ける


0. 前提とバージョン

  • Python: 3.13

  • Django: 5.2

  • Django REST Framework: 3.15+

  • PostgreSQL: 15(Docker)

  • OS: macOS / Windows / Linux いずれもOK

※ アプリはローカルの仮想環境、DBはDockerという構成で進めます。(取り急ぎサッと動かしたいため)


1. 新規プロジェクト作成

以下のコマンドを順番に実行し、Djangoの新規プロジェクトを作成します。

# 任意の作業ディレクトリでプロジェクトディレクトリ作成&移動
$ mkdir drf-excel-demo && cd drf-excel-demo

# 仮想環境を立てる(完了まで時間かかるかも)
$ python -m venv .venv

# 仮想環境を起動
# macOS/Linuxの場合はこちら
$ source .venv/bin/activate
# Windowsの場合はこちら(PowerShell)
$ .venv\Scripts\Activate.ps1

# 必要パッケージのインストール
$ pip install "Django>=5.2,<6" djangorestframework "psycopg[binary]>=3.2" \
  python-dotenv drf-spectacular pandas openpyxl django-filter requests

# Djangoプロジェクトの作成
$ django-admin startproject config .
$ python manage.py startapp inventory

2. PostgreSQL を Docker で用意

docker-compose.ymlファイルをプロジェクト直下に作成します。

services:
  db:
    image: postgres:15
    environment:
      POSTGRES_DB: drfexcel
      POSTGRES_USER: drfuser
      POSTGRES_PASSWORD: drfpass
      PGDATA: /var/lib/postgresql/data/pgdata
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

.envファイルもプロジェクト直下に作成します。

DJANGO_SECRET_KEY=change-me
DJANGO_DEBUG=1
DB_NAME=drfexcel
DB_USER=drfuser
DB_PASSWORD=drfpass
DB_HOST=127.0.0.1
DB_PORT=5432

PostgreSQLをDocker起動します。

$ docker compose up -d

3. Django 設定(DB/DRF/Schema)

config/settings.pyを以下のように編集します。

from pathlib import Path
import os
from dotenv import load_dotenv

load_dotenv()

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "insecure")
DEBUG = os.getenv("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = ["*"]

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # 3rd party
    "rest_framework",
    "drf_spectacular",
    "django_filters",
    # local
    "inventory",
]

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",
]

ROOT_URLCONF = "config.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

WSGI_APPLICATION = "config.wsgi.application"

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.getenv("DB_NAME"),
        "USER": os.getenv("DB_USER"),
        "PASSWORD": os.getenv("DB_PASSWORD"),
        "HOST": os.getenv("DB_HOST", "127.0.0.1"),
        "PORT": os.getenv("DB_PORT", "5432"),
    }
}

AUTH_PASSWORD_VALIDATORS = []

LANGUAGE_CODE = "ja"
TIME_ZONE = "Asia/Tokyo"
USE_I18N = True
USE_TZ = True

STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

REST_FRAMEWORK = {
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
}

SPECTACULAR_SETTINGS = {
    "TITLE": "DRF Excel Demo API",
    "VERSION": "1.0.0",
}

config/urls.pyを以下のように編集します。

from django.contrib import admin
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from inventory.views import ItemViewSet
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView

router = DefaultRouter()
router.register(r"items", ItemViewSet, basename="item")

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include(router.urls)),
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="docs"),
]

4. モデル/シリアライザ/ビュー

それぞれ以下をinventoryディレクトリ配下に作成します。

inventory/models.py

from django.db import models

class Item(models.Model):
    code = models.CharField(max_length=32, unique=True)
    name = models.CharField(max_length=255)
    qty = models.IntegerField(default=0)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f"{self.code} - {self.name}"

inventory/serializers.py

from rest_framework import serializers
from .models import Item

class ItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = Item
        fields = ["id", "code", "name", "qty", "price", "created_at", "updated_at"]

inventory/views.py

import pandas as pd
from decimal import Decimal
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response
from .models import Item
from .serializers import ItemSerializer

class ItemViewSet(viewsets.ModelViewSet):
    queryset = Item.objects.all().order_by("code")
    serializer_class = ItemSerializer
    filterset_fields = ["code", "name"]
    search_fields = ["code", "name"]
    ordering_fields = ["code", "name", "qty", "price", "updated_at"]

    @action(detail=False, methods=["post"], parser_classes=[MultiPartParser])
    def upload_excel(self, request):
        file = request.FILES.get("file")
        if not file:
            return Response({"detail": "'file' フィールドに .xlsx を添付してください"}, status=status.HTTP_400_BAD_REQUEST)
        try:
            df = pd.read_excel(file)  # openpyxl が使われます
        except Exception as e:
            return Response({"detail": f"Excel 読み込み失敗: {e}"}, status=status.HTTP_400_BAD_REQUEST)

        # ヘッダを正規化
        df.columns = [str(c).strip().lower() for c in df.columns]
        required = {"code", "name", "qty", "price"}
        missing = sorted(list(required - set(df.columns)))
        if missing:
            return Response({"detail": f"不足している列があります: {missing}"}, status=status.HTTP_400_BAD_REQUEST)

        created, updated = 0, 0
        for _, row in df.iterrows():
            code = str(row["code"]).strip()
            defaults = {
                "name": str(row["name"]).strip(),
                "qty": int(row["qty"]) if pd.notna(row["qty"]) else 0,
                "price": Decimal(str(row["price"])) if pd.notna(row["price"]) else Decimal("0"),
            }
            obj, is_created = Item.objects.update_or_create(code=code, defaults=defaults)
            created += int(is_created)
            updated += int(not is_created)

        return Response({"created": created, "updated": updated, "total_rows": len(df)})

inventory/admin.py

from django.contrib import admin
from .models import Item

@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
    list_display = ("code", "name", "qty", "price", "updated_at")
    search_fields = ("code", "name")

5. マイグレーション & 起動

マイグレーションファイルを作成&実行し、runserverでDjangoアプリケーションを起動します。

$ python manage.py makemigrations
$ python manage.py migrate
$ python manage.py createsuperuser  # 任意
$ python manage.py runserver

各種URLは以下のようになります。

ここまで終わるとAPIを叩ける状態になっています。

プロジェクト構成ツリーは以下のようになっているはず。
(__pycache__系の記載は省いています)

drf-excel-demo
├── config
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── docker-compose.yml
├── inventory
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   └──__init__.py
│   ├── models.py
│   ├── serializers.py
│   ├── tests.py
│   └── views.py
└── manage.py

6. APIの叩き方

用意したExcelファイルをもとに、
API経由でローカルのPostgreSQL側にCreate(作成)/Update(更新)をしてみましょう。

ついでにDelete(削除)/Read(検索) のパターンも載せておきます。

6.1 Create と Update

6.1.1 Excelファイルの用意

最初に、1行目のヘッダーをcode, name, qty, priceとした、以下のようなExcelファイルを用意します。

data.xlsx

code name qty price
A001 りんご 10 120.5
A002 ばなな 8 98.0
B100 みかん 25 50.0

6.1.2 Excelファイルを用いた一括作成

上記のExcelファイルを用意したら、
DRFで構築したAPIのアクションupload_excelを叩いてdata.xlsxを読み込ませ、
PostgreSQL側にデータを作成します。

curl -X POST \
    -F "file=@./data.xlsx" \
    http://127.0.0.1:8000/api/items/upload_excel/

問題なく成功すると以下のようなレスポンスが返ってきます。

{"created": 3, "updated": 0, "total_rows": 3}

6.1.3 Excelファイルを用いた一括更新

上記の一括作成で実行したコマンドと同じく、upload_excelを叩いてください。
同じcodeの行を含むExcelファイルを送るとその行は更新され、新規codeの行は作成されます。

curl -X POST \
    -F "file=@./data.xlsx" \
    http://127.0.0.1:8000/api/items/upload_excel/
# codeが全部かぶっていたら以下のように返ってくるはず↓
#  {"created": 0, "updated": 3, "total_rows": 3}

6.1.4 (おまけ)Excelを使わない場合の作成と更新

Excelファイルを使用せず、APIを叩いて1データのみ作りたい場合は以下のように実行します。

curl -X POST http://127.0.0.1:8000/api/items/ \
    -H "Content-Type: application/json" \
    -d '{"code": "C777", "name": "キウイ", "qty": 5, "price": 210}'

また、更新する際は更新したいコードのIDを下記のコマンドで調べて

curl "http://127.0.0.1:8000/api/items/?code=A001"

取得したIDに対して部分更新をかけます。

curl -X PATCH http://127.0.0.1:8000/api/items/1/ \
    -H "Content-Type: application/json" \
    -d '{"price": 135.0, "qty": 12}'

上記はPATCHで更新していますが、全置換ならPUTでもOKです。

curl -X PUT http://127.0.0.1:8000/api/items/1/ \
    -H "Content-Type: application/json" \
    -d '{"code": "A001", "name": "マンゴー", "qty": 10, "price": 250}'

6.2 (おまけ)DELETE と READ

取り急ぎUPSERTの連携の部分は上手くいったのでここまでとなるのですが、
データの削除と抽出の例をこちらに記載させていただきます。

6.2.1 削除

例えばid=1を削除したい場合は、以下のように実行します。

curl -X DELETE http://127.0.0.1:8000/api/items/1/

6.2.2 抽出

データを抽出したい場合は以下のようにAPIを叩きます。

全件を出す場合

curl "http://127.0.0.1:8000/api/items/"

例えばcode=A001のみの完全一致検索

curl "http://127.0.0.1:8000/api/items/?code=A001"

namecodeに「りんご」を含むあいまい検索

curl "http://127.0.0.1:8000/api/items/?search=りんご"

並び替え

# 更新日の降順
curl "http://127.0.0.1:8000/api/items/?ordering=-updated_at"
# 価格の昇順
curl "http://127.0.0.1:8000/api/items/?ordering=price"
# 価格の降順
curl "http://127.0.0.1:8000/api/items/?ordering=-price"
# 複数キーの場合
curl "http://127.0.0.1:8000/api/items/?ordering=price,-updated_at"

次のステップとしては、DRFへupload_excelアクションのように削除機能やExcelファイルへの抽出機能を実装してみたいと考えています。


7. おわりに

目的としていたDBへのAPI経由でのCRUD処理と、
Excelファイルを連携させたUPSERT処理が実装できました。

ご質問や改善点等あれば気軽にコメントを頂ければと思います。
よろしくお願いいたします。

Discussion