2008年9月6日土曜日

Google App Engine でダイエット表の作成 (3)

追記(2008.9.6) : ダイエット表を作成するには → http://4diet.appspot.com/


Google App Engine でダイエット表の作成 (2)」の続き。前回、 HTML でダイエット表におけるマス目を作成し、CSS で装飾をするための土台を作るクラス HtmlTable を作成した。今回は、その HtmlTable を利用してダイエット表の作成を主導するクラス DietTable を作成する。

 

DietTable クラス

このクラスはダイエット表の書き方について知っている。 例えば、表の大きさは「行数○○、列数××」で、土日の日付に相当する列は色をつけるとか、ユーザーの設定した「現在の体重」「目標体重」を表のどこそこに書くなど、具体的なダイエット表の書き方を指示する。しかし、ダイエット表を作るのに際し、「どのように HTML の table 要素に変換するのか」に関しては全く関知しない。専ら「ダイエット表を描くにはどうすればいいのか」ということに関心があり、それに基づき HtmlTable クラスに指示を出す。

 

Diet クラス

080903-001ところで、ユーザがフォームで入力する情報は次の通り。

  • 年月
  • 現在の体重
  • 目標体重

フォームでこれらの情報が送信されると、 Diet クラスがこれを受け取ることにした。 Diet クラスの役割は、入力された情報の妥当性を検討することが主な役割だ。次のようなチェックを行う。

  • 基本的なチェック
    • 必要な情報が入力されているか?
    • 不適切な値が入力されていないか?
  • ダイエット表を描く際のルールのチェック
    • 「目標体重」が「現在の体重」よりも重く設定されてないか?
    • 表の大きさから考えて、「目標体重」が「現在の体重」より離れすぎていないか?

 

後の追加機能

後で追加した機能だけれど、入力される「年月」のデフォルトの値を計算するのものこのクラスの役割。 ダイエット表が対象とする月は、作成しようとしている月であることが多いと考えられる。現在において、先月のダイエット表を作るのはナンセンスだし、また、月初めに来月のダイエット表を作るのは気が早い。そこで、ユーザが設定する画面において、作成しようとしている日の「年月」をデフォルトで表示させるようにした。ただし、翌月まで後 3 日となった日付の時点で、翌月の年月が設定される。これは翌月の少し前に前もって表を作成することがあると想定したため。 (cf. Python で指定した日数後の日付を得る)

 

ソースコード

以下にコードを示す。

Diet.py

import HtmlTable
import calendar
import logging
from decimal import Decimal

