Skip to content

Commit eefd4e8

Browse files
committed
分刻みの通知設定
1 parent abbd0db commit eefd4e8

12 files changed

Lines changed: 381 additions & 54 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from src.Infrastructure.Repositories import (
2+
line_user_repository,
3+
notification_schedule_repository,
4+
)
5+
6+
7+
def main() -> None:
8+
line_users = line_user_repository.find()
9+
for line_user in line_users:
10+
notification_schedule_repository.upsert(
11+
line_user_id=line_user.line_user_id,
12+
notify_time="12:00",
13+
timezone_name="Asia/Tokyo",
14+
)
15+
print(f"backfilled notification_schedules: {len(line_users)}")
16+
17+
18+
if __name__ == "__main__":
19+
main()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from dataclasses import dataclass
2+
from datetime import datetime
3+
4+
5+
@dataclass()
6+
class NotificationSchedule:
7+
line_user_id: str
8+
notify_time: str
9+
timezone: str
10+
enabled: bool
11+
next_notify_at: datetime
12+
created_at: datetime
13+
updated_at: datetime
14+
15+
def __init__(
16+
self,
17+
line_user_id: str = None,
18+
notify_time: str = "12:00",
19+
timezone: str = "Asia/Tokyo",
20+
enabled: bool = True,
21+
next_notify_at: datetime = None,
22+
created_at: datetime = datetime.now(),
23+
updated_at: datetime = datetime.now(),
24+
):
25+
self.line_user_id = line_user_id
26+
self.notify_time = notify_time
27+
self.timezone = timezone
28+
self.enabled = enabled
29+
self.next_notify_at = next_notify_at
30+
self.created_at = created_at
31+
self.updated_at = updated_at
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from datetime import datetime, timedelta, timezone
2+
from typing import List, Optional
3+
from zoneinfo import ZoneInfo
4+
5+
from google.cloud import firestore
6+
7+
from src.Domains.Entities.NotificationSchedule import NotificationSchedule
8+
from src.firestore_client import firestore_client
9+
10+
11+
class NotificationScheduleRepository:
12+
_collection_name = "notification_schedules"
13+
_default_notify_time = "12:00"
14+
_default_timezone = "Asia/Tokyo"
15+
16+
def _collection(self):
17+
return firestore_client.collection(self._collection_name)
18+
19+
def find_due(self, now_utc: datetime, limit: int = 500) -> List[NotificationSchedule]:
20+
query = (
21+
self._collection()
22+
.where("enabled", "==", True)
23+
.where("next_notify_at", "<=", now_utc)
24+
.order_by("next_notify_at", direction=firestore.Query.ASCENDING)
25+
.limit(limit)
26+
)
27+
schedules: List[NotificationSchedule] = []
28+
for doc in query.stream():
29+
data = doc.to_dict() or {}
30+
data["line_user_id"] = doc.id
31+
schedules.append(NotificationSchedule(**data))
32+
return schedules
33+
34+
def find_by_line_user_id(self, line_user_id: str) -> Optional[NotificationSchedule]:
35+
snapshot = self._collection().document(line_user_id).get()
36+
if not snapshot.exists:
37+
return None
38+
data = snapshot.to_dict() or {}
39+
data["line_user_id"] = snapshot.id
40+
return NotificationSchedule(**data)
41+
42+
def compute_next_notify_at(
43+
self,
44+
notify_time: Optional[str],
45+
timezone_name: Optional[str],
46+
base_time_utc: datetime,
47+
) -> datetime:
48+
safe_notify_time = notify_time or self._default_notify_time
49+
safe_timezone = timezone_name or self._default_timezone
50+
try:
51+
hour, minute = [int(x) for x in safe_notify_time.split(":", 1)]
52+
except (ValueError, TypeError):
53+
hour, minute = [int(x) for x in self._default_notify_time.split(":", 1)]
54+
55+
local_tz = ZoneInfo(safe_timezone)
56+
local_now = base_time_utc.astimezone(local_tz)
57+
try:
58+
next_local = local_now.replace(
59+
hour=hour,
60+
minute=minute,
61+
second=0,
62+
microsecond=0,
63+
)
64+
except ValueError:
65+
default_hour, default_minute = [int(x) for x in self._default_notify_time.split(":", 1)]
66+
next_local = local_now.replace(
67+
hour=default_hour,
68+
minute=default_minute,
69+
second=0,
70+
microsecond=0,
71+
)
72+
if next_local <= local_now:
73+
next_local += timedelta(days=1)
74+
return next_local.astimezone(timezone.utc)
75+
76+
def claim_and_schedule_next(self, line_user_id: str, now_utc: datetime) -> bool:
77+
doc_ref = self._collection().document(line_user_id)
78+
transaction = firestore_client.transaction()
79+
80+
@firestore.transactional
81+
def _claim(tx):
82+
snapshot = doc_ref.get(transaction=tx)
83+
if not snapshot.exists:
84+
return False
85+
86+
data = snapshot.to_dict() or {}
87+
if not data.get("enabled", True):
88+
return False
89+
90+
next_notify_at = data.get("next_notify_at")
91+
if next_notify_at is None:
92+
return False
93+
if next_notify_at > now_utc:
94+
return False
95+
96+
next_at = self.compute_next_notify_at(
97+
notify_time=data.get("notify_time"),
98+
timezone_name=data.get("timezone"),
99+
base_time_utc=now_utc,
100+
)
101+
tx.update(
102+
doc_ref,
103+
{
104+
"next_notify_at": next_at,
105+
"updated_at": now_utc,
106+
},
107+
)
108+
return True
109+
110+
return bool(_claim(transaction))
111+
112+
def upsert(
113+
self,
114+
line_user_id: str,
115+
notify_time: str = "12:00",
116+
timezone_name: str = "Asia/Tokyo",
117+
enabled: Optional[bool] = None,
118+
base_time_utc: Optional[datetime] = None,
119+
) -> NotificationSchedule:
120+
now_utc = base_time_utc or datetime.now(timezone.utc)
121+
next_notify_at = self.compute_next_notify_at(
122+
notify_time=notify_time,
123+
timezone_name=timezone_name,
124+
base_time_utc=now_utc,
125+
)
126+
doc_ref = self._collection().document(line_user_id)
127+
snapshot = doc_ref.get()
128+
data = snapshot.to_dict() or {}
129+
merged_enabled = data.get("enabled", True) if enabled is None else enabled
130+
created_at = data.get("created_at", now_utc)
131+
payload = {
132+
"notify_time": notify_time,
133+
"timezone": timezone_name,
134+
"enabled": merged_enabled,
135+
"next_notify_at": next_notify_at,
136+
"updated_at": now_utc,
137+
}
138+
if not snapshot.exists:
139+
payload["created_at"] = now_utc
140+
doc_ref.set(payload, merge=True)
141+
return NotificationSchedule(
142+
line_user_id=line_user_id,
143+
notify_time=notify_time,
144+
timezone=timezone_name,
145+
enabled=merged_enabled,
146+
next_notify_at=next_notify_at,
147+
created_at=created_at,
148+
updated_at=now_utc,
149+
)

