【Python】FullCalendarとBootstrapで詳細な予定を登録できるようにする

2018年6月24日Javascript,Python,開発

おはようございます。

前回に引き続き、
FullCalendarとMySQLとの連携ですが
今回は Bootstrap を使って入力ダイアログを表示し、少し詳細な予定を登録できるようにしていきます。

プログラムは前回のを流用します。

スポンサーリンク

データベース変更

新たに、予定の詳細用にカラムを追加します。

ALTER TABLE TBL_SCHEDULE ADD COLUMN (
        DESCRIPTION VARCHAR(1000)
)

画面の修正

変更点

1. 予定の入力フォームを Bootstrapのダイアログで作成
2. 日付、時刻の入力にBootstrap DateTimePicker、moment.jsを利用
3. Javascriptを外部ファイルにする

Bootstrap、DateTimePicker用のライブラリをCDNから読み込み、
(普段は非表示の)予定入力モーダルダイアログを追加。

index.html

    <!DOCTYPE html>
    <html>
       <head>
          <title>カレンダーサンプル</title>
          <link rel="stylesheet" href="{{ static_url('css/fullcalendar.min.css') }}"/>
          <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
          <link rel="stylesheet" href="{{ static_url('css/style.css') }}"/>
          <link rel="stylesheet" href="http://cdn.rawgit.com/Eonasdan/bootstrap-datetimepicker/v4.0.0/build/css/bootstrap-datetimepicker.css">
          <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
          <script type="text/javascript" src="https://stackpath.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
          <script src="http://cdnjs.cloudflare.com/ajax/libs/moment.js/2.9.0/moment-with-locales.js"></script>
          <script type="text/javascript" src="{{ static_url('js/moment.min.js') }}"></script>
          <script type="text/javascript" src="{{ static_url('js/fullcalendar.min.js') }}"></script>
          <script type="text/javascript" src="{{ static_url('lang/ja.js') }}"></script>
          <script src="http://cdn.rawgit.com/Eonasdan/bootstrap-datetimepicker/v4.0.0/src/js/bootstrap-datetimepicker.js"></script>
          <script type="text/javascript" src="{{ static_url('js/script.js') }}"></script>
          <script>
             // ページ読み込み時の処理
             $(document).ready(function () {
                initializePage();
             });
          </script>
       </head>
       <body>
          <div id='calendar'></div>
          <div id="inputScheduleForm" class="modal fade" tabindex="-1">
             <div class="modal-dialog modal-nm">
                <div class="modal-content">
                   <div class="modal-header">
                      <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
                      <h4 class="modal-title">スケジュール登録</h4>
                   </div>
                   <div class="modal-body">
                      <div class="container">
                         <div class="row">
                            <div  class="col-md-2">
                               <label for="">タイトル</label>
                            </div>
                            <div class="col-md-5">
                               <input id="inputTitle" type="text" class="form-control input-sm ime-active" placeholder="タイトル" value="" >
                            </div>
                         </div>
                         <div class="row">
                            <div  class="col-md-2 required">
                               <label for="">日時</label>
                            </div>
                            <div class="col-md-5">
                               <div class="input-group">
                                  <div class="checkbox" style="margin-top: 0px;">
                                     <input type="checkbox" id="allDayCheck" checked/><label for="allDayCheck">終日</label>
                                  </div>
                               </div>
                               <div class="form-inline">
                                  <div class="form-group" style="position:relative;">
                                     <input id="inputYmdFrom" type="text" class="form-control input-sm ymd" value=""/>
                                     <input id="inputYmdHmFrom" type="text" class="form-control input-sm ymdHm"/> ~
                                     <input id="inputYmdTo" type="text" class="form-control input-sm ymd" />
                                     <input id="inputYmdHmTo" type="text" class="form-control input-sm ymdHm" />
                                  </div>
                               </div>
                            </div>
                         </div>
                         <div class="row">
                            <div  class="col-md-2">
                               <label for="">詳細</label>
                            </div>
                            <div class="col-md-5">
                               <textarea id="inputDescription" class="form-control ime-active" rows="5" placeholder="詳細"></textarea>
                            </div>
                         </div>
                      </div>
                   </div>
                   <div class="modal-footer">
                      <div id="inputError" class="pull-left" style="color:red; padding:5px;"></div>
                      <button type="button" class="btn btn-primary">登録</button>
                      <button type="button" class="btn btn-default" data-dismiss="modal">閉じる</button>
                   </div>
                </div>
             </div>
          </div>
       </body>
    </html>

