Files
habit-tracker/app/services/journal_service.py
T
shinalokandClaude Sonnet 5 bdf9d0bae7 journal: real markdown editor (EasyMDE) with live preview toggle, fix default template
- Replace the plain textarea with EasyMDE (vendored locally, no CDN) for
  markdown authoring: syntax highlighting, smart list continuation, and a
  custom text-based toolbar (built-in EasyMDE toolbar icons require Font
  Awesome from a CDN, which this app doesn't use). unorderedListStyle is set
  to "-" to match the app's own template convention.
- Add a preview/edit toggle button that swaps the editor for the exact same
  server-rendered markdown (via /journal/preview) shown after saving, instead
  of always showing both.
- Fix create/edit entry routes to verify the submitted category_id actually
  belongs to the current user before inserting -- every other write path in
  this app already checked ownership; this one didn't (found while manually
  testing the new editor with a typo'd category id that happened to belong to
  someone else's category, which surfaced as an IntegrityError 500 instead of
  a clean 404-equivalent).
- Fix the default "일상" category template: bare "-" bullet lines don't parse
  as list items in the markdown renderer (they need a trailing space), and
  the content_template validator was silently stripping that trailing space
  off on every save. Backfill migration updates any category still holding
  the old, broken template text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:18 +09:00

399 lines
14 KiB
Python