src/Infrastructure/Repositories/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .LineUserRepository import LineUserRepository
2+
from .NotificationScheduleRepository import NotificationScheduleRepository
23
from .StockRepository import StockRepository
34
from .WebUserRepository import WebUserRepository
45
from .HabitTaskRepository import HabitTaskRepository
@@ -7,6 +8,7 @@
78

89
web_user_repository = WebUserRepository()
910
line_user_repository = LineUserRepository()
11+
notification_schedule_repository = NotificationScheduleRepository()
1012
stock_repository = StockRepository()
1113
habit_task_repository = HabitTaskRepository()
1214
habit_task_log_repository = HabitTaskLogRepository()

src/UseCases/Line/CheckExpiredStockUseCase.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,58 @@
1+
from datetime import datetime, timezone
2+
13
from src import config
2-
from datetime import datetime
3-
from src.UseCases.Interface.IUseCase import IUseCase
4-
from src.Domains.IRepositories.ILineUserRepository import ILineUserRepository
54
from src.Domains.IRepositories.IStockRepository import IStockRepository
65
from src.Domains.IRepositories.IWebUserRepository import IWebUserRepository
76
from src.UseCases.Interface.ILineResponseService import ILineResponseService
7+
from src.UseCases.Interface.IUseCase import IUseCase
88

