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,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