- 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>
137 lines
3.2 KiB
Python
137 lines
3.2 KiB
Python
from datetime import date, datetime
|
|
|
|
from pydantic import BaseModel, ConfigDict, field_validator
|
|
|
|
from app.models.journal import JournalAttachmentType, JournalMood
|
|
|
|
|
|
class JournalCategoryBase(BaseModel):
|
|
name: str
|
|
color: str | None = None
|
|
content_template: str | None = None
|
|
|
|
@field_validator("name")
|
|
@classmethod
|
|
def name_not_blank(cls, v: str) -> str:
|
|
v = v.strip()
|
|
if not v:
|
|
raise ValueError("카테고리 이름을 입력해주세요")
|
|
return v
|
|
|
|
@field_validator("color")
|
|
@classmethod
|
|
def blank_color_to_none(cls, v: str | None) -> str | None:
|
|
if v is None:
|
|
return None
|
|
v = v.strip()
|
|
return v or None
|
|
|
|
@field_validator("content_template")
|
|
@classmethod
|
|
def blank_template_to_none(cls, v: str | None) -> str | None:
|
|
if v is None:
|
|
return None
|
|
v = v.strip()
|
|
return v or None
|
|
|
|
|
|
class JournalCategoryCreate(JournalCategoryBase):
|
|
pass
|
|
|
|
|
|
class JournalCategoryOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
name: str
|
|
color: str | None
|
|
content_template: str | None
|
|
sort_order: int | None
|
|
created_at: datetime
|
|
|
|
|
|
class JournalCategoryReorderRequest(BaseModel):
|
|
category_ids: list[int]
|
|
|
|
|
|
class JournalEntryBase(BaseModel):
|
|
category_id: int
|
|
entry_date: date
|
|
title: str | None = None
|
|
content: str
|
|
moods: list[JournalMood] = []
|
|
tags: list[str] = []
|
|
|
|
@field_validator("content")
|
|
@classmethod
|
|
def content_not_blank(cls, v: str) -> str:
|
|
v = v.strip()
|
|
if not v:
|
|
raise ValueError("내용을 입력해주세요")
|
|
return v
|
|
|
|
@field_validator("title")
|
|
@classmethod
|
|
def blank_title_to_none(cls, v: str | None) -> str | None:
|
|
if v is None:
|
|
return None
|
|
v = v.strip()
|
|
return v or None
|
|
|
|
@field_validator("moods")
|
|
@classmethod
|
|
def dedupe_moods(cls, v: list[JournalMood]) -> list[JournalMood]:
|
|
cleaned: list[JournalMood] = []
|
|
for mood in v:
|
|
if mood not in cleaned:
|
|
cleaned.append(mood)
|
|
return cleaned
|
|
|
|
@field_validator("tags")
|
|
@classmethod
|
|
def clean_tags(cls, v: list[str]) -> list[str]:
|
|
cleaned: list[str] = []
|
|
for raw in v:
|
|
name = raw.strip()
|
|
if name and name not in cleaned:
|
|
cleaned.append(name)
|
|
return cleaned
|
|
|
|
|
|
class JournalEntryCreate(JournalEntryBase):
|
|
pass
|
|
|
|
|
|
class JournalEntryUpdate(JournalEntryBase):
|
|
pass
|
|
|
|
|
|
class JournalAttachmentOut(BaseModel):
|
|
id: int
|
|
media_type: JournalAttachmentType
|
|
original_filename: str
|
|
has_thumbnail: bool
|
|
|
|
|
|
class JournalCalendarDay(BaseModel):
|
|
entry_date: date
|
|
total_count: int
|
|
category_colors: list[str] # 그 날 엔트리가 있는 카테고리들의 색상(점 표시용, 중복 제거)
|
|
|
|
|
|
class JournalDayDetailItem(BaseModel):
|
|
id: int
|
|
category_id: int
|
|
category_name: str
|
|
category_color: str | None
|
|
title: str | None
|
|
content: str
|
|
moods: list[JournalMood]
|
|
tags: list[str]
|
|
attachments: list[JournalAttachmentOut]
|
|
|
|
|
|
class JournalOnThisDayItem(JournalDayDetailItem):
|
|
entry_date: date
|
|
years_ago: int
|