見出し画像

Django Adminサイト上で売上分析画面を作成するレシピ

この技術ノートではDjangoのadminサイトで以下のような売上分析用画面を作成する方法を学ぶことができます。

このレシピでは、商品の売り上げを特定の期間(すべて、月毎、日ごと)で合計し、商品毎の売り上げ割合などを分析できる画面を作成します。


1. 事前準備

まずは、開発に必要な事前準備を行います。

仮想環境の作成、Djangoプロジェクトの作成、アプリケーションの作成まで行います。

以下のコマンドを実行して、アプリケーション作成まで完了させてください。

仮想環境の作成とアクティベート

python -m venv  dashboard
dashboard\scripts\activate

次にdjangoをインストールします。

pip install django

次に、Djangoプロジェクトを作成します。

django-admin startproject config .

次に、アプリケーション(dashboard)を作成します。

python manage.py startapp dashboard

config/settings.pydashboard.apps.DashboardConfigを追加します。

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'dashboard.apps.DashboardConfig',  #追加
]]

また、言語とタイムゾーンを日本に変更しておきます。

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'

以上で初期設定は完了です。

2.モデルの定義

ここでは以下のような商品をユーザに販売するケースを想定したモデルを定義します。

dashboard/models.pyに以下のようなモデルを定義します。

from django.db import models
from django.contrib.auth.models import User


class Product(models.Model):
    
    product_name = models.CharField(max_length=100,verbose_name ="製品名")
    price = models.IntegerField(default=0,verbose_name ="価格") 
    def __str__(self):
        return self.product_name
    class Meta:
        db_table = 'm_product'


class Sale(models.Model):
    
    user = models.ForeignKey(User, on_delete=models.PROTECT,verbose_name ="購入ユーザ")
    product = models.ForeignKey(Product, on_delete = models.PROTECT,verbose_name ="製品名")
    created = models.DateTimeField("購入日時")
    class Meta:
        db_table = 't_sale'

Saleクラスにはユーザ名(user)と製品名(product)、購入日時(created)のフィールドを定義しています。

製品は別テーブルクラス(Product)を定義し、製品名(product_name)とその製品の価格(price)のフィールドを定義しておきます。

ここはあくまで参考例ですので、好きな項目を追加していただいて問題ありません。

次に、販売情報(Sale)をダッシュボードに表示するためのクラスを定義します。

dashboard/models.pyの末尾に以下のモデル定義を追加してください。

class SaleSummary(Sale):
    
    class Meta:
        proxy = True
        verbose_name = '販売概要'
        verbose_name_plural = '販売概要'

ここでは、先に定義したSaleクラスを承継して販売情報をサマリーするためのSaleSummaryクラスを定義しています。

ポイントはMetaクラス内で定義しているproxy = Trueという部分です。

Djangoのモデルクラスを継承した際にMetaクラスに特に指定が無い場合は、migrateを実行すると承継元、承継先のテーブルがそれぞれ生成されてしまいます。

今回は、あくまでadmin画面上で販売情報(Sale)を分析するダッシュボード用のモデル定義がほしいだけで、Saleとは別にSaleSummaryクラスのテーブルを作成する必要はありません。

こういうケースでは、プロキシモデルを使うことで承継先のテーブルだけを使い、承継元に必要なメソッド等を追加するといった使い方ができます。

Metaクラス内にproxy = Trueと指定することで、定義したクラス(SaleSummary)がプロキシモデルになります。

この状態でマイグレーションを実行してみましょう。

python manage.py makemigrations


#実行結果
Migrations for 'dashboard':
  dashboard\migrations\0001_initial.py
    - Create model Product
    - Create model Sale
    - Create proxy model SaleSummary
python manage.py migrate


#実行結果
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, dashboard, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying dashboard.0001_initial... OK
  Applying sessions.0001_initial... OK

マイグレーションを実行すると、m_productテーブルとt_saleテーブルのみが作成されます。

SaleSummaryクラスに対するテーブルは作成されません。

3.admin.pyの定義

モデルができたので、admin.pyにadmin画面を表示するためのカスタマイズをしていきます。

日付でフィルタリングできるナビゲーションを追加

まずは、以下の通り設定します。

