import calendar from datetime import date, timedelta from datetime import time as time_type from fastapi import APIRouter, Depends, Form, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from pydantic import ValidationError from sqlalchemy.orm import Session from app.config import settings from app.database import get_db from app.models.habit import HabitDifficulty, HabitStatus, HabitType from app.models.user import User from app.schemas.habit import HabitCreate, HabitUpdate from app.security import get_current_user_optional from app.services import habit_service, log_service, user_service from app.template_utils import ( difficulty_label, goal_progress, heatmap_opacity, is_milestone_streak, level_tier_emoji, weekday_label, ) router = APIRouter() templates = Jinja2Templates(directory="app/templates") templates.env.globals["weekday_label"] = weekday_label templates.env.globals["heatmap_opacity"] = heatmap_opacity templates.env.globals["is_milestone_streak"] = is_milestone_streak templates.env.globals["difficulty_label"] = difficulty_label templates.env.globals["goal_progress"] = goal_progress templates.env.globals["level_tier_emoji"] = level_tier_emoji templates.env.globals["habit_difficulties"] = list(HabitDifficulty) def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectResponse: user = get_current_user_optional(request, db) if user is None: return RedirectResponse(url="/login", status_code=303) return user @router.get("/") def index(request: Request, db: Session = Depends(get_db)): if get_current_user_optional(request, db) is not None: return RedirectResponse(url="/today", status_code=303) # 로그인 없이도 앱 목적을 설명하는 페이지가 있어야 한다(구글 OAuth 동의 화면 "홈페이지" 요건). return templates.TemplateResponse(request, "home.html", {"logged_in": False, "current_user": None}) @router.get("/login") def login_page(request: Request, deleted: bool = False, db: Session = Depends(get_db)): if get_current_user_optional(request, db) is not None: return RedirectResponse(url="/today", status_code=303) return templates.TemplateResponse( request, "login.html", {"logged_in": False, "current_user": None, "deleted": deleted} ) @router.get("/privacy") def privacy_page(request: Request, db: Session = Depends(get_db)): user = get_current_user_optional(request, db) return templates.TemplateResponse( request, "privacy.html", {"logged_in": user is not None, "current_user": user} ) @router.get("/account") def account_page(request: Request, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current return templates.TemplateResponse(request, "account.html", {"logged_in": True, "current_user": current}) @router.post("/account/delete") def delete_account_page(request: Request, confirm_email: str = Form(""), db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current if confirm_email.strip().lower() != current.email.lower(): return templates.TemplateResponse( request, "account.html", { "logged_in": True, "current_user": current, "delete_error": "입력한 이메일이 계정 이메일과 일치하지 않습니다.", }, status_code=400, ) user_service.delete_account(db, current) response = RedirectResponse(url="/login?deleted=1", status_code=303) response.delete_cookie(settings.session_cookie_name) return response def _today_context(db: Session, user_id: int, **extra) -> dict: build_items, quit_items = log_service.get_today_items(db, user_id, date.today()) all_items = build_items + quit_items return { "build_items": build_items, "quit_items": quit_items, "yesterday_missed": log_service.get_yesterday_missed_items(db, user_id), "total_count": len(all_items), "checked_count": sum(1 for item in all_items if item.checked), "account_level": log_service.get_account_level(db, user_id), **extra, } @router.get("/today") def today_page(request: Request, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current return templates.TemplateResponse( request, "today.html", {"logged_in": True, "current_user": current, **_today_context(db, current.id)}, ) @router.post("/today/{habit_id}/toggle") def toggle_today_page(request: Request, habit_id: int, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) _, milestone_streak, level_up = log_service.toggle_check_and_celebrate(db, habit, date.today()) extra = {"celebrate_habit_name": habit.name} if (milestone_streak or level_up) else {} if milestone_streak: extra["celebrate_streak"] = milestone_streak if level_up: extra["celebrate_level_up"] = level_up return templates.TemplateResponse( request, "partials/today_content.html", {"logged_in": True, "current_user": current, **_today_context(db, current.id, **extra)}, ) @router.post("/today/{habit_id}/toggle-yesterday") def toggle_yesterday_page(request: Request, habit_id: int, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) yesterday = date.today() - timedelta(days=1) _, milestone_streak, level_up = log_service.toggle_check_and_celebrate(db, habit, yesterday) extra = {"celebrate_habit_name": habit.name} if (milestone_streak or level_up) else {} if milestone_streak: extra["celebrate_streak"] = milestone_streak if level_up: extra["celebrate_level_up"] = level_up return templates.TemplateResponse( request, "partials/today_content.html", {"logged_in": True, "current_user": current, **_today_context(db, current.id, **extra)}, ) @router.post("/today/{habit_id}/fail-yesterday") def fail_yesterday_page(request: Request, habit_id: int, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) yesterday = date.today() - timedelta(days=1) log_service.mark_failed(db, habit.id, yesterday) return templates.TemplateResponse( request, "partials/today_content.html", {"logged_in": True, "current_user": current, **_today_context(db, current.id)}, ) @router.get("/habits") def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current if tab == "completed": habits = habit_service.list_habits(db, current.id, status=HabitStatus.COMPLETED) elif tab == "abandoned": habits = habit_service.list_habits(db, current.id, status=HabitStatus.ABANDONED) else: if tab not in ("build", "quit"): tab = "build" habits = habit_service.list_habits(db, current.id, habit_type=HabitType(tab), status=HabitStatus.ACTIVE) stats_map = {h.id: log_service.get_habit_stats(db, h) for h in habits} return templates.TemplateResponse( request, "habits.html", { "logged_in": True, "current_user": current, "tab": tab, "habits": habits, "habit_type": tab, "stats_map": stats_map, "account_level": log_service.get_account_level(db, current.id), }, ) def _tab_for_habit(habit) -> str: if habit.status == HabitStatus.COMPLETED: return "completed" if habit.status == HabitStatus.ABANDONED: return "abandoned" return habit.habit_type.value @router.post("/habits/new") def create_habit_page( request: Request, name: str = Form(""), habit_type: str = Form(...), weekdays_mask: int = Form(...), difficulty: str = Form(HabitDifficulty.MEDIUM.value), condition_text: str | None = Form(None), reminder_time: str | None = Form(None), db: Session = Depends(get_db), ): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current try: parsed_time = time_type.fromisoformat(reminder_time) if reminder_time else None data = HabitCreate( name=name, habit_type=HabitType(habit_type), weekdays_mask=weekdays_mask, difficulty=HabitDifficulty(difficulty), condition_text=condition_text, reminder_time=parsed_time, ) except ValidationError as exc: message = exc.errors()[0]["msg"].removeprefix("Value error, ") return HTMLResponse(message) except ValueError: return HTMLResponse("입력값을 확인해주세요") habit_service.create_habit(db, current.id, data) response = Response(status_code=200) response.headers["HX-Redirect"] = f"/habits?tab={habit_type}" return response @router.post("/habits/{habit_id}/edit") def edit_habit_page( request: Request, habit_id: int, name: str = Form(""), weekdays_mask: int = Form(...), difficulty: str = Form(HabitDifficulty.MEDIUM.value), condition_text: str | None = Form(None), reminder_time: str | None = Form(None), db: Session = Depends(get_db), ): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) try: parsed_time = time_type.fromisoformat(reminder_time) if reminder_time else None data = HabitUpdate( name=name, habit_type=habit.habit_type, weekdays_mask=weekdays_mask, difficulty=HabitDifficulty(difficulty), condition_text=condition_text, reminder_time=parsed_time, ) except ValidationError as exc: message = exc.errors()[0]["msg"].removeprefix("Value error, ") return HTMLResponse(message) except ValueError: return HTMLResponse("입력값을 확인해주세요") habit_service.update_habit(db, habit, data) tab = _tab_for_habit(habit) response = Response(status_code=200) response.headers["HX-Redirect"] = f"/habits?tab={tab}" return response @router.post("/habits/{habit_id}/complete") def complete_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) tab = habit.habit_type.value habit_service.complete_habit(db, habit) response = Response(status_code=200) response.headers["HX-Redirect"] = f"/habits?tab={tab}" return response @router.post("/habits/{habit_id}/abandon") def abandon_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) tab = habit.habit_type.value habit_service.abandon_habit(db, habit) response = Response(status_code=200) response.headers["HX-Redirect"] = f"/habits?tab={tab}" return response @router.post("/habits/{habit_id}/reactivate") def reactivate_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) tab = _tab_for_habit(habit) habit_service.reactivate_habit(db, habit) response = Response(status_code=200) response.headers["HX-Redirect"] = f"/habits?tab={tab}" return response @router.delete("/habits/{habit_id}/delete") def delete_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) tab = _tab_for_habit(habit) habit_service.delete_habit(db, habit) response = Response(status_code=200) response.headers["HX-Redirect"] = f"/habits?tab={tab}" return response def _month_context(db: Session, user_id: int, year: int, month: int) -> dict: summaries = log_service.get_monthly_summary(db, user_id, year, month) summary_map = {s.log_date: s for s in summaries} weeks = calendar.Calendar(firstweekday=6).monthdatescalendar(year, month) # 6=일요일(calendar 모듈 기준)부터 시작 completion_rate = log_service.summarize_completion_rate(summaries, date.today()) prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1) next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1) return { "view": "month", "year": year, "month": month, "weeks": weeks, "summary_map": summary_map, "completion_rate": completion_rate, "prev_year": prev_year, "prev_month": prev_month, "next_year": next_year, "next_month": next_month, } def _week_context(db: Session, user_id: int, week_start: date) -> dict: rows = log_service.get_weekly_matrix(db, user_id, week_start) return { "view": "week", "week_start": week_start, "week_end": week_start + timedelta(days=6), "rows": rows, "prev_week": (week_start - timedelta(days=7)).isoformat(), "next_week": (week_start + timedelta(days=7)).isoformat(), } @router.get("/history/day/{log_date}") def history_day_detail(request: Request, log_date: date, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current items = log_service.get_day_detail(db, current.id, log_date) return templates.TemplateResponse( request, "partials/day_detail.html", {"log_date": log_date, "items": items}, ) @router.get("/history") def history_page( request: Request, view: str = "month", year: int | None = None, month: int | None = None, start: str | None = None, db: Session = Depends(get_db), ): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current today = date.today() if view == "week": week_start = date.fromisoformat(start) if start else today # date.weekday()는 월=0..일=6이라, 일요일까지 거슬러 올라가려면 +1 해서 나머지를 구해야 한다 # (일요일 자신은 0일 전, 월요일은 1일 전, ... 토요일은 6일 전). week_start = week_start - timedelta(days=(week_start.weekday() + 1) % 7) context = _week_context(db, current.id, week_start) else: view = "month" context = _month_context(db, current.id, year or today.year, month or today.month) return templates.TemplateResponse( request, "history.html", { "logged_in": True, "current_user": current, "account_level": log_service.get_account_level(db, current.id), **context, }, )