Djangoで作る家計簿アプリ ⑥収入と支出の推移をグラフで表示する

Djangoで作る家計簿アプリシリーズの六つ目の記事です。
前回は月間の支出を確認するダッシュボードページを作りました。
今回は、月ごとの収入と支出の推移を確認するページを作成していきます。



最終的に見たいカテゴリだけ表示したり、ページ内でグラフを切り替えられるようにしていきます。

推移グラフをページに表示する


まずはURLパターンを追記して、ヘッダーにリンクを貼りましょう。

#kakeibo/urls.py

...
urlpatterns = [
    ...
    path('transition/', views.TransitionView.as_view(), name='transition'),
]


<!-- kakeibo/templates/kakeibo/base.html -->
...
<header class="page-header">
    <h1>
      <a href="{% url 'kakeibo:payment_list' %}" class="header-title">家計簿アプリ</a>
    </h1>
    <nav class="nav">
      <ul class="main-nav ml-5">
        ...
        <!-- 追加 -->
        <li class="ml-5">
          <a href="{% url 'kakeibo:transition' %}">収支推移</a>
        </li>
    </nav>
  </header>
...


次にplugin_plotly.pyを以下のようにします。

#kakeibo/plugin_plotly.py
import plotly.graph_objects as go
from .seaborn_collorpalette import sns_paired


class GraphGenerator:
    """ビューから呼び出されて、グラフをhtmlにして返す"""
    pie_line_color = '#000'
    plot_bg_color = 'rgb(255,255,255)'
    paper_bg_color = 'rgb(255,255,255)'
    month_bar_color = 'indianred'
    font_color = 'dimgray'
    color_palette = sns_paired()
    # 追加
    payment_color = 'tomato'
    income_color = 'forestgreen'

    ....
    # 追加   
    def transition_plot(self,
                        x_list_payment=None,
                        y_list_payment=None,
                        x_list_income=None,
                        y_list_income=None):
        """推移ページの複合グラフ"""
        fig = go.Figure()
        
        # 支出はラインプロット
        if x_list_payment and y_list_payment:
            fig.add_trace(go.Scatter(
                x=x_list_payment,
                y=y_list_payment,
                mode='lines',
                name='payment',
                opacity=0.5,
                line=dict(color=self.payment_color,
                          width=5, )
            ))

        # 収入はバープロット
        if x_list_income and y_list_income:
            fig.add_trace(go.Bar(
                x=x_list_income, y=y_list_income,
                name='income',
                marker_color=self.income_color,
                opacity=0.5,
            ))

        fig.update_layout(
            paper_bgcolor=self.paper_bg_color,
            plot_bgcolor=self.plot_bg_color,
            font=dict(size=14, color=self.font_color),
            margin=dict(
                autoexpand=True,
                l=0, r=0, b=20, t=30, ),
            yaxis=dict(
                showgrid=False,
                linewidth=1,
                rangemode='tozero'))
        fig.update_yaxes(visible=False, fixedrange=True)
        fig.update_yaxes(automargin=True)
        return fig.to_html(include_plotlyjs=False)


今回は収入は縦棒グラフ、支出を折れ線グラフで表すことにします。

ビューについては前回と同様にdjango-pandasを使ってモデルをpandasデータフレーム化して、処理をしています。
このあたりについては前回のエントリに書きました。
Djangoで作る家計簿アプリ ⑤月間支出ダッシュボードの作成、グラフの表示

