Files
habit-tracker/app/models/journal.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

135 lines
5.7 KiB
Python

import enum
from datetime import date, datetime
from sqlalchemy import Column, Date, Enum, ForeignKey, Integer, String, Table, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
from app.models.habit import _by_value
class JournalMood(str, enum.Enum):
PAIN = "pain" # 아픔
ACHIEVEMENT = "achievement" # 성취
ANGER = "anger" # 분노
EXCITED = "excited" # 신남
CALM = "calm" # 평온
HAPPY = "happy" # 행복
WORRY = "worry" # 걱정
TIRED = "tired" # 피곤
SAD = "sad" # 슬픔
class JournalAttachmentType(str, enum.Enum):
IMAGE = "image"
VIDEO = "video"
journal_entry_tag = Table(
"journal_entry_tag",
Base.metadata,
Column("entry_id", ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", ForeignKey("journal_tag.id", ondelete="CASCADE"), primary_key=True),
)
class JournalCategory(Base):
__tablename__ = "journal_category"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_category_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
color: Mapped[str | None] = mapped_column(String(20), nullable=True)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워주는 틀(예: Story/Feelings/Decisions/... 회고 양식).
# 사용자가 직접 타이핑을 시작하면 더 이상 덮어쓰지 않는다(프론트에서 처리).
content_template: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
back_populates="category", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntry(Base):
__tablename__ = "journal_entry"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int] = mapped_column(
ForeignKey("journal_category.id", ondelete="CASCADE"), nullable=False
)
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
title: Mapped[str | None] = mapped_column(String(200), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)
category: Mapped["JournalCategory"] = relationship(back_populates="entries")
tags: Mapped[list["JournalTag"]] = relationship(
secondary=journal_entry_tag, back_populates="entries"
)
moods: Mapped[list["JournalEntryMood"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
attachments: Mapped[list["JournalAttachment"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntryMood(Base):
"""엔트리 하나에 여러 감정을 태그처럼 붙일 수 있게 하는 연결 테이블. mood 자체가 고정 enum이라
JournalTag처럼 별도 엔티티(이름 등)를 둘 필요가 없어 journal_entry_tag와 달리 값 자체를 PK로 쓴다."""
__tablename__ = "journal_entry_mood"
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True)
mood: Mapped[JournalMood] = mapped_column(
Enum(JournalMood, native_enum=False, length=20, values_callable=_by_value), primary_key=True
)
entry: Mapped["JournalEntry"] = relationship(back_populates="moods")
class JournalTag(Base):
__tablename__ = "journal_tag"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_tag_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
secondary=journal_entry_tag, back_populates="tags"
)
class JournalAttachment(Base):
__tablename__ = "journal_attachment"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), nullable=False)
media_type: Mapped[JournalAttachmentType] = mapped_column(
Enum(JournalAttachmentType, native_enum=False, length=10, values_callable=_by_value), nullable=False
)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
thumbnail_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entry: Mapped["JournalEntry"] = relationship(back_populates="attachments")
class JournalPrompt(Base):
"""카테고리 무관 전역 질문 뱅크. 특정 카테고리 전용 프롬프트는 v1 범위 밖(향후 확장 여지)."""
__tablename__ = "journal_prompt"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
question_text: Mapped[str] = mapped_column(String(300), nullable=False)