Files
habit-tracker/app/routers/pages.py
T
shinalokandClaude Sonnet 5 ef2a3ea082 prepare app for public app store distribution
- rebrand from 습관 트래커 to 해빗랩 across templates, manifest, service worker
- add HTTPS redirect middleware for reverse-proxied deployments
- add public landing page (/) and privacy policy page with real data
  handling disclosures
- add in-app account deletion (Apple review requirement)
- add Android TWA Digital Asset Links support (/.well-known/assetlinks.json)
- add SFTP deployment script for the Synology-hosted server

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 13:41:50 +09:00

462 lines
16 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("/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,
},
)