Initial commit: habit tracker PWA with Google OAuth, push notifications
FastAPI + SQLAlchemy/Alembic + MariaDB backend with Jinja2/htmx/Alpine server-rendered frontend. Multi-user via Google OAuth, daily habit tracking, monthly/weekly history views, Web Push reminders via APScheduler, and PWA support (manifest, service worker, offline caching). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.habit import Habit, HabitStatus, HabitType
|
||||
from app.schemas.habit import HabitCreate, HabitUpdate
|
||||
|
||||
|
||||
def list_habits(
|
||||
db: Session, user_id: int, habit_type: HabitType | None = None, status: HabitStatus | None = None
|
||||
) -> list[Habit]:
|
||||
stmt = select(Habit).where(Habit.user_id == user_id)
|
||||
if habit_type is not None:
|
||||
stmt = stmt.where(Habit.habit_type == habit_type)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Habit.status == status)
|
||||
stmt = stmt.order_by(Habit.sort_order.is_(None), Habit.sort_order, Habit.created_at)
|
||||
return list(db.scalars(stmt))
|
||||
|
||||
|
||||
def list_active_habits_with_reminders(db: Session) -> list[Habit]:
|
||||
"""스케줄러 전용: 유저 스코핑 없이 알림 시각이 설정된 전체 active 습관을 반환한다."""
|
||||
stmt = select(Habit).where(Habit.status == HabitStatus.ACTIVE, Habit.reminder_time.isnot(None))
|
||||
return list(db.scalars(stmt))
|
||||
|
||||
|
||||
def list_active_user_ids(db: Session) -> list[int]:
|
||||
"""스케줄러 전용: 유저 스코핑 없이, active 습관을 하나 이상 가진 유저 id 목록을 반환한다(주간/월간 요약 알림 대상)."""
|
||||
stmt = (
|
||||
select(Habit.user_id)
|
||||
.where(Habit.status == HabitStatus.ACTIVE, Habit.user_id.isnot(None))
|
||||
.distinct()
|
||||
)
|
||||
return list(db.scalars(stmt))
|
||||
|
||||
|
||||
def get_habit(db: Session, habit_id: int, user_id: int) -> Habit | None:
|
||||
return db.scalar(select(Habit).where(Habit.id == habit_id, Habit.user_id == user_id))
|
||||
|
||||
|
||||
def create_habit(db: Session, user_id: int, data: HabitCreate) -> Habit:
|
||||
habit = Habit(
|
||||
user_id=user_id,
|
||||
name=data.name,
|
||||
habit_type=data.habit_type,
|
||||
weekdays_mask=data.weekdays_mask,
|
||||
condition_text=data.condition_text,
|
||||
reminder_time=data.reminder_time,
|
||||
status=HabitStatus.ACTIVE,
|
||||
)
|
||||
db.add(habit)
|
||||
db.commit()
|
||||
db.refresh(habit)
|
||||
return habit
|
||||
|
||||
|
||||
def update_habit(db: Session, habit: Habit, data: HabitUpdate) -> Habit:
|
||||
habit.name = data.name
|
||||
habit.habit_type = data.habit_type
|
||||
habit.weekdays_mask = data.weekdays_mask
|
||||
habit.condition_text = data.condition_text
|
||||
habit.reminder_time = data.reminder_time
|
||||
db.commit()
|
||||
db.refresh(habit)
|
||||
return habit
|
||||
|
||||
|
||||
def delete_habit(db: Session, habit: Habit) -> None:
|
||||
db.delete(habit)
|
||||
db.commit()
|
||||
|
||||
|
||||
def complete_habit(db: Session, habit: Habit) -> Habit:
|
||||
habit.status = HabitStatus.COMPLETED
|
||||
habit.completed_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(habit)
|
||||
return habit
|
||||
|
||||
|
||||
def reactivate_habit(db: Session, habit: Habit) -> Habit:
|
||||
habit.status = HabitStatus.ACTIVE
|
||||
habit.completed_at = None
|
||||
db.commit()
|
||||
db.refresh(habit)
|
||||
return habit
|
||||
|
||||
|
||||
def reorder_habits(db: Session, user_id: int, ordered_ids: list[int]) -> None:
|
||||
"""ordered_ids에 나온 순서대로 sort_order를 다시 매긴다. 목록에 없는 id나 다른 유저의 habit은 무시한다."""
|
||||
habits = db.scalars(
|
||||
select(Habit).where(Habit.id.in_(ordered_ids), Habit.user_id == user_id)
|
||||
).all()
|
||||
habit_map = {h.id: h for h in habits}
|
||||
for index, habit_id in enumerate(ordered_ids):
|
||||
habit = habit_map.get(habit_id)
|
||||
if habit is not None:
|
||||
habit.sort_order = index
|
||||
db.commit()
|
||||
@@ -0,0 +1,254 @@
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.habit import Habit, HabitStatus, HabitType
|
||||
from app.models.habit_log import HabitLog
|
||||
from app.schemas.habit_log import HabitStats, MonthlySummaryDay, TodayItem, WeeklyMatrixRow
|
||||
from app.services import habit_service, push_service
|
||||
|
||||
# 체크 시 축하 푸시/배지를 트리거하는 연속 달성일 마일스톤.
|
||||
MILESTONE_STREAKS = {7, 30, 66, 100, 200, 365}
|
||||
|
||||
|
||||
def _to_today_item(db: Session, habit: Habit, checked: bool) -> TodayItem:
|
||||
stats = get_habit_stats(db, habit)
|
||||
return TodayItem(
|
||||
habit_id=habit.id,
|
||||
name=habit.name,
|
||||
habit_type=habit.habit_type.value,
|
||||
condition_text=habit.condition_text,
|
||||
reminder_time=habit.reminder_time.strftime("%H:%M") if habit.reminder_time else None,
|
||||
checked=checked,
|
||||
completion_rate=stats.completion_rate,
|
||||
current_streak=stats.current_streak,
|
||||
scheduled_days=stats.scheduled_days,
|
||||
)
|
||||
|
||||
|
||||
def get_today_items(db: Session, user_id: int, target_date: date) -> tuple[list[TodayItem], list[TodayItem]]:
|
||||
"""오늘 요일에 예정된 active 습관을 형성/중단으로 나누어 체크 여부와 함께 반환한다."""
|
||||
weekday = target_date.weekday()
|
||||
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
|
||||
scheduled = [h for h in active_habits if h.is_scheduled_on(weekday)]
|
||||
|
||||
checked_ids: set[int] = set()
|
||||
habit_ids = [h.id for h in scheduled]
|
||||
if habit_ids:
|
||||
checked_ids = set(
|
||||
db.scalars(
|
||||
select(HabitLog.habit_id).where(
|
||||
HabitLog.log_date == target_date, HabitLog.habit_id.in_(habit_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
build_items = [_to_today_item(db, h, h.id in checked_ids) for h in scheduled if h.habit_type == HabitType.BUILD]
|
||||
quit_items = [_to_today_item(db, h, h.id in checked_ids) for h in scheduled if h.habit_type == HabitType.QUIT]
|
||||
return build_items, quit_items
|
||||
|
||||
|
||||
def toggle_check(db: Session, habit_id: int, log_date: date) -> bool:
|
||||
"""체크 상태를 반전시키고 토글 후의 체크 여부를 반환한다.
|
||||
|
||||
호출측에서 이미 habit_service.get_habit(db, habit_id, user_id)로 소유권을 검증한 뒤에만 불러야 한다.
|
||||
"""
|
||||
existing = db.scalar(select(HabitLog).where(HabitLog.habit_id == habit_id, HabitLog.log_date == log_date))
|
||||
if existing:
|
||||
db.delete(existing)
|
||||
db.commit()
|
||||
return False
|
||||
db.add(HabitLog(habit_id=habit_id, log_date=log_date))
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def toggle_check_and_celebrate(db: Session, habit: Habit, log_date: date) -> tuple[bool, int | None]:
|
||||
"""체크를 토글하고, 새로 체크되어 스트릭이 마일스톤에 도달했으면 축하 푸시를 보낸다.
|
||||
|
||||
반환값: (checked, milestone_streak). milestone_streak은 이번 토글로 막 달성한 마일스톤 값이면 그 값,
|
||||
체크 해제거나 마일스톤이 아니면 None.
|
||||
"""
|
||||
checked = toggle_check(db, habit.id, log_date)
|
||||
if not checked:
|
||||
return checked, None
|
||||
|
||||
streak = get_habit_stats(db, habit).current_streak
|
||||
if streak not in MILESTONE_STREAKS:
|
||||
return checked, None
|
||||
|
||||
if habit.user_id is not None:
|
||||
push_service.send_to_user(
|
||||
db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today"
|
||||
)
|
||||
return checked, streak
|
||||
|
||||
|
||||
def list_logs(
|
||||
db: Session, user_id: int, habit_id: int | None = None, start: date | None = None, end: date | None = None
|
||||
) -> list[HabitLog]:
|
||||
stmt = select(HabitLog).join(Habit, HabitLog.habit_id == Habit.id).where(Habit.user_id == user_id)
|
||||
if habit_id is not None:
|
||||
stmt = stmt.where(HabitLog.habit_id == habit_id)
|
||||
if start is not None:
|
||||
stmt = stmt.where(HabitLog.log_date >= start)
|
||||
if end is not None:
|
||||
stmt = stmt.where(HabitLog.log_date <= end)
|
||||
stmt = stmt.order_by(HabitLog.log_date)
|
||||
return list(db.scalars(stmt))
|
||||
|
||||
|
||||
def _checked_counts_by_date(db: Session, habit_ids: list[int], start: date, end: date) -> dict[date, int]:
|
||||
if not habit_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(HabitLog.log_date, func.count(HabitLog.id))
|
||||
.where(HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(start, end))
|
||||
.group_by(HabitLog.log_date)
|
||||
).all()
|
||||
return {row[0]: row[1] for row in rows}
|
||||
|
||||
|
||||
def get_monthly_summary(db: Session, user_id: int, year: int, month: int) -> list[MonthlySummaryDay]:
|
||||
"""해당 월의 날짜별 예정 습관 수 / 체크된 습관 수를 집계한다 (현재 active 습관 기준)."""
|
||||
days_in_month = calendar.monthrange(year, month)[1]
|
||||
first_day = date(year, month, 1)
|
||||
last_day = date(year, month, days_in_month)
|
||||
|
||||
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
|
||||
checked_counts = _checked_counts_by_date(db, [h.id for h in active_habits], first_day, last_day)
|
||||
|
||||
summaries = []
|
||||
for day_num in range(1, days_in_month + 1):
|
||||
d = date(year, month, day_num)
|
||||
# 습관이 생성되기 전 날짜는 "예정되었지만 안 함"으로 잘못 잡히지 않도록 제외한다.
|
||||
scheduled = sum(1 for h in active_habits if h.created_at.date() <= d and h.is_scheduled_on(d.weekday()))
|
||||
summaries.append(
|
||||
MonthlySummaryDay(log_date=d, scheduled_count=scheduled, checked_count=checked_counts.get(d, 0))
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def summarize_completion_rate(summaries: list[MonthlySummaryDay], up_to: date) -> float:
|
||||
"""월별 요약에서 up_to(보통 오늘)까지 지난 날짜만 모아 전체 완료율(%)을 계산한다.
|
||||
|
||||
아직 지나지 않은 미래 날짜는 scheduled_count는 있어도 checked_count가 항상 0이라
|
||||
포함시키면 완료율이 부당하게 낮아지므로 제외한다.
|
||||
"""
|
||||
past = [s for s in summaries if s.log_date <= up_to]
|
||||
total_scheduled = sum(s.scheduled_count for s in past)
|
||||
total_checked = sum(s.checked_count for s in past)
|
||||
return round(total_checked / total_scheduled * 100, 1) if total_scheduled else 0.0
|
||||
|
||||
|
||||
def get_period_completion_rate(db: Session, user_id: int, start: date, end: date) -> tuple[float, int, int]:
|
||||
"""[start, end] 구간(양끝 포함)의 예정/체크 수를 집계해 완료율(%)과 함께 반환한다.
|
||||
|
||||
get_monthly_summary와 같은 규칙(현재 active 습관 기준, 습관 생성일 이전 제외)을 임의 기간에
|
||||
적용한 버전 — 주간 요약 알림처럼 달력 월 경계에 안 맞는 기간을 집계할 때 쓴다.
|
||||
"""
|
||||
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
|
||||
checked_counts = _checked_counts_by_date(db, [h.id for h in active_habits], start, end)
|
||||
|
||||
total_scheduled = 0
|
||||
total_checked = 0
|
||||
d = start
|
||||
while d <= end:
|
||||
total_scheduled += sum(
|
||||
1 for h in active_habits if h.created_at.date() <= d and h.is_scheduled_on(d.weekday())
|
||||
)
|
||||
total_checked += checked_counts.get(d, 0)
|
||||
d += timedelta(days=1)
|
||||
|
||||
rate = round(total_checked / total_scheduled * 100, 1) if total_scheduled else 0.0
|
||||
return rate, total_scheduled, total_checked
|
||||
|
||||
|
||||
def get_habit_stats(db: Session, habit: Habit) -> HabitStats:
|
||||
"""습관 생성일부터 오늘까지의 완료율과, 오늘(또는 어제)부터 거슬러 올라간 연속 달성일을 계산한다.
|
||||
|
||||
요일 스케줄은 현재 습관의 weekdays_mask를 과거에도 그대로 적용한 것으로 간주한다
|
||||
(과거 요일 변경 이력은 추적하지 않음 — 월별/주별 집계와 같은 단순화).
|
||||
"""
|
||||
today = date.today()
|
||||
start = habit.created_at.date()
|
||||
|
||||
checked_dates = set(db.scalars(select(HabitLog.log_date).where(HabitLog.habit_id == habit.id)))
|
||||
|
||||
scheduled_days = 0
|
||||
checked_days = 0
|
||||
d = start
|
||||
while d <= today:
|
||||
if habit.is_scheduled_on(d.weekday()):
|
||||
scheduled_days += 1
|
||||
if d in checked_dates:
|
||||
checked_days += 1
|
||||
d += timedelta(days=1)
|
||||
|
||||
completion_rate = round(checked_days / scheduled_days * 100, 1) if scheduled_days else 0.0
|
||||
|
||||
streak = 0
|
||||
d = today
|
||||
while d >= start:
|
||||
if habit.is_scheduled_on(d.weekday()):
|
||||
if d in checked_dates:
|
||||
streak += 1
|
||||
elif d != today:
|
||||
break
|
||||
# d가 오늘이고 아직 체크 전이면: 하루가 아직 안 끝났으니 스트릭을 끊지 않고 계속 거슬러 올라간다.
|
||||
d -= timedelta(days=1)
|
||||
|
||||
return HabitStats(
|
||||
completion_rate=completion_rate,
|
||||
current_streak=streak,
|
||||
scheduled_days=scheduled_days,
|
||||
checked_days=checked_days,
|
||||
)
|
||||
|
||||
|
||||
def get_weekly_matrix(db: Session, user_id: int, week_start: date) -> list[WeeklyMatrixRow]:
|
||||
"""week_start(호출측에서 정한 주 시작일, 현재 /history는 일요일을 사용)부터 7일간,
|
||||
active 습관별 요일 체크 매트릭스를 반환한다. 이 함수 자체는 week_start가 어떤 요일이든 상관없다."""
|
||||
week_days = [week_start + timedelta(days=i) for i in range(7)]
|
||||
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
|
||||
habit_ids = [h.id for h in active_habits]
|
||||
|
||||
checked_pairs: set[tuple[int, date]] = set()
|
||||
if habit_ids:
|
||||
rows = db.execute(
|
||||
select(HabitLog.habit_id, HabitLog.log_date).where(
|
||||
HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(week_days[0], week_days[-1])
|
||||
)
|
||||
).all()
|
||||
checked_pairs = {(r[0], r[1]) for r in rows}
|
||||
|
||||
today = date.today()
|
||||
result = []
|
||||
for h in active_habits:
|
||||
checks: dict[str, bool | None] = {}
|
||||
scheduled_past = 0
|
||||
checked_past = 0
|
||||
for d in week_days:
|
||||
# 습관이 생성되기 전 날짜는 요일이 맞아도 "예정 없음"으로 취급한다.
|
||||
if d < h.created_at.date() or not h.is_scheduled_on(d.weekday()):
|
||||
checks[d.isoformat()] = None
|
||||
continue
|
||||
is_checked = (h.id, d) in checked_pairs
|
||||
checks[d.isoformat()] = is_checked
|
||||
if d <= today:
|
||||
scheduled_past += 1
|
||||
if is_checked:
|
||||
checked_past += 1
|
||||
completion_rate = round(checked_past / scheduled_past * 100, 1) if scheduled_past else 0.0
|
||||
result.append(
|
||||
WeeklyMatrixRow(
|
||||
habit_id=h.id,
|
||||
name=h.name,
|
||||
habit_type=h.habit_type.value,
|
||||
checks=checks,
|
||||
completion_rate=completion_rate,
|
||||
)
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
|
||||
from pywebpush import WebPushException, webpush
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models.push_subscription import PushSubscription
|
||||
from app.schemas.push import PushSubscribeRequest
|
||||
|
||||
|
||||
def list_subscriptions(db: Session, user_id: int) -> list[PushSubscription]:
|
||||
return list(db.scalars(select(PushSubscription).where(PushSubscription.user_id == user_id)))
|
||||
|
||||
|
||||
def save_subscription(
|
||||
db: Session, user_id: int, data: PushSubscribeRequest, user_agent: str | None = None
|
||||
) -> PushSubscription:
|
||||
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint))
|
||||
if existing:
|
||||
existing.user_id = user_id # 같은 기기에서 다른 유저가 재구독하면 소유자를 갱신한다.
|
||||
existing.p256dh_key = data.keys.p256dh
|
||||
existing.auth_key = data.keys.auth
|
||||
db.commit()
|
||||
return existing
|
||||
|
||||
sub = PushSubscription(
|
||||
user_id=user_id,
|
||||
endpoint=data.endpoint,
|
||||
p256dh_key=data.keys.p256dh,
|
||||
auth_key=data.keys.auth,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
db.add(sub)
|
||||
db.commit()
|
||||
db.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
def delete_subscription(db: Session, endpoint: str) -> None:
|
||||
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == endpoint))
|
||||
if existing:
|
||||
db.delete(existing)
|
||||
db.commit()
|
||||
|
||||
|
||||
def send_to_user(db: Session, user_id: int, title: str, body: str, url: str = "/today") -> int:
|
||||
"""해당 유저의 구독자에게만 알림을 보낸다. 만료된(410/404) 구독은 자동으로 삭제한다. 성공 발송 건수를 반환."""
|
||||
payload = json.dumps({"title": title, "body": body, "url": url}, ensure_ascii=False)
|
||||
sent = 0
|
||||
for sub in list_subscriptions(db, user_id):
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": sub.endpoint,
|
||||
"keys": {"p256dh": sub.p256dh_key, "auth": sub.auth_key},
|
||||
},
|
||||
data=payload,
|
||||
vapid_private_key=settings.vapid_private_key,
|
||||
vapid_claims={"sub": settings.vapid_subject},
|
||||
)
|
||||
sent += 1
|
||||
except WebPushException as exc:
|
||||
status_code = exc.response.status_code if exc.response is not None else None
|
||||
if status_code in (404, 410):
|
||||
db.delete(sub)
|
||||
db.commit()
|
||||
# 그 외 오류(일시적 네트워크 문제 등)는 건너뛰고 다음 구독자에게 계속 발송한다.
|
||||
return sent
|
||||
@@ -0,0 +1,180 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
|
||||
from app.services import habit_service, log_service, push_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = BackgroundScheduler(timezone=settings.timezone)
|
||||
|
||||
|
||||
def _claim_notification_slot(db, habit_id: int, notify_date) -> bool:
|
||||
"""habit_notification_log에 (habit_id, notify_date) 행을 먼저 "선점"한다.
|
||||
|
||||
(habit_id, notify_date) 유니크 제약을 경합 방지용 락으로 쓴다 — reload로 겹친 워커나
|
||||
APScheduler가 중복 기동된 상황에서도 두 프로세스가 동시에 같은 알림을 보내지 않도록,
|
||||
실제 발송 전에 먼저 이 행을 커밋해서 선점에 성공한 쪽만 발송하게 한다.
|
||||
"""
|
||||
db.add(HabitNotificationLog(habit_id=habit_id, notify_date=notify_date))
|
||||
try:
|
||||
db.commit()
|
||||
return True
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def _claim_summary_slot(db: Session, user_id: int, period_type: str, period_start: date) -> bool:
|
||||
"""summary_notification_log에 (user_id, period_type, period_start) 행을 선점한다.
|
||||
|
||||
_claim_notification_slot과 같은 목적 — 리로드로 겹친 워커나 중복 기동된 스케줄러가
|
||||
같은 주간/월간 요약을 두 번 보내지 않도록 유니크 제약을 경합 방지 락으로 쓴다.
|
||||
"""
|
||||
db.add(SummaryNotificationLog(user_id=user_id, period_type=period_type, period_start=period_start))
|
||||
try:
|
||||
db.commit()
|
||||
return True
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def _tick() -> None:
|
||||
"""매분 실행되어, 지금 이 순간이 알람 시각+요일에 맞는 active 습관에 대해
|
||||
오늘 아직 안 보낸 알림만 골라 발송한다."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
now = datetime.now(ZoneInfo(settings.timezone))
|
||||
today = now.date()
|
||||
weekday = today.weekday()
|
||||
|
||||
habits = habit_service.list_active_habits_with_reminders(db)
|
||||
for habit in habits:
|
||||
if habit.user_id is None or not habit.is_scheduled_on(weekday):
|
||||
continue
|
||||
if habit.reminder_time.hour != now.hour or habit.reminder_time.minute != now.minute:
|
||||
continue
|
||||
|
||||
already_sent = db.scalar(
|
||||
select(HabitNotificationLog).where(
|
||||
HabitNotificationLog.habit_id == habit.id,
|
||||
HabitNotificationLog.notify_date == today,
|
||||
)
|
||||
)
|
||||
if already_sent:
|
||||
continue
|
||||
|
||||
try:
|
||||
claimed = _claim_notification_slot(db, habit.id, today)
|
||||
if not claimed:
|
||||
continue
|
||||
push_service.send_to_user(
|
||||
db, habit.user_id, title=habit.name, body="지금 실천할 시간이에요", url="/today"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("습관(id=%s) 알림 발송 중 오류", habit.id)
|
||||
db.rollback()
|
||||
except Exception:
|
||||
logger.exception("습관 알림 tick 처리 중 오류")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _send_period_summaries(
|
||||
db: Session, *, period_type: str, period_start: date, range_start: date, range_end: date, title: str, url: str
|
||||
) -> None:
|
||||
"""유저별로 [range_start, range_end] 완료율을 계산해 요약 푸시를 보낸다 (주간/월간 tick 공통 로직).
|
||||
|
||||
예정된 습관이 하나도 없던(scheduled == 0) 유저에게는 의미 없는 알림을 보내지 않고 건너뛴다.
|
||||
"""
|
||||
for user_id in habit_service.list_active_user_ids(db):
|
||||
rate, scheduled, checked = log_service.get_period_completion_rate(db, user_id, range_start, range_end)
|
||||
if scheduled == 0:
|
||||
continue
|
||||
try:
|
||||
if not _claim_summary_slot(db, user_id, period_type, period_start):
|
||||
continue
|
||||
push_service.send_to_user(
|
||||
db, user_id, title=title, body=f"완료율 {rate}% ({checked}/{scheduled})이에요.", url=url
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("유저(id=%s) %s 요약 알림 발송 중 오류", user_id, period_type)
|
||||
db.rollback()
|
||||
|
||||
|
||||
def _weekly_summary_tick() -> None:
|
||||
"""매주 일요일 21시에 실행되어, 이번 주(월요일~오늘)의 완료율을 유저별로 요약해 발송한다."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
today = datetime.now(ZoneInfo(settings.timezone)).date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
_send_period_summaries(
|
||||
db,
|
||||
period_type="weekly",
|
||||
period_start=week_start,
|
||||
range_start=week_start,
|
||||
range_end=today,
|
||||
title="이번 주 습관 리포트",
|
||||
url=f"/history?view=week&start={week_start.isoformat()}",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("주간 요약 tick 처리 중 오류")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _monthly_summary_tick() -> None:
|
||||
"""매월 마지막 날 21:30에 실행되어, 이번 달(1일~오늘)의 완료율을 유저별로 요약해 발송한다."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
today = datetime.now(ZoneInfo(settings.timezone)).date()
|
||||
month_start = today.replace(day=1)
|
||||
_send_period_summaries(
|
||||
db,
|
||||
period_type="monthly",
|
||||
period_start=month_start,
|
||||
range_start=month_start,
|
||||
range_end=today,
|
||||
title="이번 달 습관 리포트",
|
||||
url=f"/history?view=month&year={today.year}&month={today.month}",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("월간 요약 tick 처리 중 오류")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
scheduler.add_job(_tick, "cron", minute="*", id="habit_reminder_tick", replace_existing=True)
|
||||
scheduler.add_job(
|
||||
_weekly_summary_tick,
|
||||
"cron",
|
||||
day_of_week="sun",
|
||||
hour=21,
|
||||
minute=0,
|
||||
id="weekly_summary_tick",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
_monthly_summary_tick,
|
||||
"cron",
|
||||
day="last",
|
||||
hour=21,
|
||||
minute=30,
|
||||
id="monthly_summary_tick",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
|
||||
def shutdown_scheduler() -> None:
|
||||
scheduler.shutdown(wait=False)
|
||||
Reference in New Issue
Block a user