入力フォーム用にスタイル定義を追加。
style.css

    /**
     * Bootstrapカスタマイズ
     */
    
    /* テキストIME */
    input {
       ime-mode: disabled;
    }
    input.ime-active {
       ime-mode: active;
    }
    input.ime-inactive {
       ime-mode: inactive;
    }
    
    /* モーダルダイアログ */
    .panel-body > .container > .row, .modal-body > .container > .row {
        margin: 10px;
    }
    
    .container > .row > div.required label:after {
        content:"*";color:red;
    }
    
    .form-inline > .form-group > input.ymd {
        width: 110px;
    }
    .form-inline > .form-group > input.ymdHm {
        width: 160px;
    }
    .hidden {
        visibility: hidden;
        display: none;
    }
    
    /* チェックボックス */
    [type="checkbox"]:checked,
    [type="checkbox"]:not(:checked) {
       position: absolute;
       left: -9999px;
    }
    [type="checkbox"]:checked + label,
    [type="checkbox"]:not(:checked) + label {
       font-weight: normal;
       position: relative;
       padding-left: 24px;
       cursor: pointer;
       line-height: 18px;
       display: inline-block;
       color: #666;
    }
    [type="checkbox"]:checked + label:before,
    [type="checkbox"]:not(:checked) + label:before {
       content: '';
       position: absolute;
       left: 0;
       top: 0;
       width: 20px;
       height: 20px;
       border: 1px solid #ddd;
       background: #fff;
    }
    [type="checkbox"]:focus + label:before,
    [type="checkbox"]:active + label:before{
        content: '';
        position: absolute;
        width: 20px;
        height: 20px;
        left: 0;
        top: 0;
        border: 1px solid rgba(128, 128, 128, 1);
        box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.075) inset, 0px 0px 8px rgba(128, 128, 128, 1);
        background: #fff;
    }
    
    [type="checkbox"]:checked + label:after,
    [type="checkbox"]:not(:checked) + label:after {
       content: '';
       width: 8px;
       height: 12px;
       background: #ffffff;
       border-right: 3px solid #808080;
       border-bottom: 3px solid #808080;
       position: absolute;
       top: 2px;
       left: 6px;
       -webkit-transition: all 0.2s ease;
       transition: all 0.2s ease;
    }
    [type="checkbox"]:not(:checked) + label:after {
       opacity: 0;
       -webkit-transform: rotate(45deg);
       -ms-transform: rotate(45deg);
       transform: rotate(45deg);
    }
    [type="checkbox"]:checked + label:after {
       opacity: 1;
       -webkit-transform: rotate(45deg);
       -ms-transform: rotate(45deg);
       transform: rotate(45deg);
    }

プログラムの修正

クライアント側プログラム(JavaScript)

ちょっと日付まわりとか色々とごにょごにょしていますが、そのうち綺麗に整理します。

script.js

/**
 * ページ初期処理.
 */
