予約サイトのカレンダーページ

Python Django

概要

Djangoで、シンプルな予約サイトの作成シリーズの1つです。今回は予約サイトの目玉機能である、予約状況のカレンダーページを作成します。〇とか×がついているやつです。

予約サイトによくあるカレンダー

URL定義の追加

まず、URL定義を追加しましょう。booking/urls.pyです。

from django.urls import path
from . import views

app_name = 'booking'

urlpatterns = [
    path('', views.StoreList.as_view(), name='store_list'),
    path('store/<int:pk>/staffs/', views.StaffList.as_view(), name='staff_list'),

    # この2つを今回追加
    path('staff/<int:pk>/calendar/', views.StaffCalendar.as_view(), name='calendar'),
    path('staff/<int:pk>/calendar/<int:year>/<int:month>/<int:day>/', views.StaffCalendar.as_view(), name='calendar'),
]

2つ追加しています。/staff/1/calendar/ のようなURLのときは、pkが1のStaffの、当日を基準にしたカレンダーを表示します。/staff/1/calendar/2020/2/27/ のようなURLのときは、pkが1のStaffの、2020/2/27を基準にしたカレンダーを表示します。日付の指定がある時とない時の両方に対応させているということです。

ビューの作成

ビューを作成します。booking/views.pyです。import文の追加が結構あるので、現状で必要なimportも改めて書いています。

import datetime
from django.conf import settings
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views import generic
from .models import Store, Staff, Schedule

# 他のビュー略


class StaffCalendar(generic.TemplateView):
    template_name = 'booking/calendar.html'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        staff = get_object_or_404(Staff, pk=self.kwargs['pk'])
        today = datetime.date.today()

        # どの日を基準にカレンダーを表示するかの処理。
        # 年月日の指定があればそれを、なければ今日からの表示。
        year = self.kwargs.get('year')
        month = self.kwargs.get('month')
        day = self.kwargs.get('day')
        if year and month and day:
            base_date = datetime.date(year=year, month=month, day=day)
        else:
            base_date = today

        # カレンダーは1週間分表示するので、基準日から1週間の日付を作成しておく
        days = [base_date + datetime.timedelta(days=day) for day in range(7)]
        start_day = days[0]
        end_day = days[-1]

        # 9時から17時まで1時間刻み、1週間分の、値がTrueなカレンダーを作る
        calendar = {}
        for hour in range(9, 18):
            row = {}
            for day in days:
                row[day] = True
            calendar[hour] = row

        # カレンダー表示する最初と最後の日時の間にある予約を取得する
        start_time = datetime.datetime.combine(start_day, datetime.time(hour=9, minute=0, second=0))
        end_time = datetime.datetime.combine(end_day, datetime.time(hour=17, minute=0, second=0))
        for schedule in Schedule.objects.filter(staff=staff).exclude(Q(start__gt=end_time) | Q(end__lt=start_time)):
            local_dt = timezone.localtime(schedule.start)
            booking_date = local_dt.date()
            booking_hour = local_dt.hour
            if booking_hour in calendar and booking_date in calendar[booking_hour]:
                calendar[booking_hour][booking_date] = False

        context['staff'] = staff
        context['calendar'] = calendar
        context['days'] = days
        context['start_day'] = start_day
        context['end_day'] = end_day
        context['before'] = days[0] - datetime.timedelta(days=7)
        context['next'] = days[-1] + datetime.timedelta(days=1)
        context['today'] = today
        context['public_holidays'] = settings.PUBLIC_HOLIDAYS
        return context

なかなか本格的なコードです。順番に説明していきます。

基準となる日付の作成

まず、今回表示するカレンダーは何日から表示すりゃいいのかをハッキリさせる必要があります。urls.pyで定義していたように、年月日の指定がURL内にあればそれを基準に、そうでなければ今日を基準に表示させていきます。

        today = datetime.date.today()

        # どの日を基準にカレンダーを表示するかの処理。
        # 年月日の指定があればそれを、なければ今日からの表示。
        year = self.kwargs.get('year')
        month = self.kwargs.get('month')
        day = self.kwargs.get('day')
        if year and month and day:
            base_date = datetime.date(year=year, month=month, day=day)
        else:
            base_date = today

特に難しいこともありません、素直な実装です。