import calendar
import mimetypes
import random
import uuid
from datetime import date
from pathlib import Path
from fastapi import UploadFile
from PIL import Image
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.models.journal import (
JournalAttachment,
JournalAttachmentType,
JournalCategory,
JournalEntry,
JournalEntryMood,
JournalPrompt,
JournalTag,
)
from app.schemas.journal import (
JournalAttachmentOut,
JournalCalendarDay,
JournalCategoryCreate,
JournalDayDetailItem,
JournalEntryCreate,
JournalEntryUpdate,
JournalOnThisDayItem,
)
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
THUMBNAIL_WIDTH = 400
DEFAULT_CATEGORY_NAME = "일상"
# 목록 기호("- ") 뒤에 공백이 없으면(그냥 "-"만 있으면) markdown 라이브러리가 목록으로 안 잡고
# 그냥 문단 텍스트로 렌더링한다 — 그래서 다섯 줄 다 "- "(대시+공백)로 통일해야 실제로
# 빈 체크리스트 항목(<li></li>)이 만들어진다. 제목 줄과 "-" 사이에 빈 줄이 없으면 markdown이
# 그 "-"를 목록이 아니라 제목 밑줄(setext heading)로 오인해서 제목 자체가 사라지므로 빈 줄도 필수.
_BULLET = "- " # 뒤 공백이 핵심 — 트리플쿼트 문자열 끝의 trailing space는 도구를 거치며 잘려나가서
# 여기서는 따옴표 "안쪽"에 명시적으로 넣어 안 잘리게 한다.
DEFAULT_CATEGORY_TEMPLATE = "\n\n".join(
[
"**1. Story : 오늘 무슨 일이 있었나요?**",
_BULLET,
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
_BULLET,
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
_BULLET,
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
_BULLET,
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
_BULLET,
]
)
# ---- 카테고리 ----
def list_categories(db: Session, user_id: int) -> list[JournalCategory]:
stmt = select(JournalCategory).where(JournalCategory.user_id == user_id)
stmt = stmt.order_by(
JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at
)
return list(db.scalars(stmt))
def get_category(db: Session, category_id: int, user_id: int) -> JournalCategory | None:
return db.scalar(
select(JournalCategory).where(JournalCategory.id == category_id, JournalCategory.user_id == user_id)
)
def create_category(db: Session, user_id: int, data: JournalCategoryCreate) -> JournalCategory:
category = JournalCategory(
user_id=user_id, name=data.name, color=data.color, content_template=data.content_template
)
db.add(category)
db.commit()
db.refresh(category)
return category
def update_category(db: Session, category: JournalCategory, data: JournalCategoryCreate) -> JournalCategory:
category.name = data.name
category.color = data.color
category.content_template = data.content_template
db.commit()
db.refresh(category)
return category
def ensure_default_category(db: Session, user_id: int) -> JournalCategory:
"""유저가 카테고리를 하나도 만든 적 없으면 기본 카테고리를 자동 생성한다."""
existing = db.scalar(
select(JournalCategory)
.where(JournalCategory.user_id == user_id)
.order_by(JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at)
.limit(1)
)
if existing is not None:
return existing
return create_category(
db, user_id, JournalCategoryCreate(name=DEFAULT_CATEGORY_NAME, content_template=DEFAULT_CATEGORY_TEMPLATE)
)
def reorder_categories(db: Session, user_id: int, ordered_ids: list[int]) -> None:
categories = db.scalars(
select(JournalCategory).where(JournalCategory.id.in_(ordered_ids), JournalCategory.user_id == user_id)
).all()
category_map = {c.id: c for c in categories}
for index, category_id in enumerate(ordered_ids):
category = category_map.get(category_id)
if category is not None:
category.sort_order = index
db.commit()
def count_entries_by_category(db: Session, user_id: int) -> dict[int, int]:
rows = db.execute(
select(JournalEntry.category_id, func.count(JournalEntry.id))
.where(JournalEntry.user_id == user_id)
.group_by(JournalEntry.category_id)
).all()
return {row[0]: row[1] for row in rows}
def delete_category(db: Session, category: JournalCategory) -> None:
"""카테고리를 지우면 안의 엔트리도 함께 지워진다 — 첨부파일 디스크 삭제까지 하려면
ORM cascade에만 맡기지 않고 delete_entry를 하나씩 거쳐야 한다."""
entries = list(db.scalars(select(JournalEntry).where(JournalEntry.category_id == category.id)))
for entry in entries:
delete_entry(db, entry)
db.delete(category)
db.commit()
# ---- 태그 ----
def get_or_create_tags(db: Session, user_id: int, names: list[str]) -> list[JournalTag]:
tags = []
for name in names:
tag = db.scalar(select(JournalTag).where(JournalTag.user_id == user_id, JournalTag.name == name))
if tag is None:
tag = JournalTag(user_id=user_id, name=name)
db.add(tag)
db.flush()
tags.append(tag)
return tags
# ---- 엔트리 ----
def list_entries(
db: Session,
user_id: int,
category_id: int | None = None,
start: date | None = None,
end: date | None = None,
) -> list[JournalEntry]:
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
if start is not None:
stmt = stmt.where(JournalEntry.entry_date >= start)
if end is not None:
stmt = stmt.where(JournalEntry.entry_date <= end)
stmt = stmt.order_by(JournalEntry.entry_date.desc(), JournalEntry.created_at.desc())
return list(db.scalars(stmt))
def get_entry(db: Session, entry_id: int, user_id: int) -> JournalEntry | None:
return db.scalar(select(JournalEntry).where(JournalEntry.id == entry_id, JournalEntry.user_id == user_id))
def create_entry(db: Session, user_id: int, data: JournalEntryCreate) -> JournalEntry:
tags = get_or_create_tags(db, user_id, data.tags)
entry = JournalEntry(
user_id=user_id,
category_id=data.category_id,
entry_date=data.entry_date,
title=data.title,
content=data.content,
tags=tags,
moods=[JournalEntryMood(mood=m) for m in data.moods],
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def update_entry(db: Session, entry: JournalEntry, data: JournalEntryUpdate) -> JournalEntry:
tags = get_or_create_tags(db, entry.user_id, data.tags)
entry.category_id = data.category_id
entry.entry_date = data.entry_date
entry.title = data.title
entry.content = data.content
entry.tags = tags
entry.moods = [JournalEntryMood(mood=m) for m in data.moods]
db.commit()
db.refresh(entry)
return entry
def delete_entry(db: Session, entry: JournalEntry) -> None:
for attachment in list(entry.attachments):
_delete_attachment_files(attachment)
db.delete(entry)
db.commit()
# ---- 첨부파일 ----
def _media_dir(user_id: int, entry_id: int) -> Path:
return Path(settings.journal_media_root) / str(user_id) / str(entry_id)
def _delete_attachment_files(attachment: JournalAttachment) -> None:
for path_str in (attachment.file_path, attachment.thumbnail_path):
if path_str:
Path(path_str).unlink(missing_ok=True)
def save_attachment(db: Session, entry: JournalEntry, upload_file: UploadFile) -> JournalAttachment:
content_type = upload_file.content_type or ""
if content_type in ALLOWED_IMAGE_TYPES:
media_type = JournalAttachmentType.IMAGE
elif content_type in ALLOWED_VIDEO_TYPES:
media_type = JournalAttachmentType.VIDEO
else:
raise ValueError("지원하지 않는 파일 형식이에요 (사진: jpg/png/webp/gif, 영상: mp4/mov)")
data = upload_file.file.read()
max_bytes = settings.journal_max_upload_mb * 1024 * 1024
if len(data) > max_bytes:
raise ValueError(f"파일 용량은 {settings.journal_max_upload_mb}MB를 넘을 수 없어요")
target_dir = _media_dir(entry.user_id, entry.id)
target_dir.mkdir(parents=True, exist_ok=True)
ext = mimetypes.guess_extension(content_type) or Path(upload_file.filename or "").suffix or ""
stored_name = f"{uuid.uuid4().hex}{ext}"
file_path = target_dir / stored_name
file_path.write_bytes(data)
thumbnail_path: Path | None = None
if media_type == JournalAttachmentType.IMAGE:
thumbnail_path = target_dir / f"{uuid.uuid4().hex}_thumb.jpg"
try:
with Image.open(file_path) as img:
img = img.convert("RGB")
w, h = img.size
if w > THUMBNAIL_WIDTH:
img = img.resize((THUMBNAIL_WIDTH, round(h * THUMBNAIL_WIDTH / w)))
img.save(thumbnail_path, "JPEG", quality=85)
except Exception:
# 손상되었거나 Pillow가 못 읽는 이미지여도 원본 업로드 자체는 실패시키지 않는다.
thumbnail_path.unlink(missing_ok=True)
thumbnail_path = None
attachment = JournalAttachment(
entry_id=entry.id,
media_type=media_type,
file_path=str(file_path),
thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
original_filename=upload_file.filename or stored_name,
file_size=len(data),
)
db.add(attachment)
db.commit()
db.refresh(attachment)
return attachment
def get_attachment(db: Session, attachment_id: int, user_id: int) -> JournalAttachment | None:
return db.scalar(
select(JournalAttachment)
.join(JournalEntry, JournalAttachment.entry_id == JournalEntry.id)
.where(JournalAttachment.id == attachment_id, JournalEntry.user_id == user_id)
)
def delete_attachment(db: Session, attachment: JournalAttachment) -> None:
_delete_attachment_files(attachment)
db.delete(attachment)
db.commit()
# ---- 캘린더 / day-detail / 회상 ----
def _to_day_detail_item(entry: JournalEntry) -> JournalDayDetailItem:
return JournalDayDetailItem(
id=entry.id,
category_id=entry.category_id,
category_name=entry.category.name,
category_color=entry.category.color,
title=entry.title,
content=entry.content,
moods=[m.mood for m in entry.moods],
tags=[t.name for t in entry.tags],
attachments=[
JournalAttachmentOut(
id=a.id,
media_type=a.media_type,
original_filename=a.original_filename,
has_thumbnail=a.thumbnail_path is not None,
)
for a in entry.attachments
],
)
def get_monthly_journal_summary(
db: Session, user_id: int, year: int, month: int, category_id: int | None = None
) -> dict[date, JournalCalendarDay]:
"""해당 월의 날짜별 엔트리 개수와, 그날 등장한 카테고리 색상 목록(점 표시용)을 집계한다."""
days_in_month = calendar.monthrange(year, month)[1]
first_day = date(year, month, 1)
last_day = date(year, month, days_in_month)
stmt = (
select(JournalEntry.entry_date, JournalCategory.color)
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
rows = db.execute(stmt).all()
counts: dict[date, int] = {}
colors_by_date: dict[date, list[str]] = {}
for entry_date, color in rows:
counts[entry_date] = counts.get(entry_date, 0) + 1
colors = colors_by_date.setdefault(entry_date, [])
color = color or "var(--color-accent)"
if color not in colors:
colors.append(color)
return {
d: JournalCalendarDay(entry_date=d, total_count=counts[d], category_colors=colors_by_date[d])
for d in counts
}
def get_day_entries(
db: Session, user_id: int, target_date: date, category_id: int | None = None
) -> list[JournalDayDetailItem]:
stmt = select(JournalEntry).where(
JournalEntry.user_id == user_id, JournalEntry.entry_date == target_date
)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
stmt = stmt.order_by(JournalEntry.created_at)
return [_to_day_detail_item(e) for e in db.scalars(stmt)]
def get_on_this_day(db: Session, user_id: int, today: date) -> list[JournalOnThisDayItem]:
"""오늘과 월/일이 같은 과거 연도의 엔트리를 반환한다("1년 전 오늘" 회상 카드).
MONTH()/DAY() 같은 DB 종속 함수 대신 파이썬에서 필터링해 SQLite(테스트)/MariaDB(운영)
양쪽에서 동일하게 동작하게 한다 — 개인 규모 데이터라 전체 스캔 비용도 무시할 만하다.
"""
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id).order_by(JournalEntry.entry_date.desc())
matches = [
e
for e in db.scalars(stmt)
if e.entry_date.month == today.month
and e.entry_date.day == today.day
and e.entry_date.year != today.year
]
items = []
for e in matches:
base = _to_day_detail_item(e)
items.append(
JournalOnThisDayItem(
**base.model_dump(),
entry_date=e.entry_date,
years_ago=today.year - e.entry_date.year,
)
)
return items
# ---- 프롬프트 ----
def get_random_prompt(db: Session) -> JournalPrompt | None:
prompts = list(db.scalars(select(JournalPrompt)))
return random.choice(prompts) if prompts else None