add journaling feature: categories, tags, attachments, calendar, and multi-select emotions
Adds an 8th development stage that lets users keep a free-form journal alongside habit tracking, reusing the existing Google OAuth/DB/PWA infrastructure instead of a separate project. Users organize entries into custom categories, filter by a month calendar with day-detail drill-down, attach photos/videos (served via an authenticated route, never /static), tag entries, and pick multiple emotions per entry from a curated 9-option set. Includes a global journal prompt bank for lightweight guided journaling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.journal import JournalCategoryReorderRequest
|
||||
from app.security import require_login
|
||||
from app.services import journal_service
|
||||
|
||||
router = APIRouter(prefix="/api/journal", tags=["journal"], dependencies=[Depends(require_login)])
|
||||
|
||||
|
||||
@router.get("/media/{attachment_id}")
|
||||
def get_media(
|
||||
attachment_id: int,
|
||||
thumbnail: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_login),
|
||||
):
|
||||
attachment = journal_service.get_attachment(db, attachment_id, current_user.id)
|
||||
if attachment is None:
|
||||
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
|
||||
|
||||
path = attachment.thumbnail_path if (thumbnail and attachment.thumbnail_path) else attachment.file_path
|
||||
return FileResponse(path, filename=attachment.original_filename)
|
||||
|
||||
|
||||
@router.post("/categories/reorder")
|
||||
def reorder_categories(
|
||||
data: JournalCategoryReorderRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_login),
|
||||
):
|
||||
journal_service.reorder_categories(db, current_user.id, data.category_ids)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,346 @@
|
||||
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}
|
||||
|
||||
|
||||
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,
|
||||
"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), 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)
|
||||
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),
|
||||
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)
|
||||
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
|
||||
Reference in New Issue
Block a user