Chapter 08

カートページ

alivelimb
alivelimb
2022.05.26に更新

本章ではカートページを実装していきます。完成イメージは以下の通りです。

カート画面gif

作成手順はこれまでと同様、以下の流れで行います。

No 分類 内容
1 Model BaseDataModel を継承したデータクラス Cart, CartItem を作成する
2 Service CartAPIClient を作成する
3 Page PageId を定義し、BasePage を継承したページクラスを作る
4 Application MultiPageApp のpagesにページ ID とページクラスのペアを追加する

Model: データクラス Cart, CartItem を作成する

データクラス Cart, CartItem を作成します。Cart はカートに追加した商品情報などを保持します。CartItem は商品情報、数量を保持します。

Cart

まずはデータクラス Cart を実装します。カート情報として、ユーザ ID、カート商品、合計金額を保持します。

@dataclass(frozen=True)
class Cart(BaseDataModel):
    user_id: str
    cart_items: list[CartItem] = field(default_factory=list)
    total_price: int = 0

    def to_dict(self) -> dict[str, str]:
        cart_items = [cart_item.to_dict() for cart_item in self.cart_items]
        return dict(
            user_id=self.user_id,
            cart_items=json.dumps(cart_items),
            total_price=str(self.total_price),
        )

    @classmethod
    def from_dict(cls, data: dict[str, str]) -> Cart:
        cart_items = [CartItem.from_dict(cart_item_dict) for cart_item_dict in json.loads(data["cart_items"])]
        return Cart(
            user_id=data["user_id"],
            cart_items=cart_items,
            total_price=int(data["total_price"]),
        )

なお、カートは Session に保持されるため、今回は MockSessionDB に保持されるということになります。

CartItem

次に CartItem です。カート商品情報として、商品と数量を定義します。

@dataclass(frozen=True)
class CartItem(BaseDataModel):
    item: Item
    quantity: int = 0

    def to_dict(self) -> dict[str, str]:
        item_dict = self.item.to_dict()
        return dict(
            item=json.dumps(item_dict),
            quantity=str(self.quantity),
        )

    @classmethod
    def from_dict(cls, data: dict[str, str]) -> CartItem:
        item_data = json.loads(data["item"])
        return CartItem(
            item=Item.from_dict(item_data),
            quantity=int(data["quantity"]),
        )

Service: CartAPIClient を作成する

Service もこれまでとほとんど同様です。まずは IF(インターフェース)を定義します。

class ICartAPIClientService(Protocol):
    def get_cart(self, session_id: str) -> Cart:
        pass

    def add_item(self, session_id: str, cart_item: CartItem) -> None:
        pass

    def clear_cart(self, session_id: str) -> None:
        pass

今回は以下のメソッドを定義しました。

No メソッド名 機能
1 get_cart セッション ID を指定してカートのデータクラスを取得する
2 add_item セッション ID を指定してカートに商品を追加する
3 clear_cart セッション ID を指定してカートの中身を空にする

get_cart

まずはget_cartから実装していきましょう。

class MockCartAPIClientService(ICartAPIClientService):
    def __init__(self, session_db: MockSessionDB) -> None:
        self.session_db = session_db

    def get_cart(self, session_id: str) -> Cart:
        with self.session_db.connect() as db:
            query = Query()
            session_dict = db.search(query.session_id == session_id)[0]
            session = Session.from_dict(session_dict)

        return session.cart

こちらは AuthAPIClient とほとんど同じで、Session を取得してから Cart を取得します。

add_item

次にadd_itemを実装します。

def add_item(self, session_id: str, cart_item: CartItem) -> None:
    with self.session_db.connect() as db:
        query = Query()
        db.update(self._get_add_item_cb(cart_item), query.session_id == session_id) # (A)

def _get_add_item_cb(self, cart_item: CartItem) -> Callable[[dict], None]:
    def transform(doc: dict) -> None:
        session = Session.from_dict(doc)
        cart = session.cart
        new_cart_items = [*cart.cart_items, cart_item]
        new_total_price = cart_item.item.price * cart_item.quantity + cart.total_price # (B)
        new_cart = Cart( # (C)
            cart.user_id,
            cart_items=new_cart_items,
            total_price=new_total_price,
        )
        new_session = Session( # (C)
            user_id=session.user_id,
            session_id=session.session_id,
            cart=new_cart,
        )
        for key, value in new_session.to_dict().items():
            doc[key] = value

    return transform

