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,143 @@
|
||||
"""add journal tables (category, entry, tag, attachment, prompt)
|
||||
|
||||
Revision ID: 0010_add_journal_tables
|
||||
Revises: 0009_add_habit_log_status
|
||||
Create Date: 2026-08-04
|
||||
|
||||
습관 체크에 곁들이는 회고 + 독립적인 자유 일기를 함께 지원하는 저널링 기능을 위한 테이블.
|
||||
카테고리(예: "일상", "투자")는 사용자별로 자유롭게 만들 수 있고, 하루에 같은 카테고리로
|
||||
여러 엔트리를 쓸 수 있어 (habit_id, log_date) 같은 유니크 제약은 두지 않는다. journal_prompt는
|
||||
카테고리 무관 전역 질문 뱅크로, 최소한의 한국어 질문 시드 데이터를 함께 넣는다(AI 기반 프롬프트는
|
||||
후속 업데이트로 미룸).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0010_add_journal_tables"
|
||||
down_revision: Union[str, None] = "0009_add_habit_log_status"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"journal_category",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(length=50), nullable=False),
|
||||
sa.Column("color", sa.String(length=20), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "name", name="uq_journal_category_user_name"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"journal_entry",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column(
|
||||
"category_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("journal_category.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("entry_date", sa.Date(), nullable=False),
|
||||
sa.Column("title", sa.String(length=200), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("mood", sa.String(length=20), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
server_onupdate=sa.func.now(),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"mood in ('great','good','neutral','bad','awful')", name="ck_journal_entry_mood"
|
||||
),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"journal_tag",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(length=50), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "name", name="uq_journal_tag_user_name"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"journal_entry_tag",
|
||||
sa.Column(
|
||||
"entry_id", sa.Integer(), sa.ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
sa.Column(
|
||||
"tag_id", sa.Integer(), sa.ForeignKey("journal_tag.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"journal_attachment",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"entry_id", sa.Integer(), sa.ForeignKey("journal_entry.id", ondelete="CASCADE"), nullable=False
|
||||
),
|
||||
sa.Column("media_type", sa.String(length=10), nullable=False),
|
||||
sa.Column("file_path", sa.String(length=500), nullable=False),
|
||||
sa.Column("thumbnail_path", sa.String(length=500), nullable=True),
|
||||
sa.Column("original_filename", sa.String(length=255), nullable=False),
|
||||
sa.Column("file_size", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.CheckConstraint("media_type in ('image','video')", name="ck_journal_attachment_media_type"),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"journal_prompt",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("question_text", sa.String(length=300), nullable=False),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
journal_prompt = sa.table(
|
||||
"journal_prompt",
|
||||
sa.column("question_text", sa.String),
|
||||
)
|
||||
op.bulk_insert(
|
||||
journal_prompt,
|
||||
[
|
||||
{"question_text": "오늘 가장 기억에 남는 순간은 언제였나요?"},
|
||||
{"question_text": "오늘 감사했던 일은 무엇인가요?"},
|
||||
{"question_text": "오늘 나를 힘들게 한 건 무엇이었고, 어떻게 다뤘나요?"},
|
||||
{"question_text": "오늘 배운 것이 있다면 무엇인가요?"},
|
||||
{"question_text": "내일의 나에게 해주고 싶은 말은?"},
|
||||
{"question_text": "오늘 스스로를 칭찬하고 싶은 부분은?"},
|
||||
{"question_text": "오늘 놓쳤지만 다음엔 다르게 해보고 싶은 것은?"},
|
||||
{"question_text": "오늘 누군가에게 도움을 받았거나 준 적이 있나요?"},
|
||||
{"question_text": "지금 가장 신경 쓰이는 고민은 무엇인가요?"},
|
||||
{"question_text": "오늘 하루를 한 문장으로 요약한다면?"},
|
||||
{"question_text": "요즘 내가 놓치고 있는 것은 없을까요?"},
|
||||
{"question_text": "이번 주 목표에 얼마나 가까워졌나요?"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("journal_prompt")
|
||||
op.drop_table("journal_attachment")
|
||||
op.drop_table("journal_entry_tag")
|
||||
op.drop_table("journal_tag")
|
||||
op.drop_table("journal_entry")
|
||||
op.drop_table("journal_category")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""allow journal entries to have multiple moods, and expand the mood vocabulary
|
||||
|
||||
Revision ID: 0011_journal_entry_multi_mood
|
||||
Revises: 0010_add_journal_tables
|
||||
Create Date: 2026-08-04
|
||||
|
||||
기분을 하나만 고를 수 있던 journal_entry.mood(단일 컬럼)를, 태그처럼 여러 개를 붙일 수 있는
|
||||
journal_entry_mood 연결 테이블로 바꾼다. mood 자체가 고정된 값 집합(enum)이라 journal_tag처럼
|
||||
별도 이름 엔티티를 둘 필요 없이 값 자체를 PK로 쓴다. 동시에 기존 5종(만족도 스케일)에
|
||||
구체적인 감정 9종을 추가한다.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0011_journal_entry_multi_mood"
|
||||
down_revision: Union[str, None] = "0010_add_journal_tables"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
MOOD_VALUES = (
|
||||
"great", "good", "neutral", "bad", "awful",
|
||||
"pain", "achievement", "anger", "excited", "calm", "happy", "worry", "tired", "sad",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("ck_journal_entry_mood", "journal_entry", type_="check")
|
||||
op.drop_column("journal_entry", "mood")
|
||||
|
||||
op.create_table(
|
||||
"journal_entry_mood",
|
||||
sa.Column(
|
||||
"entry_id", sa.Integer(), sa.ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True
|
||||
),
|
||||
sa.Column("mood", sa.String(length=20), primary_key=True),
|
||||
sa.CheckConstraint(
|
||||
"mood in (" + ",".join(f"'{v}'" for v in MOOD_VALUES) + ")", name="ck_journal_entry_mood_mood"
|
||||
),
|
||||
mysql_engine="InnoDB",
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("journal_entry_mood")
|
||||
op.add_column("journal_entry", sa.Column("mood", sa.String(length=20), nullable=True))
|
||||
op.create_check_constraint(
|
||||
"ck_journal_entry_mood", "journal_entry", "mood in ('great','good','neutral','bad','awful')"
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""remove the 5 satisfaction-scale moods, keep only the 9 named emotions
|
||||
|
||||
Revision ID: 0012_remove_satisfaction_moods
|
||||
Revises: 0011_journal_entry_multi_mood
|
||||
Create Date: 2026-08-04
|
||||
|
||||
기분 선택을 다중 선택으로 바꾼 뒤(0011) 만족도 스케일 5종(최고/좋음/보통/별로/힘듦)과 구체적 감정 9종을
|
||||
같이 뒀는데, 다중 선택에서는 "행복"과 "좋음"처럼 겹치는 느낌을 주는 항목이 섞여 있으면 혼란스러워서
|
||||
만족도 스케일 5종을 아예 없애고 구체적 감정 9종만 남긴다. 혹시 그 사이 저장된 행이 있으면(이 저장소
|
||||
개발 환경에서는 없었음) 지워야 새 CHECK 제약을 걸 수 있다.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012_remove_satisfaction_moods"
|
||||
down_revision: Union[str, None] = "0011_journal_entry_multi_mood"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
REMOVED_MOODS = ("great", "good", "neutral", "bad", "awful")
|
||||
REMAINING_MOODS = ("pain", "achievement", "anger", "excited", "calm", "happy", "worry", "tired", "sad")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
journal_entry_mood = sa.table("journal_entry_mood", sa.column("mood", sa.String))
|
||||
op.execute(journal_entry_mood.delete().where(journal_entry_mood.c.mood.in_(REMOVED_MOODS)))
|
||||
|
||||
op.drop_constraint("ck_journal_entry_mood_mood", "journal_entry_mood", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_journal_entry_mood_mood",
|
||||
"journal_entry_mood",
|
||||
"mood in (" + ",".join(f"'{v}'" for v in REMAINING_MOODS) + ")",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_journal_entry_mood_mood", "journal_entry_mood", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_journal_entry_mood_mood",
|
||||
"journal_entry_mood",
|
||||
"mood in (" + ",".join(f"'{v}'" for v in REMAINING_MOODS + REMOVED_MOODS) + ")",
|
||||
)
|
||||
# 삭제된 행 자체는 복구하지 않는다(내용을 알 수 없음) — 제약만 원상복구.
|
||||
Reference in New Issue
Block a user