【個人開発】Django管理画面をカスタマイズして会社業績10年分を一括編集可能にしてみた

# 📢 はじめに
先日、上場企業の有価証券報告書から企業業績10年分を取得・閲覧できるサイト「[Financial Lens](https://financial-lens.jp/)」をリリースしました（Web APIも提供）！

現在はβ版ですが、すでに多くのデータを自動収集して提供しています。

上記サイトを開発する過程で、EDINETから取得する有価証券報告書のCSVファイルからでは自動収集するのが難しいパターンもあったため、自動収集したデータを手動で上書きできるような仕組みを導入しました。導入にあたり、現状自分しか使わないためかｎ画面とかを一から作るのは面倒だったので、Djangoの管理画面（Django admin）をカスタマイズすることで対応しました。

# 🔧 やりたいこと
- 企業 × 年度 × 財務指標 という構造のデータを、テーブル形式でまとめて閲覧・編集したい
- それぞれのセルに直接数値を入力し、一括で保存したい
- 項目が多くて横長になるので、スクロール可能にしたい

#### 【完成イメージ】

![管理画面_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/e7200deb-bb9d-4e5a-a2ad-0f83bd603bc8.png)

# 🛠 Django管理画面をカスタマイズするには？

以下では、Django Adminの見た目や動作をカスタマイズする具体的な手順をご紹介します。

### 1. `change_list_template` によるテンプレートの差し替えでできること

Djangoでは、`ModelAdmin` クラスの `change_list_template` 属性を指定することで、管理画面の一覧表示（changelist）のテンプレートを自由に差し替えることができます。

以下のように `change_list_template` を指定することで、任意のテンプレートに差し替えできます。

```python
@admin.register(MyModel)
class MyModelAdmin(admin.ModelAdmin):
    change_list_template = "admin/my_model/change_list.html"
```

### 2. テンプレートで独自UIを定義する
`templates/admin/my_model/change_list.html` を作成して、以下のようにカスタマイズします。

```html
{% extends "admin/change_list.html" %}

{% block content %}
<form method="GET">
  <input type="text" name="q" placeholder="検索">
  <button type="submit">検索</button>
</form>

<div class="scroll-container">
  <table class="financial-table">
    <!-- ヘッダーや入力欄 -->
  </table>
</div>

<button type="submit">保存</button>
{% endblock %}
```

### 3. changelist_view() をオーバーライドしてデータを渡す
```
@admin.register(MyModel)
class MyModelAdmin(admin.ModelAdmin):
    change_list_template = "admin/my_model/change_list.html"
    
    def changelist_view(self, request, extra_context=None):
        extra_context = extra_context or {}
        extra_context["my_custom_data"] = {...}
        if request.method == "POST":
            (POST時の処理)
        return super().changelist_view(request, extra_context=extra_context)
```
- GET 時：テンプレートに渡すデータの整形
- POST 時：フォームからの入力値を処理


# 📄 実装詳細

やりたいことを踏まえて今回管理画面に追加した機能は以下になりました。

- 検索ボックスで対象銘柄の業績を表示
- 銘柄ごとに10年分のデータを横並びで表示
- 項目が多いので貸借対照表や損益計算書ごとにグループ分け
- それぞれのセルに直接数値を入力できる
- `書類管理番号`などキーとなる一部の項目は編集不可（グレーアウト）
- 項目が多いので変更があったレコードのみを更新する

上記機能を実現するためのロジックとテンプレートの実装は以下になります。

#### ロジック
```python:admin.py
@admin.register(ManualEdinetFinancialStatement)
class ManualEdinetFinancialStatementAdmin(admin.ModelAdmin):
    change_list_template = "admin/manualedinetfinancialstatement/change_list.html"

    search_fields = ["security_code", "document_id"]

    def get_queryset(self, request):
        qs = super().get_queryset(request)

        subquery = FinancialStatement.objects.filter(
            period_type='FY'
        ).values('document_id')

        qs = qs.filter(document_id__in=Subquery(subquery))

        return qs.order_by('-period_end_date')

    def changelist_view(self, request, extra_context=None):
        extra_context = extra_context or {}

        # 🔹 モデルの全カラムを取得
        model_fields = [f.name for f in ManualEdinetFinancialStatement._meta.fields]

        # 🔹 日本語のカラム名マッピング
        verbose_name_dict = {f.name: f.verbose_name for f in ManualEdinetFinancialStatement._meta.fields}

        # 🔹 初期表示時のデフォルトの銘柄を取得
        query_set = self.get_queryset(request).order_by("-security_code")
        first_company = query_set.first()
        default_security_code = first_company.security_code if first_company else ""

        # 🔹 検索条件を取得
        search_security_code = request.GET.get("q", "").strip()
        if search_security_code:
            query_set = query_set.filter(security_code__startswith=search_security_code, period_type='FY').order_by("-period_end_date")
        else:
            query_set = query_set.filter(security_code=default_security_code, period_type='FY').order_by("-period_end_date")

        financial_data = list(query_set.values(*model_fields))

        # 会社ごとにデータを整理
        formatted_data = {}
        company_names = {}

        for index, entry in enumerate(financial_data):
            company = entry["security_code"]
            if index == 0:
                company_names[company] = entry["filer_name_japanese"]

            if company not in formatted_data:
                formatted_data[company] = {}

            year = entry["fiscal_year_end_date"]
            if year not in formatted_data[company]:
                formatted_data[company][year] = {"id": entry["id"]}

            for key, value in entry.items():
                if key not in ["security_code", "filer_name_japanese", "fiscal_year_end_date", "id"]:
                    formatted_data[company][year][key] = value if value is not None else ""

        extra_context["financial_data"] = formatted_data
        extra_context["company_names"] = company_names
        extra_context["keys_list"] = [key for key in model_fields if key not in ["id", "security_code", "filer_name_japanese", "fiscal_year_end_date"]]
        extra_context["verbose_name_dict"] = verbose_name_dict
        extra_context["search_query"] = search_security_code

        # 🔹 POST処理（保存）
        if request.method == "POST":
            uploaded_ids = []
            for company, years in formatted_data.items():
                for year, values in years.items():
                    instance = ManualEdinetFinancialStatement.objects.get(id=values["id"])
                    updated = False

                    for field in model_fields:
                        if field == "document_id":
                            continue  # Skip updating document_id
                        field_key = f"{values['id']}_{field}"  # 🔹 フォームの name に対応
                        if field_key in request.POST:
                            new_value = request.POST[field_key] or None
                            if getattr(instance, field) != new_value:
                                setattr(instance, field, new_value)
                                updated = True

                    if updated:
                        instance.save()
                        uploaded_ids.append(instance.id)

            if len(uploaded_ids) > 0:
                messages.success(request, f"財務情報(IDs: {uploaded_ids})を更新しました。")
            else:
                messages.warning(request, "更新する財務情報はありませんでした。")
            return redirect(request.path)

        return super().changelist_view(request, extra_context=extra_context)
```

#### カスタムテンプレート

```html:templates/admin/manualedinetfinancialstatement/change_list.html
{% extends "admin/change_list.html" %}
{% load custom_filters %}

{% block content %}
<style>
    .scroll-container {
        width: 100%;
        overflow-x: auto;  /* 🔹 親要素に横スクロールを適用 */
        max-width: 100%;
        position: relative;
        margin-bottom: 10px;
    }
    table.financial-table {
        width: max-content;  /* 🔹 コンテンツの幅に応じて拡大 */
        border-collapse: collapse;
        margin-top: 20px;
        min-width: 100%;
    }
    table.financial-table th, table.financial-table td {
        border: 1px solid #ddd;
        padding: 8px;
        text-align: right;
        min-width: 120px;  /* 🔹 列の最小幅を設定 */
        max-width: 200px;
    }
    table.financial-table thead {
        position: sticky;
        top: 0;  /* 🔹 ヘッダーを固定 */
        z-index: 10;  /* 🔹 ヘッダーが隠れないようにする */
        background-color: white;  /* 🔹 背景色を指定してヘッダーを見やすく */
    }
    table.financial-table th {
        background-color: #f2f2f2;
        text-align: center;
        position: sticky;
        top: 0;
        z-index: 11;
    }
    input {
        width: 120px;
    }
    .company-header {
        background-color: #0044cc;
        color: white;
        text-align: left;
        font-size: 16px;
        padding: 10px;
    }
</style>

<h2>財務諸表一覧（検索可能）</h2>

<form method="GET">
    <input type="text" name="q" value="{{ search_query }}" placeholder="銘柄コードまたはDocument ID">
    <button type="submit">検索</button>
</form>

<form method="POST">
    {% csrf_token %}

    {% for company, years in financial_data.items %}
        <h3 class="company-header">{{ company_names|get_value:company }}（{{ company }}）</h3>

        <div class="scroll-container">  <!-- 🔹 横スクロール用の div -->
            <table class="financial-table">
                <thead>
                    <tr>
                        <th>項目</th>
                        {% for year in years.keys %}
                            <th>{{ year }}</th>
                        {% endfor %}
                    </tr>
                </thead>
                <tbody>
                    {% for field in keys_list %}
                        <tr>
                            <td><strong>{{ verbose_name_dict|get_value:field }}</strong></td>
                            {% for year, values in years.items %}
                                <td>
                                    <input type="text" name="{{ values.id }}_{{ field }}"
                                           value="{{ values|get_value:field }}"
                                           {% if field == "document_id" %}disabled style="background-color: lightgray;"{% endif %}
                                    >
                                </td>
                            {% endfor %}
                        </tr>
                        {% if field == "number_of_consolidated_subsidiaries" %}
                            <tr>
                              <td style="text-align: left; color: white; background-color: #0044cc;" colspan="{{ years|length|add:1 }}"><strong>貸借対照表</strong></td>
                            </tr>
                        {% endif %}
                        {% if field == "liabilities_and_net_assets" %}
                            <tr>
                              <td style="text-align: left; color: white; background-color: #0044cc;" colspan="{{ years|length|add:1 }}"><strong>損益計算書</strong></td>
                            </tr>
                        {% endif %}
                        {% if field == "diluted_earnings_per_share" %}
                            <tr>
                              <td style="text-align: left; color: white; background-color: #0044cc;" colspan="{{ years|length|add:1 }}"><strong>キャッシュフロー計算書</strong></td>
                            </tr>
                        {% endif %}
                        {% if field == "cash_and_cash_equivalents" %}
                            <tr>
                              <td style="text-align: left; color: white; background-color: #0044cc;" colspan="{{ years|length|add:1 }}"><strong>指標</strong></td>
                            </tr>
                        {% endif %}
                    {% endfor %}
                </tbody>
            </table>
        </div>
    {% endfor %}

    <button type="submit">保存</button>
</form>

{% endblock %}
```

# 🚀 まとめ
Djangoの管理画面をうまくカスタマイズすることで、専用画面を一から作らなくても、実用的な一括編集UIを実現できました！

業務系のデータ管理や、社内ツールなどでも応用しやすいテクニックと思うので、ぜひ参考にしてみてください。

# 🙌 最後に
記事で紹介した機能は、現在開発中のサービス「[Financial Lens](https://financial-lens.jp/)」でも使用しています。

日本の上場企業に関する会社業績を10年分まとめて取得し、Web上で閲覧 & Web APIを提供しています。ご興味のある方はぜひご覧ください！