Coverage for src/data_api/domain/dining/services.py: 100%
68 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""
2Dining domain service.
4Handles business logic for dining data fetching and filtering.
5"""
7from datetime import datetime
8from typing import Optional
9from zoneinfo import ZoneInfo
11from thefuzz import fuzz
13from data_api.data.manager import nthudata
14from data_api.data.nthudata import NTHUDataManager
15from data_api.domain.dining import enums
16from data_api.utils.search import fuzzy_matches
18JSON_PATH = "dining.json"
19FUZZY_SEARCH_THRESHOLD = 60
22def is_restaurant_open(restaurant: dict, day: str) -> bool:
23 """
24 Check if restaurant is open on specified day.
26 Args:
27 restaurant: Restaurant data dict
28 day: Day of week in English lowercase
30 Returns:
31 True if restaurant may be open, False if definitely closed
32 """
33 note = restaurant.get("note", "").lower()
34 for keyword in enums.DiningScheduleKeyword.BREAK_KEYWORDS:
35 for day_zh in enums.DiningScheduleKeyword.DAY_EN_TO_ZH.get(day, []):
36 if keyword in note and day_zh in note:
37 return False
38 return True
41def _filter_by_schedule(dining_data: list[dict], schedule: Optional[str]) -> list[dict]:
42 if schedule is None:
43 return dining_data
45 day = schedule
46 if schedule == "today":
47 weekday = datetime.now(ZoneInfo("Asia/Taipei")).weekday()
48 day = {5: "saturday", 6: "sunday"}.get(weekday, "weekday")
50 filtered = []
51 for building in dining_data:
52 restaurants = [
53 restaurant
54 for restaurant in building["restaurants"]
55 if is_restaurant_open(restaurant, day)
56 ]
57 if restaurants:
58 filtered.append({**building, "restaurants": restaurants})
59 return filtered
62class DiningService:
63 """Service for dining data operations."""
65 def __init__(self, manager: NTHUDataManager = nthudata):
66 self.manager = manager
68 async def get_dining_data(
69 self,
70 building_name: Optional[str] = None,
71 restaurant_name: Optional[str] = None,
72 schedule: Optional[str] = None,
73 ) -> tuple[Optional[str], list[dict]]:
74 """Get grouped dining data with building, name, and opening-day filters."""
75 commit_hash, dining_data = await self.manager.get(JSON_PATH)
77 filtered = []
79 for b in dining_data:
80 # 篩建築物
81 if building_name and b["building"] != building_name:
82 continue
83 # 篩餐廳
84 restaurants = b["restaurants"]
85 if restaurant_name:
86 restaurants = [r for r in restaurants if restaurant_name in r["name"]]
88 # 沒餐廳就不要放進去,免得空殼 building 出現
89 if restaurants:
90 filtered.append(
91 {
92 **b,
93 "restaurants": restaurants,
94 }
95 )
97 return commit_hash, _filter_by_schedule(filtered, schedule)
99 async def get_open_restaurants(
100 self,
101 schedule: str,
102 building_name: Optional[str] = None,
103 restaurant_name: Optional[str] = None,
104 ) -> tuple[Optional[str], list[dict]]:
105 """Get possibly open restaurants after fuzzy building and name filtering."""
106 commit_hash, dining_data = await self.fuzzy_search_dining_data(
107 building_name=building_name, restaurant_name=restaurant_name, schedule=schedule
108 )
110 return commit_hash, [
111 restaurant for building in dining_data for restaurant in building["restaurants"]
112 ]
114 async def fuzzy_search_dining_data(
115 self,
116 building_name: Optional[str] = None,
117 restaurant_name: Optional[str] = None,
118 schedule: Optional[str] = None,
119 ) -> tuple[Optional[str], list[dict]]:
120 """
121 Fuzzy search dining data while maintaining the nested structure.
122 Returns: list[DiningBuilding]
123 """
124 commit_hash, raw_data = await self.manager.get(JSON_PATH)
125 filtered_results = []
127 # 2. 遍歷每一棟建築
128 for building in raw_data:
129 # --- Level 1: 建築名稱篩選 ---
130 # 如果有給 building_name,就先檢查建築名稱是否符合
131 if building_name:
132 b_score = fuzz.partial_ratio(building_name, building.get("building", ""))
133 if b_score < FUZZY_SEARCH_THRESHOLD:
134 # 建築名稱不符合,直接跳過整棟
135 continue
137 # 取得該建築內的所有餐廳
138 original_restaurants = building.get("restaurants", [])
139 matched_restaurants = []
141 # --- Level 2: 餐廳名稱篩選 ---
142 if restaurant_name:
143 # 如果有搜餐廳名,則過濾內部的餐廳
144 matched_restaurants = fuzzy_matches(
145 original_restaurants, restaurant_name, "name", FUZZY_SEARCH_THRESHOLD
146 )
148 # 如果這棟樓裡面,沒有任何一家餐廳符合搜尋,這棟樓就不用回傳了
149 # (除非使用者只搜了建築名,沒搜餐廳名,那下面 else 會處理)
150 if not matched_restaurants:
151 continue
153 else:
154 # 如果沒有搜餐廳名 (只搜建築),則保留該建築內所有餐廳
155 matched_restaurants = original_restaurants
157 # 3. 重組資料結構
158 # 複製一份建築資料,避免改到快取
159 new_building = building.copy()
160 new_building["restaurants"] = matched_restaurants
162 filtered_results.append(new_building)
164 return commit_hash, _filter_by_schedule(filtered_results, schedule)
167# Global service instance
168dining_service = DiningService()