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.markdown_utils import render_markdown 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} templates.env.filters["markdown"] = render_markdown 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/preview") def preview_entry_content(request: Request, content: str = Form(""), db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) if isinstance(current, RedirectResponse): return current if not content.strip(): return HTMLResponse('미리보기가 여기에 표시돼요') return HTMLResponse(render_markdown(content)) @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 if journal_service.get_category(db, category_id, current.id) is None: return HTMLResponse("카테고리를 찾을 수 없어요") 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 if journal_service.get_category(db, category_id, current.id) is None: return _render_day_detail( request, db, current.id, original_date, edit_error="카테고리를 찾을 수 없어요", editing_entry_id=entry_id, ) 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