#kakeibo/views.py
class TransitionView(generic.TemplateView):
    """月毎の収支推移"""
    template_name = 'kakeibo/transition.html'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        payment_queryset = Payment.objects.all()
        income_queryset = Income.objects.all()

        payment_df = read_frame(payment_queryset,
                                fieldnames=['date', 'price'])
        # 日付カラムをdatetime化して、Y-m表記に変換
        payment_df['date'] = pd.to_datetime(payment_df['date'])
        payment_df['month'] = payment_df['date'].dt.strftime('%Y-%m')
        # 月ごとにpivot集計
        payment_df = pd.pivot_table(payment_df, index='month', values='price', aggfunc=np.sum)
        # x軸
        months_payment = list(payment_df.index.values)
        # y軸
        payments = [y[0] for y in payment_df.values]

        # 収入も同様にx軸とy軸を作る
        income_df = read_frame(income_queryset,
                               fieldnames=['date', 'price'])
        income_df['date'] = pd.to_datetime(income_df['date'])
        income_df['month'] = income_df['date'].dt.strftime('%Y-%m')
        income_df = pd.pivot_table(income_df, index='month', values='price', aggfunc=np.sum)
        months_income = list(income_df.index.values)
        incomes = [y[0] for y in income_df.values]

        # グラフ生成
        gen = GraphGenerator()
        context['transition_plot'] = gen.transition_plot(x_list_payment=months_payment,
                                                   y_list_payment=payments,
                                                   x_list_income=months_income,
                                                   y_list_income=incomes)

        return context


今回は月ごとに金額をpivot集計しています。
なので日付のカラムを一旦年月表記にしています。
ここの部分ですね。

payment_df = read_frame(payment_queryset,
                          fieldnames=['date', 'price'])
# 日付カラムをdatetime化して、Y-m表記に変換
payment_df['date'] = pd.to_datetime(payment_df['date'])
payment_df['month'] = payment_df['date'].dt.strftime('%Y-%m')


テストプリントすると、このようにmonthのカラムが加わります。

        date  price    month
0 2019-04-01   4369  2019-04
1 2019-04-03    503  2019-04
2 2019-04-04    775  2019-04
3 2019-04-04    258  2019-04
4 2019-04-04    180  2019-04


あとはmonthのカラムでpivot集計すれば、月ごとの合計金額が出てきます。

payment_df = pd.pivot_table(payment_df, index='month', values='price', aggfunc=np.sum)


次にtransition.htmlを作成し、グラフのみ表示させてみましょう。

<!-- kakeibo/templates/kakeibo/transition.html -->
{% extends 'kakeibo/base.html' %}

{% block content %}

{% autoescape off %}
{{ transition_plot }}
{% endautoescape %}

{% endblock %}

{% block extrajs %}
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
{% endblock %}




このように収入が縦棒、支出が折れ線で表示されます。

グラフを絞り込めるようにする

今回は毎年6月と12月にボーナスが入る想定でテストデータを入れています。
そのため、突出して6月と12月だけ収入が多いです。

でも、家計簿なら、毎月の手取りと支出のバランスを見たいという場合もあると思います。
あるいは手取りに含める食費の金額がどんな感じなのか、とか、毎月外食にどれくらいお金を使っているか、等も知りたい所です。

なので、以下のような仕様にしていきます。

  • 支出のみ、収入のみのグラフを表示できるようにする
  • 支出、収入それぞれにおいてカテゴリを単一で表示できるようにする


まずforms.pyに絞り込み用のフォームを追記していきます。

#kakeibo/forms.py
from django import forms
from .models import PaymentCategory, Payment, Income, IncomeCategory  # add
from django.utils import timezone
from .widgets import CustomRadioSelect

...

class TransitionGraphSearchForm(forms.Form):
    """推移グラフの絞り込みフォーム"""
    SHOW_CHOICES = (
        ('Payment', 'Payment'),
        ('Income', 'Income'),
    )

    payment_category = forms.ModelChoiceField(
        label='支出カテゴリでの絞り込み',
        required=False,
        queryset=PaymentCategory.objects.order_by('name'),
        widget=CustomRadioSelect,
    )

    income_category = forms.ModelChoiceField(
        label='収入カテゴリでの絞り込み',
        required=False,
        queryset=IncomeCategory.objects.order_by('name'),
        widget=CustomRadioSelect,
    )

    graph_visible = forms.ChoiceField(required=False,
                                      label='表示グラフ',
                                      choices=SHOW_CHOICES,
                                      widget=CustomRadioSelect
                                      )