コード中(A)でセッションテーブルを更新します。TinyDB ではコールバック関数を渡すことで更新が可能です。_get_add_item_cbは TinyDB に渡すコールバック関数を生成するメソッドということになります。

コード中(B)でカート内の合計金額を更新しています。

YaEC のデータクラスはイミュータブルなので、コード中(C)でデータクラスを新たに作成し、既存のレコードを置き換えることで更新処理としています。

clear_cart

最後にclear_cartです。

def clear_cart(self, session_id: str) -> None:
    with self.session_db.connect() as db:
        query = Query()
        db.update(self._get_clear_cart_cb(), query.session_id == session_id)

def _get_clear_cart_cb(self) -> Callable[[dict], None]:
    def transform(doc: dict) -> None:
        session = Session.from_dict(doc)
        new_session = Session(
            session_id=session.session_id,
            user_id=session.user_id,
            cart=Cart(user_id=session.user_id),
        )
        for key, value in new_session.to_dict().items():
            doc[key] = value

    return transform

こちらはadd_itemに似ているので、特に説明する必要はないかと思います。

Page: PageId を定義し、ページクラスを作る

これまでと同じ手順でページを実装していきましょう。

PageId の定義

まずは PageId の定義です。カートページを閲覧するにはログインが必要なので、プレフィックスをMEMBERとしておきます。

class PageId(Enum):
    # (中略)
    MEMBER_CART = auto() # 追加

ページクラスの作成

次にページクラスを作っていきます。本章からは会員ページの実装になるため、会員ページのベースとなるMemberPageクラスをまずは実装しましょう。MemberPageではユーザの閲覧権限を検証するvalidate_userメソッドを定義しておきます。

class MemberPage(BasePage):
    def validate_user(self) -> bool:
        user = self.ssm.get_user()
        if (user is None) or (user.role not in (UserRole.MEMBER, UserRole.ADMIN)):
            st.warning("会員専用ページです")
            return False

        return True

次にカートページを実装していきます。

class CartPage(MemberPage):
    def render(self) -> None:
        session_id = self.ssm.get_session_id()
        if not self.validate_user() or session_id is None: # (A)
            return # (A)

        cart_api_client = self.ssm.get_cart_api_client()
        cart = cart_api_client.get_cart(session_id)

        if len(cart.cart_items) == 0:
            st.warning("カートに入っている商品はありません。")
            return

        # タイトル表示
        st.title(self.title)

        # カートテーブル表示
        col_size = [1, 2, 2, 2]
        columns = st.columns(col_size)
        headers = ["No", "商品名", "単価", "数量"]
        for col, field_name in zip(columns, headers):
            col.write(field_name)

        for idx, cart_item in enumerate(cart.cart_items):
            (
                no_col,
                name_col,
                price_col,
                q_col,
            ) = st.columns(col_size)
            no_col.write(idx + 1)
            name_col.write(cart_item.item.name)
            price_col.write(cart_item.item.price)
            q_col.write(cart_item.quantity)

        # 合計金額表示
        st.text(f"合計金額: {cart.total_price}")

        st.button("注文", on_click=self._order_commit) #(B)

    def _order_commit(self) -> None:
        session_id = self.ssm.get_session_id()
        order_api_client = self.ssm.get_order_api_client()
        cart_api_client = self.ssm.get_cart_api_client()

        order_api_client.order_commit(session_id)
        cart_api_client.clear_cart(session_id)
        st.sidebar.success("注文が完了しました")

コード中(A)では先ほど実装したvalidate_userを用いてユーザの閲覧権限を検証します。閲覧権限がない場合は return することで処理を停止させます。

コード中(B)ではコールバック関数を用いています。今回は入力ウィジェットを操作していませんが、注文時にカートテーブルを再レンダリングさせたいため、コールバック関数を使っています(if 文を使うとカートテーブルが再レンダリングされず、手動でページを更新する必要があります)。

Application: ページ ID とページクラスのペアを追加する

最後に作成したページを Application に追加します。

def init_pages(ssm: StreamlitSessionManager) -> list[BasePage]:
    pages = [
        # (中略)
        CartPage(page_id=PageId.MEMBER_CART, title="カート", ssm=ssm), # 追加
    ]
    return pages

SSM, SessionKey 等も登録する必要がありますが、ここでの紹介は省略するため、ソースコードを確認してください。