カレンダーは、1週間分を毎回表示させようと思います。その1週間分のdatetimeオブジェクトが入ったリストを作っておきます。このリストを作っておくと、次のカレンダー辞書作成時の処理が分かりやすくなったり、テンプレートファイルで1週間分の日付欄の作成に役立ちます。

        # カレンダーは1週間分表示するので、基準日から1週間の日付を作成しておく
        days = [base_date + datetime.timedelta(days=day) for day in range(7)]
        start_day = days[0]
        end_day = days[-1]

期間の初日と最後は何度か使う機会があるので、start_day, end_dayという変数名として持っておきます。

空のカレンダー辞書を作成

カレンダー情報を表現するオブジェクトを作ります。今回は単純に、辞書で作ります。

        # 9時から17時まで1時間刻み、1週間分の、値がTrueなカレンダーを作る
        calendar = {}
        for hour in range(9, 18):
            row = {}
            for day in days:
                row[day] = True
            calendar[hour] = row

先ほど作ったdaysリストを活用しています。

calendar変数は次のような辞書になります。{9:{1/8: True, 1/9: True...}, 10:{1/8: True, 1/9: True...}, ...}といった辞書ですね。これをforで取り出していくと、カレンダーが作れそうです!

{9: {datetime.date(2020, 1, 8): True,
     datetime.date(2020, 1, 9): True,
     datetime.date(2020, 1, 10): True,
     datetime.date(2020, 1, 11): True,
     datetime.date(2020, 1, 12): True,
     datetime.date(2020, 1, 13): True,
     datetime.date(2020, 1, 14): True},
 10: {datetime.date(2020, 1, 8): True,
      datetime.date(2020, 1, 9): True,
      datetime.date(2020, 1, 10): True,
      datetime.date(2020, 1, 11): True,
      datetime.date(2020, 1, 12): True,
      datetime.date(2020, 1, 13): True,
      datetime.date(2020, 1, 14): True},
 11: {datetime.date(2020, 1, 8): True,
      datetime.date(2020, 1, 9): True,
      datetime.date(2020, 1, 10): True,
      datetime.date(2020, 1, 11): True,
      datetime.date(2020, 1, 12): True,
      datetime.date(2020, 1, 13): True,
      datetime.date(2020, 1, 14): True},
 12: {datetime.date(2020, 1, 8): True,
      datetime.date(2020, 1, 9): True,
      datetime.date(2020, 1, 10): True,
      datetime.date(2020, 1, 11): True,
      datetime.date(2020, 1, 12): True,
      datetime.date(2020, 1, 13): True,
      datetime.date(2020, 1, 14): True},
 13: {datetime.date(2020, 1, 8): True,
      datetime.date(2020, 1, 9): True,
      datetime.date(2020, 1, 10): True,
      datetime.date(2020, 1, 11): True,
      datetime.date(2020, 1, 12): True,
      datetime.date(2020, 1, 13): True,
      datetime.date(2020, 1, 14): True},
 14: {datetime.date(2020, 1, 8): True,
      datetime.date(2020, 1, 9): True,
      datetime.date(2020, 1, 10): True,
      datetime.date(2020, 1, 11): True,
      datetime.date(2020, 1, 12): True,
      datetime.date(2020, 1, 13): True,
      datetime.date(2020, 1, 14): True},
 15: {datetime.date(2020, 1, 8): True,
      datetime.date(2020, 1, 9): True,
      datetime.date(2020, 1, 10): True,
      datetime.date(2020, 1, 11): True,
      datetime.date(2020, 1, 12): True,
      datetime.date(2020, 1, 13): True,
      datetime.date(2020, 1, 14): True},
 16: {datetime.date(2020, 1, 8): True,
      datetime.date(2020, 1, 9): True,
      datetime.date(2020, 1, 10): True,
      datetime.date(2020, 1, 11): True,
      datetime.date(2020, 1, 12): True,
      datetime.date(2020, 1, 13): True,
      datetime.date(2020, 1, 14): True},
 17: {datetime.date(2020, 1, 8): True,
      datetime.date(2020, 1, 9): True,
      datetime.date(2020, 1, 10): True,
      datetime.date(2020, 1, 11): True,
      datetime.date(2020, 1, 12): True,
      datetime.date(2020, 1, 13): True,
      datetime.date(2020, 1, 14): True}}

