Files
habit-tracker/app/routers/journal_pages.py
T
shinalokandClaude Sonnet 5 c466cc6639 journal: multi-select emotions and per-category writing templates
- Mood switches from a single enum column to a many-to-many
  JournalEntryMood table so an entry can carry several feelings at
  once; the vocabulary is trimmed to 9 named emotions (dropped the
  overlapping satisfaction scale) with unified noun-style labels.
- Categories can define a content_template that pre-fills the "new
  entry" textarea when selected (only if the user hasn't started
  typing), seeded with a Story/Feelings/Decisions/Insights/Actions
  reflection template on the default "일상" category.
- Fixes a real attribute-injection bug found while building the
  template feature: Jinja's built-in |tojson filter doesn't escape
  double quotes, which breaks a double-quoted x-data="..." attribute
  when the JSON payload contains one; added |forceescape and a
  regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 19:12:59 +09:00

356 lines
12 KiB
Python

import calendar
from datetime import date
from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.journal import JournalMood
from app.routers.pages import _current_user_or_redirect, templates
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
from app.services import journal_service
router = APIRouter()
# (enum 값, 이모지, 한글 라벨). 기분 선택 pill(작성/수정 폼)과 표시(day-detail)에서 공유해서 쓴다.
# 여러 개를 동시에 고를 수 있어서(다중 선택) 라벨은 전부 명사형으로 통일한다.
JOURNAL_MOOD_OPTIONS = [
(JournalMood.PAIN, "🤕", "아픔"),
(JournalMood.ACHIEVEMENT, "🏆", "성취"),
(JournalMood.ANGER, "😠", "분노"),
(JournalMood.EXCITED, "🤩", "신남"),
(JournalMood.CALM, "😌", "평온"),
(JournalMood.HAPPY, "😊", "행복"),
(JournalMood.WORRY, "😟", "걱정"),
(JournalMood.TIRED, "😪", "피곤"),
(JournalMood.SAD, "😢", "슬픔"),
]
templates.env.globals["journal_mood_options"] = JOURNAL_MOOD_OPTIONS
templates.env.globals["journal_mood_emoji"] = {m.value: emoji for m, emoji, _ in JOURNAL_MOOD_OPTIONS}
# Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면
# 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다.
templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False}
def _parse_moods(raw: str) -> list[JournalMood]:
moods = []
for v in raw.split(","):
v = v.strip()
if not v:
continue
try:
moods.append(JournalMood(v))
except ValueError:
continue
return moods
def _parse_tags(raw: str) -> list[str]:
return [t for t in raw.split(",")]
def _month_context(db: Session, user_id: int, year: int, month: int, category_id: int | None) -> dict:
summary_map = journal_service.get_monthly_journal_summary(db, user_id, year, month, category_id)
weeks = calendar.Calendar(firstweekday=6).monthdatescalendar(year, month)
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 {
"year": year,
"month": month,
"weeks": weeks,
"summary_map": summary_map,
"prev_year": prev_year,
"prev_month": prev_month,
"next_year": next_year,
"next_month": next_month,
}
def _render_day_detail(request: Request, db: Session, user_id: int, entry_date: date, **extra):
return templates.TemplateResponse(
request,
"partials/journal_day_detail.html",
{
"entry_date": entry_date,
"items": journal_service.get_day_entries(db, user_id, entry_date),
"categories": journal_service.list_categories(db, user_id),
**extra,
},
)
@router.get("/journal")
def journal_page(
request: Request,
tab: str = "calendar",
year: int | None = None,
month: int | None = None,
category_id: int | None = None,
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
journal_service.ensure_default_category(db, current.id)
categories = journal_service.list_categories(db, current.id)
today = date.today()
year = year or today.year
month = month or today.month
tab = "manage" if tab == "manage" else "calendar"
return templates.TemplateResponse(
request,
"journal.html",
{
"logged_in": True,
"current_user": current,
"tab": tab,
"categories": categories,
"category_id": category_id,
"category_templates": {str(c.id): c.content_template or "" for c in categories},
"entry_counts": journal_service.count_entries_by_category(db, current.id) if tab == "manage" else {},
"on_this_day": journal_service.get_on_this_day(db, current.id, today),
"prompt": journal_service.get_random_prompt(db),
"today_iso": today.isoformat(),
**_month_context(db, current.id, year, month, category_id),
},
)
@router.get("/journal/day/{entry_date}")
def journal_day_detail(request: Request, entry_date: date, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
return _render_day_detail(request, db, current.id, entry_date)
@router.post("/journal/new")
def create_entry_page(
request: Request,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryCreate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
except ValueError:
return HTMLResponse("입력값을 확인해주세요")
entry = journal_service.create_entry(db, current.id, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError as exc:
return HTMLResponse(str(exc))
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/journal?year={parsed_date.year}&month={parsed_date.month}"
return response
@router.post("/journal/{entry_id}/edit")
def edit_entry_page(
request: Request,
entry_id: int,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
original_date = entry.entry_date
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryUpdate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return _render_day_detail(
request, db, current.id, original_date, edit_error=message, editing_entry_id=entry_id
)
except ValueError:
return _render_day_detail(
request,
db,
current.id,
original_date,
edit_error="입력값을 확인해주세요",
editing_entry_id=entry_id,
)
journal_service.update_entry(db, entry, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError:
pass # 첨부 실패는 조용히 넘어간다 — 본문 수정은 이미 반영됐다
return _render_day_detail(request, db, current.id, parsed_date)
@router.post("/journal/{entry_id}/delete")
def delete_entry_page(request: Request, entry_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_entry(db, entry)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/{entry_id}/attachments/{attachment_id}/delete")
def delete_attachment_page(
request: Request, entry_id: int, attachment_id: int, db: Session = Depends(get_db)
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
attachment = journal_service.get_attachment(db, attachment_id, current.id)
if attachment is None or attachment.entry_id != entry.id:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_attachment(db, attachment)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/categories/new")
def create_category_page(
request: Request,
name: str = Form(""),
color: str | None = Form(None),
content_template: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
try:
data = JournalCategoryCreate(name=name, color=color, content_template=content_template)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.create_category(db, current.id, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/edit")
def edit_category_page(
request: Request,
category_id: int,
name: str = Form(""),
color: str | None = Form(None),
content_template: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
try:
data = JournalCategoryCreate(name=name, color=color, content_template=content_template)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.update_category(db, category, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/delete")
def delete_category_page(request: Request, category_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
journal_service.delete_category(db, category)
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response