function initializePage() {
    // カレンダーの設定
    $('#calendar').fullCalendar({
        height: 550,
        lang: "ja",
        header: {
            left: 'prev,next today',
            center: 'title',
            right: 'month,basicWeek,basicDay'
        },
        timeFormat: 'HH:mm',
        selectable: true,
        selectHelper: true,
        navLinks: true,
        eventSources: [{
            url: 'http://localhost:8080/getCalendar',
            dataType: 'json',
            async: false,
            type : 'GET',
            error: function() {
                $('#script-warning').show();
            }
        }],
        select: function(start, end, resource) {
        // 日付選択された際のイベント

            // タイトル初期化
            $("#inputTitle").val("");
            $('#inputScheduleForm').on('show.bs.modal', function (event) {
                setTimeout(function(){
                    $('#inputTitle').focus();
                }, 500);
            }).modal("show");

            // 日付ピッカーの設定
            $('.ymdHm').hide()
            $('#inputYmdFrom').datetimepicker({locale: 'ja', format : 'YYYY年MM月DD日', useCurrent: false });
            $('#inputYmdTo').datetimepicker({locale: 'ja', format : 'YYYY年MM月DD日', useCurrent: false });
            $('.ymdHm').datetimepicker({
                locale: 'ja',
                format : 'YYYY年MM月DD日 HH時mm分'
            });

            // 開始終了が逆転しないように制御
            $("#inputYmdFrom").on("dp.change", function (e) {
                $('#inputYmdTo').data("DateTimePicker").minDate(e.date);
            });
            $("#inputYmdTo").on("dp.change", function (e) {
                $('#inputYmdFrom').data("DateTimePicker").maxDate(e.date);
            });

            // 終日チェックボックス
            $('#allDayCheck').prop("checked", true);

            // 選択された日付をフォームにセット
            // FullCalendar の仕様で、終了が翌日の00:00になるため小細工
            var startYmd = moment(start);
            var endYmd = moment(end);
            if (endYmd.diff(startYmd, 'days') > 1) {
                endYmd = endYmd.add(-1, "days");
            } else {
                endYmd = startYmd;
            }
            $('#inputYmdFrom').val(startYmd.format("YYYY年MM月DD日"));
            $('#inputYmdFrom').data("DateTimePicker").date(startYmd.format("YYYY年MM月DD日"));
            $('#inputYmdTo').val(endYmd.format("YYYY年MM月DD日"));
            $('#inputYmdTo').data("DateTimePicker").date(endYmd.format("YYYY年MM月DD日"));
        },
        eventClick: function(event) {
        // 予定クリック時のイベント
            console.log(event);
        },
        editable: true,
        eventLimit: true
    });
}

/**
 * 予定入力フォームの登録ボタンクリックイベント.
 */
function registSchedule() {

    var startYmd = moment(formatNengappi($('#inputYmdFrom').val() + "00時00分00", 1));
    var endYmd = moment(formatNengappi($('#inputYmdTo').val() + "00時00分00", 1));
    var allDayCheck = $('#allDayCheck').prop("checked");
    if (!allDayCheck) {
        startYmd = moment(formatNengappi($('#inputYmdHmFrom').val() + "00", 1));
        endYmd = moment(formatNengappi($('#inputYmdHmTo').val() + "00", 1));
    }
    if (endYmd.diff(startYmd, 'days') > 0) {
        endYmd = endYmd.add(+1, "days");
    }

    var eventData;
    if ($('#inputTitle').val()) {
        eventData = {
            title: $('#inputTitle').val(),
            start: startYmd.format("YYYY-MM-DDTHH:mm:ss"),
            end: endYmd.format("YYYY-MM-DDTHH:mm:ss"),
            allDay: allDayCheck,
            description: $('#inputDescription').val()
        };
        $.ajax({
            url: "http://localhost:8080/regist",
            type: "POST",
            data: JSON.stringify(eventData),
            success: function(jsonResponse) {
                $('#calendar').fullCalendar('renderEvent', jsonResponse, true);
                alert("予定を登録しました。");
            },
            error: function() {
            }
        });
    }
    $('#calendar').fullCalendar('unselect');
}

/**
 * 終日チェックボックスクリックイベント.
 *
 */
function allDayCheckClick(element) {
    if (element && element.checked) {
        $('.ymdHm').hide();
        $('.ymd').show();
    }
    else {
        var startYmd = moment(formatNengappi($("#inputYmdFrom").val(), 0));
        var endYmd = moment(formatNengappi($("#inputYmdTo").val(), 0));
        var startYmdHm = moment(startYmd.format("YYYY-MM-DD") + "T" + moment().format("HH") + ":00:00");
        var endYmdHm = moment(startYmd.format("YYYY-MM-DD") + "T" + moment().format("HH") + ":00:00").add(1, "hours");
        $("#inputYmdHmFrom").val(startYmdHm.format("YYYY年MM月DD日 HH時mm分"));
        $("#inputYmdHmTo").val(endYmdHm.format("YYYY年MM月DD日 HH時mm分"));

        $('.ymd').hide();
        $('.ymdHm').show();
    }
}

/**
 * 年月日の形式を変換する.
 */
