- 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>
356 lines
12 KiB
Python
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
|