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