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>
This commit is contained in:
@@ -43,6 +43,9 @@ class JournalCategory(Base):
|
||||
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(
|
||||
|
||||
@@ -30,6 +30,9 @@ JOURNAL_MOOD_OPTIONS = [
|
||||
]
|
||||
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}
|
||||
|
||||
|
||||
def _parse_moods(raw: str) -> list[JournalMood]:
|
||||
@@ -111,6 +114,7 @@ def journal_page(
|
||||
"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),
|
||||
@@ -274,14 +278,18 @@ def delete_attachment_page(
|
||||
|
||||
@router.post("/journal/categories/new")
|
||||
def create_category_page(
|
||||
request: Request, name: str = Form(""), color: str | None = Form(None), db: Session = Depends(get_db)
|
||||
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)
|
||||
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)
|
||||
@@ -303,6 +311,7 @@ def edit_category_page(
|
||||
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)
|
||||
@@ -314,7 +323,7 @@ def edit_category_page(
|
||||
return Response(status_code=404)
|
||||
|
||||
try:
|
||||
data = JournalCategoryCreate(name=name, color=color)
|
||||
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)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -25,6 +26,14 @@ class JournalCategoryBase(BaseModel):
|
||||
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
|
||||
@@ -36,6 +45,7 @@ class JournalCategoryOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
color: str | None
|
||||
content_template: str | None
|
||||
sort_order: int | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -34,6 +34,27 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
|
||||
THUMBNAIL_WIDTH = 400
|
||||
DEFAULT_CATEGORY_NAME = "일상"
|
||||
DEFAULT_CATEGORY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?**
|
||||
|
||||
-
|
||||
|
||||
**2. Feelings : 오늘 들었던 생각과 나의 감정은?**
|
||||
|
||||
-
|
||||
|
||||
**3. Decisions : 오늘 내가 내린 결정이 있나요?**
|
||||
|
||||
-
|
||||
|
||||
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**
|
||||
|
||||
-
|
||||
|
||||
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.
|
||||
|
||||
**5. Actions : 내가 다음에 할 행동은 무엇인가요?**
|
||||
|
||||
- """
|
||||
|
||||
|
||||
# ---- 카테고리 ----
|
||||
@@ -54,7 +75,9 @@ def get_category(db: Session, category_id: int, user_id: int) -> JournalCategory
|
||||
|
||||
|
||||
def create_category(db: Session, user_id: int, data: JournalCategoryCreate) -> JournalCategory:
|
||||
category = JournalCategory(user_id=user_id, name=data.name, color=data.color)
|
||||
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)
|
||||
@@ -64,6 +87,7 @@ def create_category(db: Session, user_id: int, data: JournalCategoryCreate) -> J
|
||||
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
|
||||
@@ -79,7 +103,9 @@ def ensure_default_category(db: Session, user_id: int) -> JournalCategory:
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return create_category(db, user_id, JournalCategoryCreate(name=DEFAULT_CATEGORY_NAME))
|
||||
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:
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
<label for="edit-category-color-{{ category.id }}">색상</label>
|
||||
<input type="color" id="edit-category-color-{{ category.id }}" name="color" value="{{ category.color or '#d97757' }}" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="edit-category-template-{{ category.id }}">기본 작성 틀 (선택)</label>
|
||||
<textarea id="edit-category-template-{{ category.id }}" name="content_template" rows="4" placeholder="이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워줄 틀을 입력하세요">{{ category.content_template or '' }}</textarea>
|
||||
</div>
|
||||
<div id="journal-category-edit-error-{{ category.id }}" class="form-error-text"></div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button type="submit" class="btn btn-primary" style="flex:1;">저장</button>
|
||||
@@ -85,6 +89,10 @@
|
||||
<label for="new-category-color">색상 (선택)</label>
|
||||
<input type="color" id="new-category-color" name="color" value="#d97757" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-category-template">기본 작성 틀 (선택)</label>
|
||||
<textarea id="new-category-template" name="content_template" rows="4" placeholder="이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워줄 틀을 입력하세요"></textarea>
|
||||
</div>
|
||||
<div id="journal-category-error" class="form-error-text"></div>
|
||||
<button type="submit" class="btn btn-primary btn-block">추가하기</button>
|
||||
</form>
|
||||
@@ -113,6 +121,17 @@
|
||||
open: false,
|
||||
moods: [],
|
||||
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
|
||||
categoryTemplates: {{ category_templates|tojson|forceescape }},
|
||||
content: '',
|
||||
lastAutoFilled: '',
|
||||
onCategoryChange(categoryId) {
|
||||
const tmpl = this.categoryTemplates[categoryId] || '';
|
||||
if (this.content === this.lastAutoFilled) {
|
||||
this.content = tmpl;
|
||||
this.lastAutoFilled = tmpl;
|
||||
}
|
||||
},
|
||||
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
|
||||
}"
|
||||
>
|
||||
<button type="button" class="btn btn-primary btn-block" @click="open = !open">
|
||||
@@ -135,7 +154,7 @@
|
||||
>
|
||||
<div class="field">
|
||||
<label for="new-entry-category">카테고리</label>
|
||||
<select id="new-entry-category" name="category_id">
|
||||
<select id="new-entry-category" name="category_id" x-ref="categorySelect" @change="onCategoryChange($event.target.value)">
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {{ 'selected' if category_id == category.id }}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
@@ -154,7 +173,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="new-entry-content">내용</label>
|
||||
<textarea id="new-entry-content" name="content" rows="5" required placeholder="오늘 하루는 어땠나요?"></textarea>
|
||||
<textarea id="new-entry-content" name="content" rows="8" required placeholder="오늘 하루는 어땠나요?" x-model="content"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""add content_template to journal_category, seed the default 일상 category's template
|
||||
|
||||
Revision ID: 0013_journal_category_template
|
||||
Revises: 0012_remove_satisfaction_moods
|
||||
Create Date: 2026-08-04
|
||||
|
||||
카테고리별로 "새 기록 쓰기" 폼의 내용칸을 미리 채워주는 틀(예: Story/Feelings/Decisions/
|
||||
Insights/Actions 회고 양식)을 지정할 수 있게 한다. 이미 존재하는 "일상" 카테고리에는 이 틀을
|
||||
바로 채워 넣는다(새로 자동 생성되는 "일상"은 ensure_default_category가 채운다).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0013_journal_category_template"
|
||||
down_revision: Union[str, None] = "0012_remove_satisfaction_moods"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
DAILY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?**
|
||||
|
||||
-
|
||||
|
||||
**2. Feelings : 오늘 들었던 생각과 나의 감정은?**
|
||||
|
||||
-
|
||||
|
||||
**3. Decisions : 오늘 내가 내린 결정이 있나요?**
|
||||
|
||||
-
|
||||
|
||||
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**
|
||||
|
||||
-
|
||||
|
||||
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.
|
||||
|
||||
**5. Actions : 내가 다음에 할 행동은 무엇인가요?**
|
||||
|
||||
- """
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("journal_category", sa.Column("content_template", sa.Text(), nullable=True))
|
||||
|
||||
journal_category = sa.table(
|
||||
"journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text)
|
||||
)
|
||||
op.execute(
|
||||
journal_category.update()
|
||||
.where(journal_category.c.name == "일상")
|
||||
.values(content_template=DAILY_TEMPLATE)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("journal_category", "content_template")
|
||||
@@ -41,12 +41,25 @@ def _upload(filename: str, content_type: str, data: bytes) -> UploadFile:
|
||||
def test_ensure_default_category_creates_once(db_session, test_user):
|
||||
first = journal_service.ensure_default_category(db_session, test_user.id)
|
||||
assert first.name == journal_service.DEFAULT_CATEGORY_NAME
|
||||
assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE.strip()
|
||||
|
||||
second = journal_service.ensure_default_category(db_session, test_user.id)
|
||||
assert second.id == first.id
|
||||
assert len(journal_service.list_categories(db_session, test_user.id)) == 1
|
||||
|
||||
|
||||
def test_category_content_template_persists_and_updates(db_session, test_user):
|
||||
category = journal_service.create_category(
|
||||
db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2")
|
||||
)
|
||||
assert category.content_template == "질문 1\n\n질문 2"
|
||||
|
||||
updated = journal_service.update_category(
|
||||
db_session, category, JournalCategoryCreate(name="회고", content_template=" ")
|
||||
)
|
||||
assert updated.content_template is None
|
||||
|
||||
|
||||
def test_list_categories_scoped_to_user(db_session, test_user, other_user):
|
||||
_make_category(db_session, test_user.id, name="일상")
|
||||
_make_category(db_session, other_user.id, name="남의 카테고리")
|
||||
|
||||
@@ -8,8 +8,10 @@ from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate
|
||||
from app.services import journal_service
|
||||
|
||||
|
||||
def _make_category(db_session, user_id, name="일상"):
|
||||
return journal_service.create_category(db_session, user_id, JournalCategoryCreate(name=name))
|
||||
def _make_category(db_session, user_id, name="일상", content_template=None):
|
||||
return journal_service.create_category(
|
||||
db_session, user_id, JournalCategoryCreate(name=name, content_template=content_template)
|
||||
)
|
||||
|
||||
|
||||
def _make_entry(db_session, user_id, category_id, entry_date=None, content="기록"):
|
||||
@@ -36,6 +38,29 @@ def test_journal_page_creates_default_category_and_renders(auth_client):
|
||||
assert journal_service.DEFAULT_CATEGORY_NAME in response.text
|
||||
|
||||
|
||||
def test_journal_page_embeds_category_content_template(auth_client):
|
||||
response = auth_client.get("/journal")
|
||||
assert response.status_code == 200
|
||||
assert "Story" in response.text
|
||||
assert "메타인지" in response.text
|
||||
|
||||
|
||||
def test_journal_page_template_with_double_quotes_does_not_break_x_data_attribute(
|
||||
auth_client, db_session, test_user
|
||||
):
|
||||
baseline_form_count = auth_client.get("/journal").text.count("<form")
|
||||
|
||||
_make_category(db_session, test_user.id, name="따옴표카테고리", content_template='내용에 "큰따옴표"가 있어요')
|
||||
|
||||
response = auth_client.get("/journal")
|
||||
assert response.status_code == 200
|
||||
# x-data="..." 속성 안에 이스케이프 안 된 "가 섞이면 속성이 거기서 끊겨 뒤 마크업이 전부
|
||||
# 속성값으로 흡수되는데, 그러면 폼의 <form 태그들이 열린 속성값 텍스트에 파묻혀 개수가
|
||||
# 줄어든다 — 카테고리 하나 늘었다고 <form 개수가 그대로인지로 attribute-injection 여부를 검증한다.
|
||||
assert response.text.count("<form") == baseline_form_count
|
||||
assert "\\"큰따옴표\\"" in response.text
|
||||
|
||||
|
||||
def test_create_entry_via_multipart_form(auth_client, db_session, test_user, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
|
||||
category = _make_category(db_session, test_user.id, name="투자")
|
||||
|
||||
Reference in New Issue
Block a user