class Diet:
    """ ダイエットのための設定情報を表わすクラス """
    def __init__(self, weight="", target="", year="", month=""):
        self.weight = weight        # 現在の体重
        self.target = target        # 目標体重
        self.year = year            # 年
        self.month = month          # 月
        self.validated = False
        self.dietTable = None

    def setDefaultSetting(self):
        """ 年月のデフォルトの値を設定する

        今日の日付の年月を設定。
        ただし、月の末日より3日以内であれば翌月の値に設定する
        """
        from datetime import date, timedelta
        CAHANGE_PERIOD = 3
        
        today = date.today()
        if self._lastDayOfMonth(today) - CAHANGE_PERIOD > today.day:
            self.year = today.year
            self.month = today.month
        else:
            next = today + timedelta(days=CAHANGE_PERIOD)
            self.year = next.year
            self.month = next.month
        return self
            
    def _lastDayOfMonth(self, date):
        u""" 指定された日付の月の最後の日を返す """
        return calendar.monthrange(date.year, date.month)[1]
        
    def dietTable(self, dt):
        """ DietTable オブジェクトを設定する """
        self.dietTable = dt
        
    def validate(self):
        """ 設定された値の妥当性を検証する """
        if self.dietTable == None: raise DietTableNotSetErr
        
        errors = []     # エラーがあった場合にメッセージを入れるリスト

        self._validateYearMonth(errors)
        try:
            self._validateWeight(errors)
            if errors: raise DietValErr(errors)
        except DietTableErr:
            if errors: raise DietValErr(errors, self._targetLowerLimit())
        self.validated = True
        
    def _validateYearMonth(self, errors):
        """ 「年」「月」が数値に変換できることを確認する """
        if self.year.isdigit(): self.year = int(self.year)
        else: errors.append("「年」を数字で入力してください。")

        if self.month.isdigit(): self.month = int(self.month)
        else: errors.append("「月」を数字で入力してください。")

        if not 1 <= self.month <= 12:
            errors.append("「月」には 1 ~ 12 の数字を入力してください。")

    def _validateWeightFormat(self, errors):
        """ 「現在の体重」「目標体重」が数値に変換できることを確認する。 """
        validWeightFormat = self._validateWeightNowFormat(errors)
        validTargetFormat = self._validateTargetFormat(errors)
        
        if validWeightFormat and validTargetFormat:
            return True
        else:
            return False
    
    def _validateWeightNowFormat(self, errors):
        """ 「現在の体重」が数値に変換できることを確認する。

        数値に変換できることが確認できたら、小数点第1位までを
        Decimal 型に変換する。
        """
        try:
            self.weight = Decimal(str(round(float(self.weight), 1)))
            return True
        except ValueError:
            errors.append("「現在の体重」を半角数字で入力してください。")
            return False
        
    def _validateTargetFormat(self, errors):
        """ 「目標体重」が数値に変換できることを確認する。 """
        try:
            self.target = Decimal(str(round(float(self.target), 1)))
            return  True
        except ValueError:
            errors.append("「目標の体重」を半角数字で入力してください。")
            return False
        
    def _validateWeight(self, errors):
        # 体重の数値が設定されていることを確認する
        if self._validateWeightFormat(errors):
            # 「目標体重」が「現在の体重」より軽い値であることを確認する
            if self.target >= self.weight:
                errors.append("目標の体重は現在の体重よりも軽く設定してください。")
            # 「目標体重」が表に表示することができる限界を超えていないことを確認する
            try:
                self.dietTable.validate()
            except DietTableErr:
                errors.append("「目標体重」が低すぎます。"); raise
            
    def dayslist(self, dayOftheWeek):
        """ このオブジェクトの年月における指定された曜日の日付のリストを返す """
        return [x[dayOftheWeek]
            for x in calendar.monthcalendar(self.year, self.month)]

    def _targetLowerLimit(self):
        """ 「目標体重」に設定できる下限の値を返す """
        return self.weight - self.dietTable.intervalLimit()

class DietValErr(Exception):
    def __init__(self, errorMessages, targetLowerLimit=None):
        self.errorMessages = errorMessages
        self.targetLowerLimit = targetLowerLimit

 

余談

後で気がついたが、このクラスではバグを誘発するコーディングをしていた。それについては、こちらを参照。このことを考えると、フォームの基本的な内容を検証すること、つまり、必須入力のチェック、フォーマットの妥当性のチェックは別クラスにする、 Struts の フォームBean のようなものを作成した方がよかったかもしれない。コンストラクタにおいて、インスタンス変数に代入を想定している型以外の型を受けとり、一時的に設定しまったのはよくなかった。  パタッ(o_ _)o~†

 

Diet と DietTable クラスの協調

さて、話を元に戻し、先ほど Diet クラスのルールのチェックの中で挙げた最後のルール

表の大きさから考えて、「目標体重」が「現在の体重」より離れすぎていないか?

は、フォームで入力された情報からだけでは妥当であるか判断することができない。つまり、Diet クラスが持っている情報からだけでは決まらない。 ではどうするか? DietTable クラスが持っている情報と照らし合わせる必要がある。どういうことかと言えば、描ける表の大きさには限界がある。そのため、ユーザに入力された値は常に作成する表の制約に従っているか確認しなければならない。

具体的には次のようなことになる。現在、ダイエット表の縦軸一目盛りは 0.1 kg で固定して考えている。 A4 の用紙一枚に表を描くので、おおよそ「0.1 kg × 行数」 が「現在の体重」と「目標体重」の差の上限。設定画面の「目標体重」を、「現在の体重」から見て表の大きさの限界を超えた値には設定できないようにしなければならない。

ところで、実際に今使っている、表計算ソフトで作ったダイエット表は次のような形をしていた。

080830-001

 