コメントにも書いていますが、カレンダーは9時から17時、1週間分をエンドユーザーに表示します。ソースコード上に直接数値を指定していますが、この辺はStoreやStaffモデルに持たせるようにしたり、ビューのクラス属性として設定できるようにしたり、という方が汎用的です。

また、1時間刻みでカレンダーを作成しています。モデル的には開始時間と終了時間をDateTimeFieldとして持たせる汎用的な作りにしているので、1時間じゃなきゃできないって訳ではありません。30分刻み、40分+20分休憩、そういったカレンダーにもできます。そういったカレンダーの設定をモデルで管理し、店舗ごと、スタッフごとに表示を変える、等も可能ではあります。

カレンダー辞書に予約を詰める

さきほど作ったカレンダー辞書の、予約がある時間帯にFalseを入れていきます。これで、Trueなら予約可能で〇、Falseなら予約済みで×、みたいな表示の切り替えができますね。

        # カレンダー表示する最初と最後の日時の間にある予約を取得する
        start_time = datetime.datetime.combine(start_day, datetime.time(hour=9, minute=0, second=0))
        end_time = datetime.datetime.combine(end_day, datetime.time(hour=17, minute=0, second=0))
        for schedule in Schedule.objects.filter(staff=staff).exclude(Q(start__gt=end_time) | Q(end__lt=start_time)):
            local_dt = timezone.localtime(schedule.start)
            booking_date = local_dt.date()
            booking_hour = local_dt.hour
            if booking_hour in calendar and booking_date in calendar[booking_hour]:
                calendar[booking_hour][booking_date] = False

なかなか難しいコードなので、これもじっくり説明していきます。

1~2行目は、カレンダー期間の最初と最後の日時を正確に作っています。これはすぐに使います。

3行目は、カレンダー期間中の表示すべき予約だけを絞り込んで取得しています。

カレンダー期間というのは次の画像でいう、start_timeからend_timeまでのことですね(画像、start_day→start_time, end_day→end_timeの書き間違い)。

カレンダー期間

今回のScheduleモデル的には開始時間と終了時間を自由に設定することができます。次の画像のように、start_time、end_timeをまたぐような予約が入る可能性だってあるのです。本来は、これらもカレンダーに表示すべきです。

start_day, end_dayをまたぐ予約もある

カレンダーに表示する必要のない予約はどれでしょうか。次の画像の、赤いものは表示する必要がありません。

表示する必要のない予約を探そう

条件を言葉で説明すると、予約の開始時間がend_timeより後か、又は、予約の終了時間がstart_timeより前の予約は不要ということになります。start_timeが2020年1月8日 9時、end_timeが2020年1月14日 17時ならば、予約の開始時間が2020年1月14日 17時より後か、予約の終了時間が2020年1月8日 9時より前のものは必要がない、ということになります。

それを実行しているのが、exclude(Q(start__gt=end_time) | Q(end__lt=start_time))ですね。また、filter(staff=staff)として、そのスタッフの予約だけに絞り込むことも忘れないようにしましょう。これで、本当に欲しいScheduleだけ取り出すことができています。

その後の処理は、非常に手抜きです。各予約の開始時間をローカル時間にして、日付と時間に分けます。そして、カレンダー辞書[時間][日付] = Falseのようにして、予約のある日時にFalseを設定していくという流れです。今回の予約サイトでは、予約は全て1時間単位で表示・作成するので、正直このような簡単な処理でも問題はないのです。

テンプレートファイルへ渡す変数

テンプレートファイルには、今作ったカレンダーやスタッフモデルインスタンスのほか、幾つかのオブジェクトを渡します。

        context['staff'] = staff
        context['calendar'] = calendar
        context['days'] = days
        context['start_day'] = start_day
        context['end_day'] = end_day
        context['before'] = days[0] - datetime.timedelta(days=7)
        context['next'] = days[-1] + datetime.timedelta(days=1)
        context['today'] = today
        context['public_holidays'] = settings.PUBLIC_HOLIDAYS

daysはカレンダー上部にある日付部分の行を作るのに使い、start_day, end_dayは「2020/1/8 ~ 2020/1/14のカレンダー」みたいな説明文に使います。

当日以前の日付は予約を入れれないようにしたいので、today変数も渡します。

beforeとnextは、前週、次週のリンクを作るのに使います。