from django.contrib import admin
from .models import Product, Sale, SaleSummary

class SaleAdmin(admin.ModelAdmin):
    list_display = ('id', 'user', 'product', 'created')


class ProductAdmin(admin.ModelAdmin):
    list_display = ('id', 'product_name', 'price',)


class SaleSummaryAdmin(admin.ModelAdmin):
    change_list_template = 'admin/sale_summary_change_list.html'
    date_hierarchy = 'created'


admin.site.register(SaleSummary, SaleSummaryAdmin)
admin.site.register(Product, ProductAdmin)
admin.site.register(Sale, SaleAdmin)

SaleAdminとProductAdminでは、それぞれSaleとProductのデータを一覧表示させるため、list_displayに表示させたいカラム名を定義しています。

registerメソッドの第1引数に表示させたいテーブルクラス名、第2引数にadmin画面表示用のカスタマイズクラス名を指定します。

admin.site.register(Product, ProductAdmin)
admin.site.register(Sale, SaleAdmin)

次に販売サマリーデータを表示させるために以下のSaleSummaryAdminを定義しています。

class SaleSummaryAdmin(admin.ModelAdmin):

    change_list_template = 'admin/sale_summary_change_list.html'
    date_hierarchy = 'created

ModelAdminクラスに存在するchange_list_templateにカスタマイズ用のHTMLテンプレートを指定することで、adminサイトの画面デザインをカスタマイズすることができます。

また、date_hierarchyをモデルのDateFieldやDateTimeFieldに指定すると、変更リストのページに指定フィールドの日付を使って日付ベースで絞り込みできるナビゲーションが組み込まれます。

次にchange_list_templateに指定したテンプレートファイルを作成します。

まずは、dashboardアプリケーション直下にtemplates/adminフォルダを作成します。
次にadminフォルダ内にsale_summary_change_list.htmlを作成し、以下のhtmlコードを記載します。

<!-- dashboard/templates/admin/sale_summary_change_list.html -->

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

{% block content_title %}
    <h1> 商品販売サマリー </h1>
{% endblock %}

{% block result_list %}
    <!-- 後でここにコードを追加... -->

{% endblock %}

{% block pagination %}{% endblock %}

それでは、実際にadminサイトの画面を確認してみましょう。

まずはログオンするために任意のユーザ名で管理者ユーザを作成します。

python manage.py createsuperuser