緑色の線で描いている「今月の目標ライン」というがある。このラインは、毎回大体この辺りにくるように体重の値を決めている。表の最下段から数えて 6 ~ 10 段ぐらい上、体重で言うと 0.6 ~ 1.0 kg の余裕を持たせるような位置。なぜこの位置かと言えば、目標の体重を下回ったとしてもグラフとしてキチンと記録できるようにするため。せっかく目標を超えたのにちゃんと記録ができないとモチベーションが下ってしまう。 (+_+) 

ところで、上記のようなルールに基いて「目標体重」を設定しているということは、「目標となるライン」が表の中でどのに辺りに位置するかによって、「現在の体重」と「目標体重」の差の上限が決まるということ。この「目標体重の位置をどうするか」という情報はどこで管理すべきかだろうか? HTML を作成することに直接関係ないし、また、フォームに入力された情報にも直接関係ない。純粋に「表をどのように使うか」というルールに相当するので、DietTable クラスが管理するのが相応しい。

 

DietTable の役割

HtmlTable クラスを使い、表のルールを適用しながらダイエット表を描く方法を知っている  DietTable クラス。このクラスでは、上記のように Diet クラスと協調して入力を検証する以外に、以下のことをすると想定した。

  • 土日になる日付の列は、それぞれ青・赤となるようにする。
  • 設定された「現在の体重」と「目標体重」をグラフの縦軸に書き込む。
  • 日付を 5 日ごとに書きこむ。
  • 1.0 kg ごとに横線を太くする。また、 0.5 kg ごとに点線にする。
  • 10 日ごとに縦方向の線を太くする。また、5 日ごとに点線にする。

 

全体図

DietTable クラスのソースコードを示す前に、モジュールの全体を示す。 図の中に示した SettingPage, TablePata クラスについてはこれまで説明に出てこなかったが、これらはコントローラに相当し、 webapp.RequestHandler のサブクラス。ピンクの四角で囲ったところにある「リクエストスコープ」という言葉を使うのかよく知らないけれど、これはセッションにまたがって存在しないオブジェクト。

青い点線はユーザから見たアプリに対する視点。赤い点線はエラー処理の場合。黒い点線は、画面遷移のときに関わるコントローラ、ビューの移行を表わす。黒線は、矢印の先のオブジェクトを参照・利用していることを示す。

080905-008

 

ソースコード

Diet.py