public_holidayは祝日のリストです。祝日の場合は、日付の色を変えたりしたいなと思っています。祝日は国ごとに違っていて、標準ライブラリのcalendarモジュールですぐに取得できるものでもありません。今回はシンプルに、祝日のリストをsettings.pyに予め定義しておく方法をとっています。

import datetime

PUBLIC_HOLIDAYS = [
    # 2020
    datetime.date(year=2020, month=1, day=1),
    datetime.date(year=2020, month=1, day=13),
    datetime.date(year=2020, month=2, day=11),
    datetime.date(year=2020, month=2, day=23),
    datetime.date(year=2020, month=2, day=24),
    datetime.date(year=2020, month=3, day=20),
    datetime.date(year=2020, month=4, day=29),
    datetime.date(year=2020, month=5, day=3),
    datetime.date(year=2020, month=5, day=4),
    datetime.date(year=2020, month=5, day=5),
    datetime.date(year=2020, month=7, day=20),
    datetime.date(year=2020, month=8, day=11),
    datetime.date(year=2020, month=9, day=21),
    datetime.date(year=2020, month=9, day=22),
    datetime.date(year=2020, month=10, day=12),
    datetime.date(year=2020, month=11, day=3),
    datetime.date(year=2020, month=11, day=23),

    # 2021
    datetime.date(year=2021, month=1, day=1),
    datetime.date(year=2021, month=1, day=11),
    datetime.date(year=2021, month=2, day=11),
    datetime.date(year=2021, month=2, day=23),
    datetime.date(year=2021, month=3, day=20),
    datetime.date(year=2021, month=4, day=29),
    datetime.date(year=2021, month=5, day=3),
    datetime.date(year=2021, month=5, day=4),
    datetime.date(year=2021, month=5, day=5),
    datetime.date(year=2021, month=7, day=19),
    datetime.date(year=2021, month=8, day=11),
    datetime.date(year=2021, month=9, day=20),
    datetime.date(year=2021, month=9, day=23),
    datetime.date(year=2021, month=10, day=11),
    datetime.date(year=2021, month=11, day=3),
    datetime.date(year=2021, month=11, day=23),
]

こうしてみると、祝日って少ないですよね。

スタッフ一覧からリンクを張る

ここまでできたら、booking/staff_list.htmlを開いて、スタッフ名のリンク部分を変更します。これでスタッフ一覧ページからカレンダーページに移動できます。

<a href="{% url 'booking:calendar' staff.pk %}">{{ staff.name }}</a>

テンプレートファイルの作成

カレンダーのテンプレートファイルを作ります。booking/calendar.htmlです。

{% extends 'booking/base.html' %}

{% block content %}

    <h1>{{ staff.store.name }}店 {{ staff.name }}</h1>
    <p>{{ start_day }} - {{ end_day }}</p>
    <table class="table table-bordered text-center" style="table-layout: fixed;width: 100%" border="1">
        <tr>
            <td><a href="{% url 'booking:calendar' staff.pk before.year before.month before.day %}">前週</a></td>
            {% for day in days %}
                {% if day in public_holidays %}
                    <th style="background-color: yellow">{{ day | date:"d(D)" }}</th>
                {% elif day.weekday == 5 %}
                    <th style="color: blue;">{{ day | date:"d(D)" }}</th>
                {% elif day.weekday == 6 %}
                    <th style="color: red;">{{ day | date:"d(D)" }}</th>
                {% else %}
                    <th>{{ day | date:"d(D)" }}</th>
                {% endif %}
            {% endfor %}
            <td><a href="{% url 'booking:calendar' staff.pk next.year next.month next.day %}">次週</a></td>
        </tr>

        {% for hour, schedules in calendar.items %}
            <tr style="font-size:12px">
                <td>
                    {{ hour }}:00
                </td>
                {% for dt, book in schedules.items %}
                    <td>
                        {% if dt <= today %}
                            -
                        {% elif book %}
                            <a href="">○</a>
                        {% else %}
                            ×
                        {% endif %}
                    </td>

                {% endfor %}
                <td>
                    {{ hour }}:00
                </td>
            </tr>
        {% endfor %}

    </table>
{% endblock %}

予約ができるときの〇はリンクにしておきます。後で、予約ページへのURLを入れます。

FAQ