99

1010
class CheckExpiredStockUseCase(IUseCase):
1111
def __init__(
1212
self,
13-
line_user_repository: ILineUserRepository,
13+
notification_schedule_repository,
1414
web_user_repository: IWebUserRepository,
1515
stock_repository: IStockRepository,
1616
line_response_service: ILineResponseService,
1717
):
18-
self._line_user_repository = line_user_repository
18+
self._notification_schedule_repository = notification_schedule_repository
1919
self._web_user_repository = web_user_repository
2020
self._stock_repository = stock_repository
2121
self._line_response_service = line_response_service
2222

2323
def execute(self) -> None:
24-
line_users = self._line_user_repository.find()
25-
for line_user in line_users:
26-
web_users = self._web_user_repository.find(
27-
{
28-
"linked_line_user_id": line_user.line_user_id,
29-
"is_linked_line_user": True,
30-
}
24+
now_utc = datetime.now(timezone.utc)
25+
due_schedules = self._notification_schedule_repository.find_due(now_utc=now_utc)
26+
if len(due_schedules) == 0:
27+
return
28+
29+
due_line_user_ids = [s.line_user_id for s in due_schedules]
30+
linked_web_users = self._web_user_repository.find(
31+
{
32+
"linked_line_user_id__in": due_line_user_ids,
33+
"is_linked_line_user": True,
34+
}
35+
)
36+
web_user_id_map = {user.linked_line_user_id: user._id for user in linked_web_users}
37+
today = datetime.now().date()
38+
39+
for schedule in due_schedules:
40+
claimed = self._notification_schedule_repository.claim_and_schedule_next(
41+
line_user_id=schedule.line_user_id,
42+
now_utc=now_utc,
3143
)
32-
web_user_id = "" if len(web_users) == 0 else web_users[0]._id
44+
if not claimed:
45+
continue
46+
47+
web_user_id = web_user_id_map.get(schedule.line_user_id, "")
3348
stocks = self._stock_repository.find(
3449
{
35-
"owner_id__in": [line_user.line_user_id, web_user_id],
50+
"owner_id__in": [schedule.line_user_id, web_user_id],
3651
"status": 1,
3752
}
3853
)
3954
near_due_stocks = []
4055
notify_on_items = []
41-
today = datetime.now().date()
4256
for stock in stocks:
4357
if stock.notify_enabled:
4458
notify_on_items.append(stock.item_name)
@@ -73,5 +87,4 @@ def execute(self) -> None:
7387
self._line_response_service.add_message(
7488
"通知ONのアイテム:\n" + "\n".join(notify_on_items)
7589
)
76-
77-
self._line_response_service.push(to=line_user.line_user_id)
90+
self._line_response_service.push(to=schedule.line_user_id)

src/UseCases/Line/FollowUseCase.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ def __init__(
1212
line_request_service: ILineRequestService,
1313
line_response_service: ILineResponseService,
1414
line_user_service: ILineUserService,
15+
notification_schedule_repository=None,
1516
):
1617
self._line_request_service = line_request_service
1718
self._line_response_service = line_response_service
1819
self._line_user_service = line_user_service
20+
self._notification_schedule_repository = notification_schedule_repository
1921

2022
def execute(self) -> None:
2123
name = self._line_request_service.req_line_user_name
@@ -24,6 +26,12 @@ def execute(self) -> None:
2426
line_user_id=self._line_request_service.req_line_user_id,
2527
)
2628
self._line_user_service.find_or_create(new_line_user=new_line_user)
29+
if self._notification_schedule_repository is not None:
30+
self._notification_schedule_repository.upsert(
31+
line_user_id=new_line_user.line_user_id,
32+
notify_time="12:00",
33+
timezone_name="Asia/Tokyo",
34+
)
2735
self._line_response_service.add_message(
2836
f"{name}さん、友達登録ありがとうございます!"
2937
)

0 commit comments

Comments
 (0)