開発サーバを起動してadminサイト(http://127.0.0.1:8000/admin)にアクセスしましょう。

python manage.py runserver

以下のようにProducts、Sales、販売概要(SaleSummary)が表示されます。

Products(製品データ)とSales(販売データ)がまだ何もないため、適当にデータを登録します。

下図では製品情報を3つ登録しています。

販売情報(Sales)も以下のように少し多めに登録しておきましょう。

次に「販売概要」をクリックしてみましょう。
以下のようにadmin.pyで設したdate_hierarchyが効いて、日付でフィルタリングできるナビゲーションが追加されていることが確認できます。

まだダッシュボードを表示させるコードを実装していないため、何も表示されませんが問題ありません。

Summary Tableの追加

テンプレートに送信されるコンテキストは、ModelAdminchangelist_viewという関数に入力されます。
テンプレートでテーブルをレンダリングするには、changelist_view でデータを取得してコンテキストに追加します。

admin.pySaleSummaryAdminに以下の通りchangelist_viewメソッドを追加します。

from django.contrib import admin
from .models import Product, Sale, SaleSummary 
from django.db.models import Count, Sum #追加する


class SaleAdmin(admin.ModelAdmin):
    list_display = ('id', 'user', 'product', 'created')


class ProductAdmin(admin.ModelAdmin):
    list_display = ('id', 'product_name', 'price',)


class SaleSummaryAdmin(admin.ModelAdmin):
    change_list_template = 'admin/sale_summary_change_list.html'
    date_hierarchy = 'created'

    # ------------------ ここから追加 ------------------
    def changelist_view(self, request, extra_context=None):
        response = super().changelist_view(
            request,
            extra_context=extra_context,
        )
        try:
            qs = response.context_data['cl'].queryset
        except (AttributeError, KeyError):
            return response
        metrics = {
            'total': Count('id'),
            'total_sales': Sum('product__price'),
        }
        response.context_data['summary'] = list(
            qs
            .values('product__product_name')
            .annotate(**metrics)
            .order_by('-total_sales')
        )
        return response
        # ------------------ ここまで追加 ------------------


admin.site.register(SaleSummary, SaleSummaryAdmin)
admin.site.register(Product, ProductAdmin)
admin.site.register(Sale, SaleAdmin)

冒頭で以下のコードも追加しています。

from django.db.models import Count, Sum #追加する

追加したchangelist_viewメソッドの要点を解説します。

changelist_viewメソッドをオーバーライドすることで既存のadmin管理画面の動作を上書きすることができます。

まず最初に、以下の部分でsuper()で承継元(ModelAdmin)側に定義されているchange_list_viewを実行してレスポンスを受け取ります。

response = super().changelist_view(
            request,
            extra_context=extra_context,
        )

受け取ったresponseに処理を追加していきます。

response.context_data['cl'].queryset は、すべての管理フィルターが既に適用された後のクエリセットで、このクエリセットに対してカスタマイズの処理を追加していきます。

以下では、django.db.modelsCountSumクラスを使ってid列の合計数と製品のpriceカラムの合計を計算しています。

metrics = {
            'total': Count('id'),
            'total_sales': Sum('product__price'),
        }

次に以下のコードについて解説します。

 response.context_data['summary'] = list(
            qs
            .values('product__product_name')
            .annotate(**metrics)
            .order_by('-total_sales')
        )

まず基本知識ですが、特定のカラムのみ取得するにはvalue()を使います。
また、annotate()は、QuerySet の各アイテムに対する集計を生成します。
order_byはカラムでソートを行いたい場合に利用します。

各メソッドはドット「.」でつなぐことができるので、以下のようにつなげることで「製品名」毎にmetricsで指定した値を集計し、total_salesの降順で集計結果を取得することができます。


qs.values('product__product_name').annotate(**metrics).order_by('-total_sales')

具体的には、「商品ごとの売上合計数と合計金額」を取得することができます。
また、list()で囲うことで集計結果をリスト形式で取得することができます。

list(qs
      .values('product__product_name')
      .annotate(**metrics)
      .order_by('-total_sales')
      )

上記のコードが実行されると、以下のようなデータが取得されます。

{'product__product_name': '商品AAA', 'total': 14, 'total_sales': 70000}
{'product__product_name': '商品CCC', 'total': 7, 'total_sales': 68600}
{'product__product_name': '商品BBB', 'total': 14, 'total_sales': 41720}

取得した集計結果の情報は以下のようにresponsecontext_dataに格納してreturnすることでテンプレート側でデータを受け取ることができます。

response.context_data['summary']

テンプレート側で以下のようにsummaryという名称を使ってデータを取得することができます。

{% for row in summary %}
   {{ row.product__product_name }}
{% endfor %}

次に、sale_summary_change_list.htmlで上記のsummaryを受け取ってレンダリングします。

sale_summary_change_list.htmlを以下の様に修正します。

<修正前>

<!-- dashboard/templates/admin/sale_summary_change_list.html -->

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

{% block content_title %}
    <h1> 商品販売サマリー </h1>
{% endblock %}

{% block result_list %}
    <!-- 後でここにコードを追加... -->

{% endblock %}

{% block pagination %}{% endblock %}

<修正後>

<!-- dashboard/templates/admin/sale_summary_change_list.html -->

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

{% block content_title %}
<h1> 商品販売サマリー </h1>
{% endblock %}

{% block result_list %}
<!-- ここから下を追加 -->
<div class="results">
    <table>
        <thead>
            <tr>
                <th>
                    <div class="text">
                        <a href="#">製品名</a>
                    </div>
                </th>
                <th>
                    <div class="text">
                        <a href="#">合計数</a>
                    </div>
                </th>
                <th>
                    <div class="text">
                        <a href="#">合計金額</a>
                    </div>
                </th>
                <th>
                    <div class="text">
                        <a href="#">
                            <strong>総売上高に占める割合(%)</strong>
                        </a>
                    </div>
                </th>
            </tr>
        </thead>
        <tbody>
            {% for row in summary %}
            <tr class="{% cycle 'row1' 'row2' %}">
                <td> {{ row.product__product_name }} </td>
                <td> {{ row.total }}個 </td>
                <td> {{ row.total_sales }}円 </td>
                <td>
                    <strong>
                        {ここに割合を入れる}
                    </strong>
                </td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
</div>
<!-- ここまでを追加 -->
{% endblock %}

{% block pagination %}{% endblock %}

summaryには以下のようなリストデータが格納されているので、forループで1つずつ値を取得してテンプレートに表示することができます。

{'product__product_name': '商品AAA', 'total': 14, 'total_sales': 70000}
{'product__product_name': '商品CCC', 'total': 7, 'total_sales': 68600}
{'product__product_name': '商品BBB', 'total': 14, 'total_sales': 41720}

ここまでの実装が完了したら、先ほど参照したadminサイトの「販売概要」ページを更新してみましょう。
以下の通り、商品毎にレコード数合計、合計金額が表示されます。

次に「総売上高に占める割合(%)」列に各商品の売り上げ合計金額が全体の何パーセントを占めるかがわかる情報を組み込みます。

まずは、admin.pyに総売り上げを計算するロジックを追加します。

class SaleSummaryAdmin(admin.ModelAdmin):
   # ...
    response.context_data['summary'] = list(
        qs
        .values('product__product_name')
        .annotate(**metrics)  
        .order_by('-total_sales')  #降順
    )
    #  ------------------ ここから追加 ------------------
    # 合計金額の取得
    response.context_data['summary_total'] = dict(
        qs.aggregate(**metrics))
    #  ------------------ ここまで追加 ------------------
    return response

aggregateメソッドを使って販売情報(Sale)の集計値を計算します。
以下のコードでmetricsで指定された計算方法に従いt_saleテーブルの合計値が計算されます。

qs.aggregate(**metrics)

具体的には以下のような計算結果が戻り値として返ってきます。

{'total': 35, 'total_sales': 180320}

取得した結果は、テンプレート側でsummary_totalとして参照できます。

では、テンプレート側の設定を修正していきましょう。
dashboard\templates\admin\sale_summary_change_list.htmlを開き、{ここに割合を入れる}という箇所に総売り上げに占める割合を計算するコードを記載します。

<!--省略-->
        <tbody>
            {% for row in summary %}
            <tr class="{% cycle 'row1' 'row2' %}">
                <td> {{ row.product__product_name }} </td>
                <td> {{ row.total }}個 </td>
                <td> {{ row.total_sales }}円 </td>
                <td>
                    <strong>
                        <!------------ ここから下を修正-------------->
                        {{ row.total_sales | default:0 | percentof:summary_total.total_sales }}
                        <!------------ ここまでを修正-------------->
                    </strong>
                </td>
            </tr>
            {% endfor %}
        </tbody>

以下のようなテンプレートタグを設定しています。

{{ row.total_sales | default:0 | percentof:summary_total.total_sales }}

row.total_salesは商品毎の合計金額で、値がなかった場合に0を表示するようにしています。
summary_total.total_salesが先ほどadmin.pyで追加した全商品の合計金額です。

percentofrow.total_salessummary_total.total_salesを使って全体割合を計算するDjangoのカスタムテンプレートタグです。

今からこのテンプレートタグを設定してきます。

まずは、カスタムテンプレートタグを利用するため以下の基本設定を行います。

  1. アプリ(dashboard)フォルダの直下にtemplatetagsディレクトリを作成する

  2. templatetagsディレクトリ内に空の__init__.pyファイルを作成する。

  3. templatetagsディレクトリ内にカスタムフィルタ・タグのファイル(mathtags.py)を作成する。

続いて作成したmathtags.pyに以下のコードを記載します。

from django import template

register = template.Library()  #Djangoテンプレートタグライブラリ

@register.filter()
def divide(n1, n2):
    try:
        return n1 / n2
    except (ZeroDivisionError, TypeError):
        return None

@register.filter()
def floor_divide(n1, n2):
    try:
        return n1 // n2
    except (ZeroDivisionError, TypeError):
        return None

@register.filter()
def multiply(n1, n2):
    try:
        return n1 * n2
    except TypeError:
        return None

@register.filter(name="percentof")
def percentof(value, args):
    percent = format((value / args) * 100 , '.0f') 
    return percent + "%"

上3つ(divide、fllor_divide,multiply)は計算エラーを回避するためのコードです。
以下が、総売り上げに占める割合を計算するテンプレートタグです。

@register.filter(name="percentof")
def percentof(value, args):
    percent = format((value / args) * 100 , '.0f') 
    return percent + "%"

以下のように「変数 | percentof: テンプレートタグに渡す引数」と記載することで、上記カスタムテンプレートタグのvaluerow.total_salesが、argsにsummary_total.total_salesが渡されます。
その後、formatの部分で割り算と掛算で全体割合を計算してreturnすることでテンプレート側に計算結果が返されます。

{{ row.total_sales | percentof:summary_total.total_sales }}

このままだと追加したカスタムテンプレートタグが利用できないので、sale_summary_change_list.htmlの冒頭に以下のコードを追加しましょう。

{% extends "admin/change_list.html" %}
{% load mathtags %}  →追加

設定が終わったので、admin画面を更新してみましょう。
以下のように総売上高に占める割合列に割合が表示されれば完成です。

次にテーブル表の一番下に合計を表示する行を追加します。

dashboard\templates\sale_summary_change_list.html</tbody>の下に以下のコードを追加します。

</tr>
{% endfor %}
</tbody>
<!--------- ここから下を追加 ----------->
<tr style="font-weight:bold; border-top:2px solid #4d4e50; background-color:rgb(174, 255, 174)">
<td> 合計 </td>
<td> {{ summary_total.total}}個 </td>
<td> {{ summary_total.total_sales | default:0 }}円 </td>
<td> 100% </td>
</tr>
<!--------- ここまでを追加 ----------->

summary_totalには売上データの合計数(total)と総売上金額(total_sales)が格納されているので、単純にこれを表示するだけです。
trタグにstyle属性を指定して色を変えています。

admin画面を更新すると以下のようになります。

次に、今の間だと金額部分が見ずらいのでカンマ表示に変更します。
Djangoのintcommaというテンプレートタグを使うと1,000のようにカンマ表示にできます。

intcommaを使うには、settings.pyINSTALLED_APPSdjango.contrib.humanizeを追加します。

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'dashboard.apps.DashboardConfig',
    'django.contrib.humanize',  #追加
]