お店は1つ、従業員も1人、店舗やスタッフの一覧ページをなくし、このカレンダーをトップにしたい

今回のモデルの仕組みでそれをやろうとするならば、次のように、店やスタッフが1つならリダイレクトさせてしまう作りにもできます。

from django.shortcuts import get_object_or_404, redirect  # 追加
# 略


class StoreList(generic.ListView):
    model = Store
    ordering = 'name'

    # 追加
    def get(self, request, *args, **kwargs):
        # 店が1つならば、店の選択画面は飛ばす
        store_list = Store.objects.all()
        if store_list.count() == 1:
            store = store_list.first()
            return redirect('booking:staff_list', pk=store.pk)
        return super().get(request, *args, **kwargs)


class StaffList(generic.ListView):
    model = Staff
    ordering = 'name'

    # 追加
    def get(self, request, *args, **kwargs):
        # スタッフが1人ならば、スタッフ選択画面は飛ばす
        store = get_object_or_404(Store, pk=self.kwargs['pk'])
        staff_list = Staff.objects.filter(store=store)
        if staff_list.count() == 1:
            staff = staff_list.first()
            return redirect('booking:calendar', pk=staff.pk)
        return super().get(request, *args, **kwargs)

各時間の予約は、2件まで入れたい等

カレンダー辞書の作成時に、Trueといったブール値ではなくリストなどを利用するようにします。

row[day] = []

予約をカレンダーに埋めていく際に、Falseではなく予約のモデルインスタンスをそのままappendしておきます。

calendar[booking_hour][booking_date].append(schedule)

テンプレートファイルにて、予約件数によって×にしたり〇にしたりしましょう。

                {% for dt, book in schedules.items %}
                        <td>
                            {% if dt <= today %}
                                -
                            {% elif book|length < 2 %}
                                <a href="">○</a>
                            {% else %}
                                ×
                            {% endif %}
                        </td>
                {% endfor %}

この例だとbook変数はリストなので、'{% for b in book %}'のようにして、各時間の予約内容を全て表示させたり、予約の件数を表示する、などもできるでしょう。

少し複雑になってきますが、モデルを上手く利用することで、スタッフそれぞれの各時間の受け入れ件数を自由に設定する等も可能になるでしょう。

当日も1時間前なら、まだ予約できるようにしたい

今から見て1時間後なのかチェックするための、テンプレートタグを書いてみましょう。アプリケーション内にtemplatetagsディレクトリを作り、booking.pyを作ります。中身は次のようにしておきます。

import datetime
from django import template

register = template.Library()


@register.simple_tag
def is_1hour_later(dt, hour):
    later_1hour = datetime.datetime.now() + datetime.timedelta(hours=1)
    target = datetime.datetime.combine(dt, datetime.time(hour=hour, minute=0, second=0))
    return later_1hour <= target

作ったらrunserverをしなおしましょう。Djangoはファイル変更を検知しますが、ファイルの追加といった操作は検知しません。

これをカレンダーのテンプレートで使います。{% load booking %}を忘れないでください。

{% extends 'booking/base.html' %}
{% load booking %}

...
...

                {% for dt, book in schedules.items %}
                    <td>
                        {% is_1hour_later dt hour as ok %}

                        {% if not ok %}
                            -
                        {% elif book %}
                            <a href="">○</a>
                        {% else %}
                            ×
                        {% endif %}
                    </td>

                {% endfor %}

カレンダーの作成部分が長いので、上手く分割したい

今のところStaffCalendarget_context_dataメソッドに、カレンダー作成に関する全てのコードを書いています。これは保守や再利用の面では少々よろしくありません。やり方は色々と考えられますが、個人的にオススメなのが、カレンダーを作成するMixinクラスを作成することです。Djangoでカレンダーを作るシリーズでも、カレンダーのようなオブジェクトをMixinクラスで提供していました。

Django内にも日付ベースの汎用ビュー(django.views.generic.dates)のような、Mixinを上手く使った例があります。

Relation Posts

Djangoでカレンダーを作るシリーズ

Djangoで、月間カレンダーや週間カレンダー、それぞれにスケジュール表示機能がついたもの、スケジュール登録フォームがついたもの等、様々なカレンダーを作成していきます。

Python Django Bootstrap4 シリーズ・まとめ

Comment

記事にコメントする

まだコメントはありません。