470 lines
17 KiB
Python
470 lines
17 KiB
Python
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("/terms")
|
|
def terms_page(request: Request, db: Session = Depends(get_db)):
|
|
user = get_current_user_optional(request, db)
|
|
return templates.TemplateResponse(
|
|
request, "terms.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,
|
|
},
|
|
)
|