また、settings.pyに以下のパラメータも設定します。

USE_THOUSAND_SEPARATOR = True
NUMBER_GROUPING = 3

次にdashboard\templates\sale_summary_change_list.htmlの冒頭に以下のコードを追加します。

{% extends "admin/change_list.html" %}
{% load mathtags %}
{% load humanize %}  ←追加する

これでhtml内でintcommaが使えるようになります。
カンマ表記にしたい部分にintcommaを設定します。

    <tbody>
        {% for row in summary %}
        <tr class="{% cycle 'row1' 'row2' %}">
          <td> {{ row.category__category_name }} </td>
          <td> {{ row.total | intcomma}}個 </td>
          <td> {{ row.total_sales | default:0 | intcomma}}円 </td>
          <!-- percentof:summary_total.total_sales-->
          <td><strong> {{ row.total_sales | default:0 | percentof:summary_total.total_sales }} </strong> </td>
        </tr>
        {% endfor %}
      </tbody>
      <tr style="font-weight:bold; border-top:2px solid #4d4e50; background-color:rgb(174, 255, 174)">
        <td> 合計 </td>
        <td> {{ summary_total.total| intcomma}}個 </td>
        <td> {{ summary_total.total_sales | default:0 | intcomma}}円 </td>
        <td> 100% </td>
    </tr>
  </table>

admin画面を更新し、以下のように金額がカンマ表記になればOKです。

以上で、以下のような売上分析画面が完成します。

以上でこのレシピは完了です。
お疲れさまでした!


主にITテクノロジー系に興味があります。 【現在興味があるもの】 python、Django,統計学、機械学習、ディープラーニングなど。 技術系ブログもやってます。 https://sinyblog.com/