以前に書いた「Djangoで作る家計簿アプリ ③検索とページネーションの実装」で使用したカスタムラジオボックスを使っていきます。
次に作成したformを読み込み、ビューを編集します。

#kakeibo/views.py
...
from .forms import PaymentSearchForm, IncomeSearchForm, PaymentCreateForm, IncomeCreateForm, TransitionGraphSearchForm # add 

...

class TransitionView(generic.TemplateView):
    """月毎の収支推移"""
    template_name = 'kakeibo/transition.html'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        payment_queryset = Payment.objects.all()
        income_queryset = Income.objects.all()
        self.form = form = TransitionGraphSearchForm(self.request.GET or None)
        context['search_form'] = self.form

        graph_visible = None
        # plotlyに渡すデータ
        months_payment = None
        payments = None
        months_income = None
        incomes = None

        if form.is_valid():
            # カテゴリーで絞り込む
            payment_category = form.cleaned_data.get('payment_category')
            if payment_category:
                payment_queryset = payment_queryset.filter(category=payment_category)
            income_category = form.cleaned_data.get('income_category')
            if income_category:
                income_queryset = income_queryset.filter(category=income_category)

            # 表示するをグラフ
            graph_visible = form.cleaned_data.get('graph_visible')

        # グラフ表示指定がない、もしくは支出グラフ表示が選択
        if not graph_visible or graph_visible == 'Payment':
            payment_df = read_frame(payment_queryset,
                                    fieldnames=['date', 'price'])
            payment_df['date'] = pd.to_datetime(payment_df['date'])
            payment_df['month'] = payment_df['date'].dt.strftime('%Y-%m')
            payment_df = pd.pivot_table(payment_df, index='month', values='price', aggfunc=np.sum)
            months_payment = list(payment_df.index.values)
            payments = [y[0] for y in payment_df.values]

        # グラフ表示指定がない、もしくは収入グラフ表示が選択
        if not graph_visible or graph_visible == 'Income':
            income_df = read_frame(income_queryset,
                                   fieldnames=['date', 'price'])
            income_df['date'] = pd.to_datetime(income_df['date'])
            income_df['month'] = income_df['date'].dt.strftime('%Y-%m')
            income_df = pd.pivot_table(income_df, index='month', values='price', aggfunc=np.sum)
            months_income = list(income_df.index.values)
            incomes = [y[0] for y in income_df.values]

        gen = GraphGenerator()
        context['transition_plot'] = gen.transition_plot(x_list_payment=months_payment,
                                                   y_list_payment=payments,
                                                   x_list_income=months_income,
                                                   y_list_income=incomes)

        return context


基本的には検索が実行されたらクエリを絞り込んで、データフレーム化、グラフを生成という流れです。

ただ、今回は表示の切り替えがあるので、plotlyに渡すデータ(支出と収入のx軸、y軸)が担保されていません。
なので、一旦、それぞれの変数においてNoneをあてています。

# plotlyに渡すデータ
months_payment = None
payments = None
months_income = None
incomes = None


plugin_plotly.py内では以下のようにしていました。

# 支出はラインプロット
if x_list_payment and y_list_payment:
    fig.add_trace(go.Scatter(
        x=x_list_payment,
        y=y_list_payment,
        mode='lines',
        name='payment',
        opacity=0.5,
        line=dict(color=self.payment_color,
                  width=5, )
    ))

# 収入はバープロット
if x_list_income and y_list_income:
    fig.add_trace(go.Bar(
        x=x_list_income, y=y_list_income,
        name='income',
        marker_color=self.income_color,
        opacity=0.5,
    ))


どちらか一方のグラフを表示するとしていた場合は、選択されなかった方のx_listとy_listにはNoneがあたり、False評価となるのでグラフが生成されません。

テンプレートに検索フォームを追記しましょう。
あと、JavaScriptも追記しています。

<!-- kakeibo/templates/kakeibo/transition.html -->
{% extends 'kakeibo/base.html' %}

