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:
2026-08-04 18:40:07 +09:00
co-authored by Claude Sonnet 5
parent ef2a3ea082
commit 54c971875c
24 changed files with 2485 additions and 7 deletions
+83
View File
@@ -0,0 +1,83 @@
from datetime import date
from io import BytesIO
from PIL import Image
from starlette.datastructures import Headers, UploadFile
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate
from app.services import journal_service
def _make_entry_with_attachment(db_session, user_id, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = journal_service.create_category(db_session, user_id, JournalCategoryCreate(name="일상"))
entry = journal_service.create_entry(
db_session,
user_id,
JournalEntryCreate(category_id=category.id, entry_date=date.today(), content="기록"),
)
buf = BytesIO()
Image.new("RGB", (800, 600), "blue").save(buf, format="PNG")
upload = UploadFile(file=BytesIO(buf.getvalue()), filename="photo.png", headers=Headers({"content-type": "image/png"}))
attachment = journal_service.save_attachment(db_session, entry, upload)
return attachment
def test_get_media_requires_login(client):
response = client.get("/api/journal/media/1")
assert response.status_code == 401
def test_get_media_returns_file_for_owner(auth_client, db_session, test_user, tmp_path, monkeypatch):
attachment = _make_entry_with_attachment(db_session, test_user.id, tmp_path, monkeypatch)
response = auth_client.get(f"/api/journal/media/{attachment.id}")
assert response.status_code == 200
assert response.content[:8] == b"\x89PNG\r\n\x1a\n"
def test_get_media_thumbnail_variant(auth_client, db_session, test_user, tmp_path, monkeypatch):
attachment = _make_entry_with_attachment(db_session, test_user.id, tmp_path, monkeypatch)
response = auth_client.get(f"/api/journal/media/{attachment.id}?thumbnail=true")
assert response.status_code == 200
def test_get_media_returns_404_for_missing_attachment(auth_client):
response = auth_client.get("/api/journal/media/9999")
assert response.status_code == 404
def test_get_media_returns_404_for_other_users_attachment(auth_client, db_session, other_user, tmp_path, monkeypatch):
attachment = _make_entry_with_attachment(db_session, other_user.id, tmp_path, monkeypatch)
response = auth_client.get(f"/api/journal/media/{attachment.id}")
assert response.status_code == 404
def test_reorder_categories_requires_login(client):
response = client.post("/api/journal/categories/reorder", json={"category_ids": [1, 2]})
assert response.status_code == 401
def test_reorder_categories_updates_sort_order(auth_client, db_session, test_user):
a = journal_service.create_category(db_session, test_user.id, JournalCategoryCreate(name="A"))
b = journal_service.create_category(db_session, test_user.id, JournalCategoryCreate(name="B"))
response = auth_client.post("/api/journal/categories/reorder", json={"category_ids": [b.id, a.id]})
assert response.status_code == 200
db_session.refresh(a)
db_session.refresh(b)
assert b.sort_order == 0
assert a.sort_order == 1
def test_reorder_categories_ignores_other_users_ids(auth_client, db_session, other_user):
other_category = journal_service.create_category(db_session, other_user.id, JournalCategoryCreate(name="남의 것"))
response = auth_client.post("/api/journal/categories/reorder", json={"category_ids": [other_category.id]})
assert response.status_code == 200
db_session.refresh(other_category)
assert other_category.sort_order is None
+285
View File
@@ -0,0 +1,285 @@
from datetime import date, timedelta
from io import BytesIO
from pathlib import Path
from PIL import Image
from starlette.datastructures import Headers, UploadFile
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
from app.services import journal_service
def _make_category(db_session, user_id, name="일상", color=None):
return journal_service.create_category(db_session, user_id, JournalCategoryCreate(name=name, color=color))
def _make_entry(db_session, user_id, category_id, entry_date=None, content="오늘의 기록", tags=None, **overrides):
data = JournalEntryCreate(
category_id=category_id,
entry_date=entry_date or date.today(),
title=overrides.pop("title", None),
content=content,
moods=overrides.pop("moods", []),
tags=tags or [],
)
return journal_service.create_entry(db_session, user_id, data)
def _png_bytes(size=(800, 600)) -> bytes:
buf = BytesIO()
Image.new("RGB", size, "red").save(buf, format="PNG")
return buf.getvalue()
def _upload(filename: str, content_type: str, data: bytes) -> UploadFile:
return UploadFile(file=BytesIO(data), filename=filename, headers=Headers({"content-type": content_type}))
# ---- 카테고리 ----
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
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_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="남의 카테고리")
mine = journal_service.list_categories(db_session, test_user.id)
assert [c.name for c in mine] == ["일상"]
def test_reorder_categories_ignores_foreign_and_unknown_ids(db_session, test_user, other_user):
a = _make_category(db_session, test_user.id, name="A")
b = _make_category(db_session, test_user.id, name="B")
other = _make_category(db_session, other_user.id, name="남의 것")
journal_service.reorder_categories(db_session, test_user.id, [b.id, a.id, other.id, 9999])
db_session.refresh(a)
db_session.refresh(b)
db_session.refresh(other)
assert b.sort_order == 0
assert a.sort_order == 1
assert other.sort_order is None
def test_count_entries_by_category(db_session, test_user, other_user):
a = _make_category(db_session, test_user.id, name="A")
b = _make_category(db_session, test_user.id, name="B")
other_category = _make_category(db_session, other_user.id, name="남의 카테고리")
_make_entry(db_session, test_user.id, a.id, content="1")
_make_entry(db_session, test_user.id, a.id, content="2")
_make_entry(db_session, test_user.id, b.id, content="3")
_make_entry(db_session, other_user.id, other_category.id, content="다른 유저 기록")
counts = journal_service.count_entries_by_category(db_session, test_user.id)
assert counts == {a.id: 2, b.id: 1}
def test_delete_category_cascades_entries_and_attachment_files(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="지울 카테고리")
entry = _make_entry(db_session, test_user.id, category.id)
attachment = journal_service.save_attachment(
db_session, entry, _upload("photo.png", "image/png", _png_bytes())
)
file_path = attachment.file_path
journal_service.delete_category(db_session, category)
assert journal_service.get_entry(db_session, entry.id, test_user.id) is None
assert not Path(file_path).exists()
# ---- 태그 ----
def test_get_or_create_tags_reuses_existing_by_name(db_session, test_user):
first = journal_service.get_or_create_tags(db_session, test_user.id, ["투자", "재테크"])
second = journal_service.get_or_create_tags(db_session, test_user.id, ["투자", "새태그"])
assert {t.name for t in first} == {"투자", "재테크"}
investment_id = next(t.id for t in first if t.name == "투자")
assert next(t.id for t in second if t.name == "투자") == investment_id
assert {t.name for t in second} == {"투자", "새태그"}
# ---- 엔트리 ----
def test_multiple_entries_per_day_same_category_allowed(db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
morning = _make_entry(db_session, test_user.id, category.id, entry_date=today, content="아침 기록")
evening = _make_entry(db_session, test_user.id, category.id, entry_date=today, content="저녁 기록")
entries = journal_service.list_entries(db_session, test_user.id, category_id=category.id)
assert {e.id for e in entries} == {morning.id, evening.id}
def test_get_entry_returns_none_for_other_users_entry(db_session, test_user, other_user):
category = _make_category(db_session, other_user.id)
other_entry = _make_entry(db_session, other_user.id, category.id)
assert journal_service.get_entry(db_session, other_entry.id, test_user.id) is None
assert journal_service.get_entry(db_session, other_entry.id, other_user.id) is not None
def test_create_entry_attaches_tags(db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, tags=["투자", "회고"])
assert {t.name for t in entry.tags} == {"투자", "회고"}
def test_update_entry_replaces_tags(db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, tags=["투자"])
updated = journal_service.update_entry(
db_session,
entry,
JournalEntryUpdate(category_id=category.id, entry_date=entry.entry_date, content="수정된 내용", tags=["새태그"]),
)
assert {t.name for t in updated.tags} == {"새태그"}
assert updated.content == "수정된 내용"
def test_create_entry_supports_multiple_moods(db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, moods=["happy", "tired"])
assert {m.mood.value for m in entry.moods} == {"happy", "tired"}
def test_update_entry_replaces_moods(db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, moods=["sad"])
updated = journal_service.update_entry(
db_session,
entry,
JournalEntryUpdate(
category_id=category.id, entry_date=entry.entry_date, content="내용", moods=["happy", "calm"]
),
)
assert {m.mood.value for m in updated.moods} == {"happy", "calm"}
def test_delete_entry_removes_attachment_files(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)
entry = _make_entry(db_session, test_user.id, category.id)
attachment = journal_service.save_attachment(
db_session, entry, _upload("photo.png", "image/png", _png_bytes())
)
file_path = Path(attachment.file_path)
thumb_path = Path(attachment.thumbnail_path)
assert file_path.exists()
assert thumb_path.exists()
journal_service.delete_entry(db_session, entry)
assert not file_path.exists()
assert not thumb_path.exists()
# ---- 첨부파일 ----
def test_save_attachment_generates_thumbnail_for_image(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)
entry = _make_entry(db_session, test_user.id, category.id)
attachment = journal_service.save_attachment(
db_session, entry, _upload("photo.png", "image/png", _png_bytes())
)
assert attachment.thumbnail_path is not None
with Image.open(attachment.thumbnail_path) as thumb:
assert thumb.width <= journal_service.THUMBNAIL_WIDTH
assert Path(attachment.file_path).exists()
def test_save_attachment_rejects_unsupported_type(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)
entry = _make_entry(db_session, test_user.id, category.id)
try:
journal_service.save_attachment(db_session, entry, _upload("doc.pdf", "application/pdf", b"%PDF-1.4"))
assert False, "should have raised"
except ValueError as exc:
assert "지원하지 않는" in str(exc)
def test_save_attachment_rejects_oversized_file(db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
monkeypatch.setattr(journal_service.settings, "journal_max_upload_mb", 0)
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id)
try:
journal_service.save_attachment(db_session, entry, _upload("photo.png", "image/png", _png_bytes()))
assert False, "should have raised"
except ValueError as exc:
assert "용량" in str(exc)
def test_get_attachment_scoped_to_owner(db_session, test_user, other_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, other_user.id)
entry = _make_entry(db_session, other_user.id, category.id)
attachment = journal_service.save_attachment(
db_session, entry, _upload("photo.png", "image/png", _png_bytes())
)
assert journal_service.get_attachment(db_session, attachment.id, test_user.id) is None
assert journal_service.get_attachment(db_session, attachment.id, other_user.id) is not None
# ---- 캘린더 / day-detail / 회상 ----
def test_get_monthly_journal_summary_counts_entries_per_day(db_session, test_user):
category = _make_category(db_session, test_user.id, color="#ff0000")
today = date.today()
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="1")
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="2")
summary = journal_service.get_monthly_journal_summary(db_session, test_user.id, today.year, today.month)
assert summary[today].total_count == 2
assert summary[today].category_colors == ["#ff0000"]
def test_get_day_entries_returns_entries_for_that_date(db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
yesterday = today - timedelta(days=1)
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="오늘 거")
_make_entry(db_session, test_user.id, category.id, entry_date=yesterday, content="어제 거")
items = journal_service.get_day_entries(db_session, test_user.id, today)
assert [i.content for i in items] == ["오늘 거"]
def test_get_on_this_day_matches_month_day_different_year(db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
last_year = today.replace(year=today.year - 1)
_make_entry(db_session, test_user.id, category.id, entry_date=last_year, content="작년 오늘")
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="올해 오늘")
items = journal_service.get_on_this_day(db_session, test_user.id, today)
assert [i.content for i in items] == ["작년 오늘"]
assert items[0].years_ago == 1
def test_get_random_prompt_returns_none_when_empty(db_session):
assert journal_service.get_random_prompt(db_session) is None
+257
View File
@@ -0,0 +1,257 @@
from datetime import date
from io import BytesIO
from PIL import Image
from starlette.datastructures import Headers, UploadFile
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_entry(db_session, user_id, category_id, entry_date=None, content="기록"):
data = JournalEntryCreate(category_id=category_id, entry_date=entry_date or date.today(), content=content)
return journal_service.create_entry(db_session, user_id, data)
def _png_file():
buf = BytesIO()
Image.new("RGB", (400, 300), "green").save(buf, format="PNG")
buf.seek(0)
return buf
def test_journal_page_requires_login(client):
response = client.get("/journal", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_journal_page_creates_default_category_and_renders(auth_client):
response = auth_client.get("/journal")
assert response.status_code == 200
assert journal_service.DEFAULT_CATEGORY_NAME 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="투자")
today = date.today()
response = auth_client.post(
"/journal/new",
data={
"category_id": str(category.id),
"entry_date": today.isoformat(),
"title": "첫 기록",
"content": "오늘의 투자 회고",
"moods": "happy,tired",
"tags": "투자, 회고",
},
files={"files": ("photo.png", _png_file(), "image/png")},
follow_redirects=False,
)
assert response.status_code == 200
assert response.headers["hx-redirect"] == f"/journal?year={today.year}&month={today.month}"
entries = journal_service.list_entries(db_session, test_user.id)
assert len(entries) == 1
assert entries[0].title == "첫 기록"
assert {t.name for t in entries[0].tags} == {"투자", "회고"}
assert {m.mood.value for m in entries[0].moods} == {"happy", "tired"}
assert len(entries[0].attachments) == 1
def test_create_entry_rejects_blank_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
response = auth_client.post(
"/journal/new",
data={"category_id": str(category.id), "entry_date": date.today().isoformat(), "content": " "},
)
assert response.status_code == 200
assert "내용을 입력해주세요" in response.text
assert journal_service.list_entries(db_session, test_user.id) == []
def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="오늘의 기록")
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "오늘의 기록" in response.text
def test_journal_day_detail_requires_login(client):
response = client.get(f"/journal/day/{date.today().isoformat()}", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_edit_entry_updates_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={
"category_id": str(category.id),
"entry_date": entry.entry_date.isoformat(),
"content": "수정된 내용",
"tags": "",
},
)
assert response.status_code == 200
assert "수정된 내용" in response.text
db_session.refresh(entry)
assert entry.content == "수정된 내용"
def test_edit_entry_blank_content_keeps_form_open_with_error(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={"category_id": str(category.id), "entry_date": entry.entry_date.isoformat(), "content": " "},
)
assert response.status_code == 200
assert "내용을 입력해주세요" in response.text
db_session.refresh(entry)
assert entry.content == "원래 내용"
def test_edit_other_users_entry_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id)
entry = _make_entry(db_session, other_user.id, category.id)
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={"category_id": str(category.id), "entry_date": entry.entry_date.isoformat(), "content": "해킹 시도"},
)
assert response.status_code == 404
def test_delete_entry_removes_it(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="지울 기록")
response = auth_client.post(f"/journal/{entry.id}/delete")
assert response.status_code == 200
assert "지울 기록" not in response.text
assert journal_service.get_entry(db_session, entry.id, test_user.id) is None
def test_delete_other_users_entry_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id)
entry = _make_entry(db_session, other_user.id, category.id)
response = auth_client.post(f"/journal/{entry.id}/delete")
assert response.status_code == 404
assert journal_service.get_entry(db_session, entry.id, other_user.id) is not None
def test_delete_attachment_removes_it(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)
entry = _make_entry(db_session, test_user.id, category.id)
upload = UploadFile(file=_png_file(), filename="a.png", headers=Headers({"content-type": "image/png"}))
attachment = journal_service.save_attachment(db_session, entry, upload)
response = auth_client.post(f"/journal/{entry.id}/attachments/{attachment.id}/delete")
assert response.status_code == 200
assert journal_service.get_attachment(db_session, attachment.id, test_user.id) is None
def test_create_category_and_reject_duplicate(auth_client):
response = auth_client.post("/journal/categories/new", data={"name": "투자"}, follow_redirects=False)
assert response.status_code == 200
assert response.headers["hx-redirect"] == "/journal?tab=manage"
dup_response = auth_client.post("/journal/categories/new", data={"name": "투자"})
assert dup_response.status_code == 200
assert "이미 같은 이름" in dup_response.text
def test_journal_page_manage_tab_shows_delete_button_for_categories(auth_client, db_session, test_user):
_make_category(db_session, test_user.id, name="투자")
response = auth_client.get("/journal?tab=manage")
assert response.status_code == 200
assert "/journal/categories/" in response.text
assert "/delete" in response.text
def test_journal_page_default_tab_hides_category_management(auth_client, db_session, test_user):
_make_category(db_session, test_user.id, name="투자")
response = auth_client.get("/journal")
assert response.status_code == 200
assert "/journal/categories/" not in response.text
def test_delete_category_removes_it_and_its_entries(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id, name="지울 카테고리")
entry = _make_entry(db_session, test_user.id, category.id, content="딸려서 지워질 기록")
response = auth_client.post(f"/journal/categories/{category.id}/delete", follow_redirects=False)
assert response.status_code == 200
assert response.headers["hx-redirect"] == "/journal?tab=manage"
assert journal_service.get_category(db_session, category.id, test_user.id) is None
assert journal_service.get_entry(db_session, entry.id, test_user.id) is None
def test_delete_other_users_category_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(f"/journal/categories/{category.id}/delete")
assert response.status_code == 404
assert journal_service.get_category(db_session, category.id, other_user.id) is not None
def test_edit_category_updates_name_and_color(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id, name="원래 이름")
response = auth_client.post(
f"/journal/categories/{category.id}/edit",
data={"name": "바뀐 이름", "color": "#123456"},
follow_redirects=False,
)
assert response.status_code == 200
assert response.headers["hx-redirect"] == "/journal?tab=manage"
db_session.refresh(category)
assert category.name == "바뀐 이름"
assert category.color == "#123456"
def test_edit_category_rejects_duplicate_name(auth_client, db_session, test_user):
_make_category(db_session, test_user.id, name="투자")
category = _make_category(db_session, test_user.id, name="일기")
response = auth_client.post(f"/journal/categories/{category.id}/edit", data={"name": "투자"})
assert response.status_code == 200
assert "이미 같은 이름" in response.text
def test_edit_other_users_category_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(f"/journal/categories/{category.id}/edit", data={"name": "가로채기"})
assert response.status_code == 404
def test_manage_tab_shows_entry_count_per_category(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id, name="투자")
_make_entry(db_session, test_user.id, category.id, content="1")
_make_entry(db_session, test_user.id, category.id, content="2")
response = auth_client.get("/journal?tab=manage")
assert "기록 2개" in response.text