function formatNengappi(nengappi, flg) {
    var ret = nengappi.replace("年", "-").replace("月", "-").replace("日", "");
    if (flg == 1){
        ret = nengappi.replace("年", "-").replace("月", "-").replace("日", "T").replace("時",":").replace("分",":").replace(" ","");
    }
    return ret;
}

サーバー側(Python)

追加したカラムもクライアントから連携して登録するように修正。
また、クライアントにJSONを返すようにして、画面の情報も更新します。

Main.py

class RegistSchedule(RequestHandler):
    """
    スケジュール登録
    """
    def initialize(self):
        logging.info("RegistSchedule [initialize]")

    def post(self):
        logging.info("RegistSchedule [post]")

        mysql = MySQLUtil()

        param = json.loads(self.request.body)
        allday = "TRUE" if param["allDay"] else "FALSE"
        id = mysql.get_next_id('001')

        data = [
            "001",
            id,
            param["title"],
            param["start"],
            param["end"],
            "",
            "",
            "",
            allday,
            param["description"]
        ]
        mysql.insert_data(data)

        # IDを設定して返す
        param["id"] = id
        self.write(param)

MySQLUtil.py

カラム追加したので、その修正も行う。

def create_db(self):
    """
    データベース、及び必要なテーブルを作成します.
    :return:
    """

    with closing(mysql.connector.connect(**self.config)) as conn:

        c = conn.cursor()

        # スケジュールテーブル
        sql = "CREATE TABLE IF NOT EXISTS TBL_SCHEDULE ("
        sql += "  USER_CD VARCHAR(10)"
        sql += ", ID INT(10)"
        sql += ", TITLE VARCHAR(100)"
        sql += ", START DATETIME"
        sql += ", END DATETIME"
        sql += ", TEXTCOLOR VARCHAR(20)"
        sql += ", COLOR VARCHAR(20)"
        sql += ", URL VARCHAR(100)"
        sql += ", ALLDAY VARCHAR(10)"
        sql += ", DESCRIPTION VARCHAR(1000)"
        sql += ", PRIMARY KEY(USER_CD, ID)"
        sql += ")"
        c.execute(sql)

        c.close()
        conn.commit()
        
        
def insert_data(self, data):
    """
    データを登録します
    :param ticker:
    :return:
    """

    with closing(mysql.connector.connect(**self.config)) as conn:

        c = conn.cursor()
        # データ登録
        sql = "INSERT INTO TBL_SCHEDULE VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)"
        c.execute(sql, data)

        c.close()
        conn.commit()

def get_schedule(self, year_month, user_cd=""):
    """
    データ(テーブル)をデータフレームに変換してかえします
    :return:
    """
    result = []

    with closing(mysql.connector.connect(**self.config)) as conn:

        c = conn.cursor(dictionary=True)

        sql = "SELECT * FROM TBL_SCHEDULE"
        sql += " WHERE (DATE_FORMAT(START, '%Y%m') = '" + year_month + "'"
        sql += " OR DATE_FORMAT(END, '%Y%m') = '" + year_month + "')"
        if user_cd != "":
            sql += " AND USER = '" + user_cd + "'"

        sql += " ORDER BY USER_CD, ID"

        c.execute(sql)
        for r in c.fetchall():
            result.append({
                "user_cd": r['USER_CD'],
                "id": r['ID'],
                "title": r['TITLE'],
                "start": r['START'],
                "end": r['END'],
                "textColor": r['TEXTCOLOR'],
                "color": r['COLOR'],
                "url": r['URL'],
                "allDay": (r['ALLDAY'] == 'TRUE'),
                "description": r['DESCRIPTION']
            })

    return result

起動してみる

初期表示
入力ダイアログ

日付をクリック、またはドラッグすると予定入力用のダイアログフォームが表示されます。

予定登録

予定を入力して登録ボタンをクリックします。

登録完了

登録完了メッセージが表示されればOKです。

カレンダーに反映される

ダイアログを閉じると、カレンダーに登録された予定が反映されています。

まとめ

細かいところは全然なので最終的にブラッシュアップが必要ですが、とりあえず使える感じにはなってきてますね。

少し長くなってしまったので、
既に登録されている予定の更新や削除はまた次回に。

ではでは。

 

スポンサーリンク


関連するコンテンツ