{% block content %}
<form id="search-form" action="" method="GET">
  <h2 class="section-title">表示グラフ</h2>
  <div class="mt-2">
    {{ search_form.graph_visible }}
  </div>
  <!-- 表示グラフが収入の時は表示しない -->
  {% if search_form.cleaned_data.graph_visible != 'Income' %}
  <h2 class=" mt-4 section-title">支出カテゴリ</h2>
  <div class="mt-2">
    {{ search_form.payment_category }}
  </div>
  {% endif %}
  <!-- 表示グラフが支出の時は表示しない -->
  {% if search_form.cleaned_data.graph_visible != 'Payment' %}
  <h2 class=" mt-4 section-title">収入カテゴリ</h2>
  <div class="mt-2" style="padding-bottom:10px;">
    {{ search_form.income_category }}
  </div>
  {% endif %}
</form>

{% autoescape off %}
{{ transition_plot }}
{% endautoescape %}

{% endblock %}
{% block extrajs %}
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<script type="text/javascript">
  document.addEventListener('DOMContentLoaded', e => {
    const searchForm = document.getElementById('search-form');

    // 支出カテゴリがクリックされたら送信
    for (const check of document.getElementsByName('payment_category')) {
      check.addEventListener('change', () => {
        searchForm.submit();
      });
    }
  // 収入カテゴリがクリックされたら送信
    for (const check of document.getElementsByName('income_category')) {
      check.addEventListener('change', () => {
        searchForm.submit();
      });
    }

    // グラフ表示条件がクリックされたら送信
    for (const check of document.getElementsByName('graph_visible')) {
      check.addEventListener('change', () => {
        // 支出グラフが選択されたら、収入カテゴリのチェックは外して送信
        if (check.value == 'Payment') {
          for (const radio of document.getElementsByName('income_category')) {
            if (radio.checked) {
              radio.checked = false;
            }
          }
        //収入グラフが選択されたら、支出カテゴリのチェックは外して送信
        } else {
          for (const radio of document.getElementsByName('payment_category')) {
            if (radio.checked) {
              radio.checked = false
            }
          }
        }
        searchForm.submit();
      })
    }

    // 選択済みのボタンがクリックされたら解除して送信
    const selectedPaymentCategory = document.querySelector(`input[name='payment_category']:checked`)
    if (selectedPaymentCategory) {
      selectedPaymentCategory.onclick = () => {
        selectedPaymentCategory.checked = false
        searchForm.submit();
      }
    }

    const selectedIncomeCategory = document.querySelector(`input[name='income_category']:checked`)
    if (selectedIncomeCategory) {
      selectedIncomeCategory.onclick = () => {
        selectedIncomeCategory.checked = false
        searchForm.submit();
      }
    }

    const selectedGraphVisible = document.querySelector(`input[name='graph_visible']:checked`)
    if (selectedGraphVisible) {
      selectedGraphVisible.onclick = () => {
        selectedGraphVisible.checked = false
        searchForm.submit();
      }
    }
  });
</script>
{% endblock %}


styleも1件だけ追加します。

/* kakeibo/static/kakeibo/css/style.css */

/* --------------------------------
 * 収支推移ページ
 * -------------------------------- */
.section-title {
  font-size: 1.4rem;
  color: #888;
}





このような見た目になりました。
収入カテゴリの給料を選択しますと…



給料だけの表示になるので、毎月の収支のイメージがつきやすくなります。
さらに支出を食費に絞ってみましょう。



表示グラフを選択しますと、単一で表示されます。



支出を選んだ場合です。
その際、収入のカテゴリは表示しないようにしています。

  <!-- 表示グラフが支出の時は表示しない -->
  {% if search_form.cleaned_data.graph_visible != 'Payment' %}
  <h2 class=" mt-4 section-title">収入カテゴリ</h2>
  <div class="inline mt-2" style="padding-bottom:10px;">
    {{ search_form.income_category }}
  </div>
  {% endif %}


ここの部分ですね。

TOPページ