class DietTable:
    u""" ダイエット情報 (Diet) からHTML の table 要素を生成するクラス
    
    表の書き方のルール :
        (1)縦軸を体重、横軸を日付とする
        (2)表の最左列には「現在の体重」と「目標体重」を記入する。
        (3)表の最下列には日付のために空けておく。
        (4)「目標体重」は表の体重の下限の行から数えて 6 行上にくるようにする。
            この位置は固定。これにより目標体重よりもダイエットが成功したときに
            体重の記入ができる。
    """
    def __init__(self):
        self.row = 48                       # 行数
        self.col = 33                       # 列数
        self.weightPerRow = Decimal("0.1")  # 表における 1 行当たりの重さ (kg)

        # 「目標体重」の位置。表の最上段の線を 0 として下方向へ数える。
        # この位置から現在値を見て、表の中に入ることができるか確認することが必要
        self.targetPosition = self.row - 8
        
        self.diet = None

    def setDiet(self, diet):
        self.diet = diet                    # ダイエット情報
        self.diet.dietTable = self          # diet オブジェクトの自身を設定する
        
    def createHtml(self):
        u""" HTML のテーブルを作成する
        """
        if not self.diet.validated: raise DietNotValidatedErr
        if self.diet == None: raise DietNotSetErr
    
        self.htmltb = HtmlTable.HtmlTable(self.row, self.col)
        # テーブルの内容を設定する
        self._setHtmlTableContents()
        # HTML テーブルをカスタマイズする
        self._addCssToHtmlTable()

    def _setHtmlTableContents(self):
        # 1 kg おきに横軸に数値を記入する
        for i,e in enumerate(reversed(list(self._horizontalLineWeights()))):
            if e % 1 == 0:
                self.htmltb.setCell(i, 0, e)
        
        # 5 日おきに日付を書き込む
        for i in range(5,31,5):
            self.htmltb.setCell(self.row-1, i, i)
                
        #--- テーブルの内容を設定する ---
        # セルの設定は上書きのため、CSS の設定に先行する必要がある。
        # TODO diet_table.html の CSS において、値を動的に生成する。
        self.htmltb.setCell(self.targetPosition, 0, self.diet.target)
        self.htmltb.addCssToCell(self.targetPosition, 0, "target")
        
        self.htmltb.setCell(self._weightPosition(), 0, self.diet.weight)
        self.htmltb.addCssToCell(self._weightPosition(), 0, "weightNow")

    def _addCssToHtmlTable(self):
        #--- HTML テーブルをカスタマイズする ---
        # 土日のセルの背景色を設定する。
        # @@@
        for i in self.diet.dayslist(calendar.SUNDAY):
            if i == 0: continue
            self.htmltb.addCssToCol(i, "sunday")
        for i in self.diet.dayslist(calendar.SATURDAY):
            if i == 0 : continue
            self.htmltb.addCssToCol(i, "saturday")
        # 軸の線を太くする
        self.htmltb.addCssToRow(self.row-1, "x_axis")
        self.htmltb.addCssToCol(0, "y_axis")

        # 10 日おきに縦軸を太くする
        for i in [10, 20, 30]:
            self.htmltb.addCssToCol(i, "y_axis_10")
        # 5がつく日の縦軸を太くする
        for i in [5, 15, 25]:
            self.htmltb.addCssToCol(i, "y_axis_05")
            
        # 0.5 kg おきに横軸を太くする
        for i,e in enumerate(reversed(list(self._horizontalLineWeights()))):
            if e % Decimal("0.5") == 0 and not e % Decimal("1.0") == 0:
                self.htmltb.addCssToRow(i+1, "x_axis_05")
        # 1 kg おきに横軸を太くする
        for i,e in enumerate(reversed(list(self._horizontalLineWeights()))):
            if e % Decimal("1.0") == 0:
                self.htmltb.addCssToRow(i+1, "x_axis_1")

    def _horizontalLineWeights(self):
        """ 横軸の体重の値を下限から返すジェネレータ """
        for i in range(0, self.row-1):
            yield Decimal(str(round(
                self._lowerLimit() + self.weightPerRow * i, 1)))

    def year(self):
        u""" 対象の「年」を返す """
        return self.diet.year
    def month(self):
        u""" 対象の「月」を返す """
        return self.diet.month
    
    def validate(self):
        u""" 設定された値で表を描くことができるか確認する """
        if self.diet == None: raise DietNotSetErr
        if self._upperLimit() < self.diet.weight:
            raise DietTableErr
            
    def _upperLimit(self):
        u""" 「目標体重」から見て、上限となる「現在の体重」の値を返す

            ※ 表の一番上の枠線の値ではなくて、2番目の線が上限としていることに注意。
        """
        return self.diet.target + self.targetPosition * self.weightPerRow
    
    def _lowerLimit(self):
        u""" 表における下限の値を返す """
        return self.diet.target - \
            (self.row - self.targetPosition - 2) * self.weightPerRow

    def _weightPosition(self):
        u""" 「目標体重」から「現在の体重」の位置を計算する """
        return self.targetPosition - \
            (Decimal(str(self.diet.weight)) - Decimal(str(self.diet.target))) \
            / Decimal(str(self.weightPerRow))
        
    def intervalLimit(self):
        u""" 現在の体重と目標に設定できる体重の差の上限 """
        return self.targetPosition * self.weightPerRow
        
    def toHtml(self):
        return self.htmltb.toHtml()

class DietTableErr(Exception):
    pass
class DietNotValidatedErr(Exception):
    """ ダイエット情報の検証が終っていないことを表わす """
    def __str__(self):
        return "Diet が未検証です"
class DietNotSetErr(Exception):
    """ ダイエット情報が設定されてないことを表わす """
    def __str__(self):
        return "Diet が設定されていません。"
class DietTableNotSetErr(Exception):
    """ ダイエット表が設定されてないことを表わす """
    def __str__(self):
        return "DietTable が設定されていません。"

このクラス、ユニットテスト全然書いてない… ^^; 後で簡単に変更できるように最低限何か書いておかねば。

 

失敗談

小数の計算はちゃんとしよう。 パタッ(o_ _)o~†  (cf. Python で正確な小数の計算をする)


関連記事