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
+4
View File
@@ -20,3 +20,7 @@ GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:you@example.com VAPID_SUBJECT=mailto:you@example.com
# 저널 사진/영상 첨부 저장 경로(로컬 디스크)와 업로드 용량 제한(MB). 둘 다 기본값이 있어 생략 가능.
# JOURNAL_MEDIA_ROOT=app/media/journal
# JOURNAL_MAX_UPLOAD_MB=20
+1
View File
@@ -13,3 +13,4 @@ node_modules/
logs/ logs/
img*.png img*.png
image.md image.md
app/media/
+12 -3
View File
@@ -36,8 +36,8 @@ pytest tests/test_habits.py::test_name # 단일 테스트
### 요청 흐름 (2가지 라우터 계열이 공존) ### 요청 흐름 (2가지 라우터 계열이 공존)
- `app/routers/habits.py`, `app/routers/logs.py`, `app/routers/push.py``/api/*` 하위, JSON in/out API. 라우터 레벨 `dependencies=[Depends(require_login)]`로 기본 보호되고, 유저 객체가 필요한 각 엔드포인트는 `current_user: User = Depends(require_login)`을 시그니처에 추가로 선언한다(FastAPI가 같은 요청 안에서 dependency를 캐싱하므로 DB 조회가 중복되지 않는다). 미인증 시 401 JSON을 반환. - `app/routers/habits.py`, `app/routers/logs.py`, `app/routers/push.py`, `app/routers/journal.py``/api/*` 하위, JSON in/out API. 라우터 레벨 `dependencies=[Depends(require_login)]`로 기본 보호되고, 유저 객체가 필요한 각 엔드포인트는 `current_user: User = Depends(require_login)`을 시그니처에 추가로 선언한다(FastAPI가 같은 요청 안에서 dependency를 캐싱하므로 DB 조회가 중복되지 않는다). 미인증 시 401 JSON을 반환. `journal.py`는 현재 첨부파일 스트리밍(`GET /api/journal/media/{attachment_id}`) 하나뿐이다.
- `app/routers/pages.py` — SSR 페이지(`/login`, `/today`, `/habits`, `/history`)와 htmx가 폼 제출로 호출하는 액션 엔드포인트(`/habits/new`, `/habits/{id}/complete` 등). 미인증 시 401 대신 `/login`으로 303 리다이렉트한다 — `_current_user_or_redirect(request, db)``User`(로그인됨) 또는 `RedirectResponse`(미인증)를 반환하고, 각 핸들러는 `isinstance(current, RedirectResponse)`로 분기한다. 폼 액션은 대부분 처리 후 `HX-Redirect` 헤더로 같은 탭을 새로고침하는 방식으로 단순화되어 있다(부분 DOM 스왑이 아님). - `app/routers/pages.py`, `app/routers/journal_pages.py` — SSR 페이지(`/login`, `/today`, `/habits`, `/history`, `/journal`)와 htmx가 폼 제출로 호출하는 액션 엔드포인트(`/habits/new`, `/habits/{id}/complete`, `/journal/new` 등). 미인증 시 401 대신 `/login`으로 303 리다이렉트한다 — `_current_user_or_redirect(request, db)``User`(로그인됨) 또는 `RedirectResponse`(미인증)를 반환하고, 각 핸들러는 `isinstance(current, RedirectResponse)`로 분기한다. 폼 액션은 대부분 처리 후 `HX-Redirect` 헤더로 같은 탭을 새로고침하는 방식으로 단순화되어 있다(부분 DOM 스왑이 아님) — 단 `/today`의 체크 토글과 `/journal`의 day-detail 내부 액션(수정/삭제)처럼 이미 htmx partial 안에 있는 경우는 예외로, 그 partial을 다시 렌더링해서 돌려준다. `journal_pages.py`는 별도 파일이지만 `pages.py``_current_user_or_redirect``templates`(Jinja2Templates 인스턴스)를 그대로 import해서 재사용한다 — `pages.py`가 계속 비대해지는 걸 막기 위해 기능별로 페이지 라우터 파일을 분리하기 시작한 첫 사례.
- `app/routers/auth.py``/auth/*`, 페이지 네비게이션(리다이렉트/폼 POST)이라 `/api/*` 프리픽스를 쓰지 않는다. `GET /auth/google/login`이 구글 동의 화면으로 리다이렉트하고, `GET /auth/google/callback``authlib`로 id_token을 검증해 `User`를 조회/생성한 뒤 세션 쿠키를 발급, `POST /auth/logout`이 쿠키를 지운다. OAuth 핸드셰이크 중 state/nonce를 담는 `SessionMiddleware`(`app/main.py`)는 로그인 유지용 쿠키(`habit_session`)와 별개의 임시 쿠키(`oauth_session`)를 쓴다. - `app/routers/auth.py``/auth/*`, 페이지 네비게이션(리다이렉트/폼 POST)이라 `/api/*` 프리픽스를 쓰지 않는다. `GET /auth/google/login`이 구글 동의 화면으로 리다이렉트하고, `GET /auth/google/callback``authlib`로 id_token을 검증해 `User`를 조회/생성한 뒤 세션 쿠키를 발급, `POST /auth/logout`이 쿠키를 지운다. OAuth 핸드셰이크 중 state/nonce를 담는 `SessionMiddleware`(`app/main.py`)는 로그인 유지용 쿠키(`habit_session`)와 별개의 임시 쿠키(`oauth_session`)를 쓴다.
두 계열이 같은 `app/services/*`, `app/schemas/*`를 공유한다 — 새 기능을 추가할 때 API와 페이지 라우터 양쪽에서 비즈니스 로직을 중복 구현하지 말고 `app/services/`에 두고 재사용할 것. 두 계열이 같은 `app/services/*`, `app/schemas/*`를 공유한다 — 새 기능을 추가할 때 API와 페이지 라우터 양쪽에서 비즈니스 로직을 중복 구현하지 말고 `app/services/`에 두고 재사용할 것.
@@ -70,7 +70,7 @@ pytest tests/test_habits.py::test_name # 단일 테스트
1. ✅ 기반 셋업 + 습관 CRUD (`/habits` 화면, PIN 로그인 — 7단계에서 구글 OAuth로 대체됨) 1. ✅ 기반 셋업 + 습관 CRUD (`/habits` 화면, PIN 로그인 — 7단계에서 구글 OAuth로 대체됨)
2. ✅ 데일리 체크 & `/today` 화면 — `/habits`와 달리 여기는 실제 htmx 부분 갱신을 쓴다: 체크 버튼이 `#today-content`를 통째로 `partials/today_content.html`로 교체한다(개별 아이템만 스왑하지 않는 이유: 진행률 배지도 같이 갱신해야 해서). 이 partial은 `today.html`의 최초 렌더와 토글 응답에서 동일하게 재사용된다 (`app/routers/pages.py``_today_context`). 2. ✅ 데일리 체크 & `/today` 화면 — `/habits`와 달리 여기는 실제 htmx 부분 갱신을 쓴다: 체크 버튼이 `#today-content`를 통째로 `partials/today_content.html`로 교체한다(개별 아이템만 스왑하지 않는 이유: 진행률 배지도 같이 갱신해야 해서). 이 partial은 `today.html`의 최초 렌더와 토글 응답에서 동일하게 재사용된다 (`app/routers/pages.py``_today_context`).
3. ✅ 기록 확인 화면 (월별/주별) — `/history`. 두 뷰 모두 순수 SSR(링크 기반 이전/다음 네비게이션)이고 htmx 상호작용은 없다. 월별 집계와 주별 매트릭스는 모두 **현재 active 상태인 습관만** 기준으로 계산한다(`app/services/log_service.py``get_monthly_summary`/`get_weekly_matrix`) — 완료 처리되었거나 삭제된 습관은 과거 날짜라도 집계에서 빠진다. 즉 "그 날 실제로 무엇이 예정되어 있었는가"를 재구성하지 않고 "지금 진행 중인 습관 기준으로 최근 기록이 어떤지"를 보여주는 단순화된 설계다. 히트맵 투명도는 `app/template_utils.py``heatmap_opacity()`가 계산해 `--color-accent-rgb` CSS 변수와 조합한다. 3. ✅ 기록 확인 화면 (월별/주별) — `/history`. 두 뷰 모두 순수 SSR(링크 기반 이전/다음 네비게이션)이고 htmx 상호작용은 없다. 월별 집계와 주별 매트릭스는 모두 **현재 active 상태인 습관만** 기준으로 계산한다(`app/services/log_service.py``get_monthly_summary`/`get_weekly_matrix`) — 완료 처리되었거나 삭제된 습관은 과거 날짜라도 집계에서 빠진다. 즉 "그 날 실제로 무엇이 예정되어 있었는가"를 재구성하지 않고 "지금 진행 중인 습관 기준으로 최근 기록이 어떤지"를 보여주는 단순화된 설계다. 히트맵 투명도는 `app/template_utils.py``heatmap_opacity()`가 계산해 `--color-accent-rgb` CSS 변수와 조합한다.
4. ✅ PWA 기본 (manifest, 서비스워커, 아이콘) — `manifest.json``/static/manifest.json`에 있고 `base.html``/static/manifest.json`으로 직접 링크한다(루트 경로 라우트 아님, manifest는 자체 `scope` 필드로 범위를 지정하므로 파일 위치가 중요하지 않음). 반면 `service-worker.js`는 앱 전체를 커버해야 해서 `app/main.py``GET /service-worker.js``app/static/service-worker.js`를 루트 경로로 직접 서빙한다 — 이 둘의 서빙 방식이 다른 이유이니 헷갈리지 말 것. 아이콘은 `scripts/generate_icons.py`(Pillow 필요, 런타임 의존성 아님)로 생성. 4. ✅ PWA 기본 (manifest, 서비스워커, 아이콘) — `manifest.json``/static/manifest.json`에 있고 `base.html``/static/manifest.json`으로 직접 링크한다(루트 경로 라우트 아님, manifest는 자체 `scope` 필드로 범위를 지정하므로 파일 위치가 중요하지 않음). 반면 `service-worker.js`는 앱 전체를 커버해야 해서 `app/main.py``GET /service-worker.js``app/static/service-worker.js`를 루트 경로로 직접 서빙한다 — 이 둘의 서빙 방식이 다른 이유이니 헷갈리지 말 것. 아이콘은 `scripts/generate_icons.py`(Pillow 필요)로 생성. Pillow는 8단계(저널링)의 첨부 이미지 썸네일 생성에도 쓰이기 시작해 지금은 런타임 의존성이다(`pyproject.toml``dependencies`에 있음, 예전엔 `dev` 전용이었음).
- **캐싱 전략 (중요)**: 처음엔 모든 GET을 stale-while-revalidate로 캐싱했는데, `/habits`·`/today`처럼 사용자가 직접 데이터를 바꾸고 곧바로 재방문하는 페이지에서 "방금 저장한 게 사라진 것처럼" 보이는 실사용 버그로 이어졌다(습관 생성/수정 후 `HX-Redirect`로 재이동했을 때 캐시된 옛 페이지가 먼저 뜸). `service-worker.js`의 fetch 핸들러는 이제 `request.mode === "navigate"`(페이지 탐색)와 그 외(정적 자산)를 분리한다 — 탐색은 **네트워크 우선**(오프라인일 때만 캐시 폴백), 정적 자산만 캐시 우선 stale-while-revalidate 유지. 서비스워커 캐시 로직을 바꿀 때마다 `CACHE_NAME` 버전을 올려야 `activate` 핸들러가 구버전 캐시를 정리한다(현재 `habit-tracker-v2`). - **캐싱 전략 (중요)**: 처음엔 모든 GET을 stale-while-revalidate로 캐싱했는데, `/habits`·`/today`처럼 사용자가 직접 데이터를 바꾸고 곧바로 재방문하는 페이지에서 "방금 저장한 게 사라진 것처럼" 보이는 실사용 버그로 이어졌다(습관 생성/수정 후 `HX-Redirect`로 재이동했을 때 캐시된 옛 페이지가 먼저 뜸). `service-worker.js`의 fetch 핸들러는 이제 `request.mode === "navigate"`(페이지 탐색)와 그 외(정적 자산)를 분리한다 — 탐색은 **네트워크 우선**(오프라인일 때만 캐시 폴백), 정적 자산만 캐시 우선 stale-while-revalidate 유지. 서비스워커 캐시 로직을 바꿀 때마다 `CACHE_NAME` 버전을 올려야 `activate` 핸들러가 구버전 캐시를 정리한다(현재 `habit-tracker-v2`).
5. ✅ Web Push 알림 (APScheduler 매분 tick + VAPID) — VAPID 키 원시 바이트는 `py_vapid``b64urlencode`로 직접 인코딩해서 `.env`에 저장한다(`Vapid02``public_key_str` 같은 헬퍼가 없음, `scripts/generate_vapid_keys.py` 참고). `scheduler_service._tick()`은 습관별 job을 등록/해제하는 대신 **매분 폴링** 방식으로 전체 active 습관을 훑어 지금 시각+요일이 맞고 오늘 아직 `habit_notification_log`에 없는 것만 발송한다 — 습관 CRUD와 스케줄러 job을 동기화할 필요가 없어서 이 방식을 택함. `push_service.send_to_user(db, user_id, ...)`(멀티유저 전환 전에는 `send_to_all`이었음)는 만료된 구독(404/410)만 자동 삭제하고 다른 오류는 무시하고 다음 구독자로 넘어간다(한 구독자 오류가 전체 발송을 막지 않도록). 프론트엔드 구독 흐름은 `app/static/js/push-register.js`(`window.habitPush`), `/today` 카드에 "알림 켜기" 버튼으로 노출. **주의**: Chrome 네이티브 알림 권한 프롬프트는 브라우저 크롬 UI 영역이라 자동화 도구로 클릭할 수 없다 — 구독 저장/발송/스케줄러 로직은 curl과 직접 함수 호출로 검증했지만, 실제 브라우저 권한 승인 → 진짜 푸시 수신까지의 마지막 단계는 사람이 직접 확인해야 한다. 5. ✅ Web Push 알림 (APScheduler 매분 tick + VAPID) — VAPID 키 원시 바이트는 `py_vapid``b64urlencode`로 직접 인코딩해서 `.env`에 저장한다(`Vapid02``public_key_str` 같은 헬퍼가 없음, `scripts/generate_vapid_keys.py` 참고). `scheduler_service._tick()`은 습관별 job을 등록/해제하는 대신 **매분 폴링** 방식으로 전체 active 습관을 훑어 지금 시각+요일이 맞고 오늘 아직 `habit_notification_log`에 없는 것만 발송한다 — 습관 CRUD와 스케줄러 job을 동기화할 필요가 없어서 이 방식을 택함. `push_service.send_to_user(db, user_id, ...)`(멀티유저 전환 전에는 `send_to_all`이었음)는 만료된 구독(404/410)만 자동 삭제하고 다른 오류는 무시하고 다음 구독자로 넘어간다(한 구독자 오류가 전체 발송을 막지 않도록). 프론트엔드 구독 흐름은 `app/static/js/push-register.js`(`window.habitPush`), `/today` 카드에 "알림 켜기" 버튼으로 노출. **주의**: Chrome 네이티브 알림 권한 프롬프트는 브라우저 크롬 UI 영역이라 자동화 도구로 클릭할 수 없다 — 구독 저장/발송/스케줄러 로직은 curl과 직접 함수 호출로 검증했지만, 실제 브라우저 권한 승인 → 진짜 푸시 수신까지의 마지막 단계는 사람이 직접 확인해야 한다.
- **경합 방지**: `_tick()`은 발송 전에 `habit_notification_log`에 먼저 커밋해 "선점"하고(`_claim_notification_slot`), `(habit_id, notify_date)` 유니크 제약을 경합 방지 락처럼 쓴다 — 선점에 실패(`IntegrityError`)하면 이미 다른 워커/틱이 처리한 것으로 보고 조용히 건너뛴다. 이 앱은 `uvicorn --reload`로 개발 중 실행하는 경우가 많은데, **포트가 겹친 채로 오래된 워커 프로세스가 안 죽고 새 프로세스와 동시에 떠 있으면 각자 스케줄러를 따로 띄워서 같은 알림을 동시에 두 번 기록하려다 죽는 사고**가 실제로 있었다(웹 요청도 임의로 둘 중 한 프로세스로 라우팅되어 "가끔 옛날 코드로 응답"하는 것처럼 보이는 증상과 세트로 나타남). 이상 동작이 보이면 먼저 `Get-NetTCPConnection -LocalPort 8000 -ErrorAction SilentlyContinue`(PowerShell, `netstat`보다 정확함)로 실제 리스너가 1개인지 확인하고, 여러 개면 관련 `python.exe`/`uvicorn.exe`를 모두 정리한 뒤 하나만 새로 띄울 것. - **경합 방지**: `_tick()`은 발송 전에 `habit_notification_log`에 먼저 커밋해 "선점"하고(`_claim_notification_slot`), `(habit_id, notify_date)` 유니크 제약을 경합 방지 락처럼 쓴다 — 선점에 실패(`IntegrityError`)하면 이미 다른 워커/틱이 처리한 것으로 보고 조용히 건너뛴다. 이 앱은 `uvicorn --reload`로 개발 중 실행하는 경우가 많은데, **포트가 겹친 채로 오래된 워커 프로세스가 안 죽고 새 프로세스와 동시에 떠 있으면 각자 스케줄러를 따로 띄워서 같은 알림을 동시에 두 번 기록하려다 죽는 사고**가 실제로 있었다(웹 요청도 임의로 둘 중 한 프로세스로 라우팅되어 "가끔 옛날 코드로 응답"하는 것처럼 보이는 증상과 세트로 나타남). 이상 동작이 보이면 먼저 `Get-NetTCPConnection -LocalPort 8000 -ErrorAction SilentlyContinue`(PowerShell, `netstat`보다 정확함)로 실제 리스너가 1개인지 확인하고, 여러 개면 관련 `python.exe`/`uvicorn.exe`를 모두 정리한 뒤 하나만 새로 띄울 것.
@@ -87,6 +87,15 @@ pytest tests/test_habits.py::test_name # 단일 테스트
- **OAuth 핸드셰이크와 로그인 세션은 별개의 쿠키**: `authlib`가 리다이렉트 도중 state/nonce를 저장하려면 `request.session`이 있어야 해서 `app/main.py``SessionMiddleware`(쿠키명 `oauth_session`, `max_age=600`)를 추가했다. 로그인 유지용 쿠키(`habit_session`, `itsdangerous` 서명, 30일)와는 완전히 다른 메커니즘이니 헷갈리지 말 것 — `create_session_token(user_id)`가 담는 페이로드도 `{"authenticated": True}`에서 `{"user_id": ...}`로 바뀌었다. - **OAuth 핸드셰이크와 로그인 세션은 별개의 쿠키**: `authlib`가 리다이렉트 도중 state/nonce를 저장하려면 `request.session`이 있어야 해서 `app/main.py``SessionMiddleware`(쿠키명 `oauth_session`, `max_age=600`)를 추가했다. 로그인 유지용 쿠키(`habit_session`, `itsdangerous` 서명, 30일)와는 완전히 다른 메커니즘이니 헷갈리지 말 것 — `create_session_token(user_id)`가 담는 페이로드도 `{"authenticated": True}`에서 `{"user_id": ...}`로 바뀌었다.
- **리디렉션 URI는 동적 추론이 아니라 `.env`에 명시**: 이 앱은 Tailscale로 리버스 프록시 없이 HTTPS를 받는 배포가 흔한데(위 6단계 HTTPS 문제 참고), `request.url_for()`로 콜백 URL을 추론하면 프록시 뒤에서 scheme이 `http`로 잘못 잡힐 위험이 있다. 그래서 `GOOGLE_REDIRECT_URI``.env`에 명시적으로 두고 Google Cloud Console의 "승인된 리디렉션 URI"와 정확히 일치시키는 방식을 택했다 — 값이 하나라도 다르면 구글이 `redirect_uri_mismatch`로 콜백을 거부한다. - **리디렉션 URI는 동적 추론이 아니라 `.env`에 명시**: 이 앱은 Tailscale로 리버스 프록시 없이 HTTPS를 받는 배포가 흔한데(위 6단계 HTTPS 문제 참고), `request.url_for()`로 콜백 URL을 추론하면 프록시 뒤에서 scheme이 `http`로 잘못 잡힐 위험이 있다. 그래서 `GOOGLE_REDIRECT_URI``.env`에 명시적으로 두고 Google Cloud Console의 "승인된 리디렉션 URI"와 정확히 일치시키는 방식을 택했다 — 값이 하나라도 다르면 구글이 `redirect_uri_mismatch`로 콜백을 거부한다.
- **테스트 관련 주의**: 실제 구글 계정으로 로그인/동의 화면을 클릭하는 마지막 단계는 브라우저 자동화로 재현할 수 없다(진짜 구글 계정 자격증명이 필요한 영역) — `GET /auth/google/login``accounts.google.com`으로 302 리다이렉트하는지, 로그인 후 발급된 세션 쿠키로 API가 정상 동작하는지는 curl로 검증할 수 있지만, 구글 동의 화면 자체는 사람이 직접 로그인해서 `/today`까지 도달하는지 확인해야 한다. - **테스트 관련 주의**: 실제 구글 계정으로 로그인/동의 화면을 클릭하는 마지막 단계는 브라우저 자동화로 재현할 수 없다(진짜 구글 계정 자격증명이 필요한 영역) — `GET /auth/google/login``accounts.google.com`으로 302 리다이렉트하는지, 로그인 후 발급된 세션 쿠키로 API가 정상 동작하는지는 curl로 검증할 수 있지만, 구글 동의 화면 자체는 사람이 직접 로그인해서 `/today`까지 도달하는지 확인해야 한다.
8. ✅ 저널링 — 습관 체크에 곁들이는 회고와, 습관과 무관한 자유 일기를 함께 지원한다. 카테고리(사용자가 자유롭게 만드는 "일상"/"투자" 같은 저널 묶음, `JournalCategory`), 태그, 사진/영상 첨부, 월별 캘린더, 고정 질문 템플릿, 기분 트래킹, "1년 전 오늘" 회상을 이번 출시에 포함했고 AI 기반 프롬프트/피드백만 후속 업데이트로 미뤘다. 별도 프로젝트로 분리하지 않고 기존 구글 OAuth/유저/DB/PWA 인프라를 재사용했다.
- **라우터 파일이 하나 더 늘었다**: 위 "요청 흐름" 절에서 설명한 API(`habits.py`/`logs.py`/`push.py`) vs 페이지(`pages.py`) 2계열 구조를 그대로 따르되, `pages.py`를 더 비대하게 만들지 않으려고 저널 전용 파일을 새로 뺐다 — `app/routers/journal.py`(`/api/journal/*`, 현재는 첨부파일 스트리밍 `GET /api/journal/media/{attachment_id}` 하나뿐)와 `app/routers/journal_pages.py`(`/journal` 캘린더·day-detail·엔트리/카테고리 CRUD, `pages.py``_current_user_or_redirect``templates`를 그대로 import해서 재사용). 앞으로 다른 기능도 규모가 커지면 `pages.py`에 계속 얹기보다 이 패턴(기능별 페이지 라우터 파일 분리)을 따를 것.
- **데이터 모델**: `app/models/journal.py``JournalCategory`(user별 유니크 이름), `JournalEntry`(entry_date와 created_at 분리 — entry_date는 사용자가 지정 가능해 어제 일을 오늘 쓸 수 있고, 하루+카테고리당 여러 엔트리를 허용하므로 유니크 제약이 없다), `JournalTag`/`JournalEntryTag`(M:N), `JournalEntryMood`, `JournalAttachment`, `JournalPrompt`(카테고리 무관 전역 질문 뱅크, 마이그레이션에서 시드 데이터 삽입)가 있다. 카테고리 FK는 non-null이고, 대신 `journal_service.ensure_default_category`가 유저의 첫 저널 진입 시 카테고리가 하나도 없으면 "일상"을 자동 생성해 "카테고리 없음" 케이스를 아예 없앤다.
- **기분(mood)은 엔트리당 하나가 아니라 여러 개를 태그처럼 붙일 수 있다**: 처음엔 `JournalEntry.mood`가 단일 nullable enum 컬럼이었는데(0010 마이그레이션), 감정을 동시에 여러 개 고를 수 있어야 한다는 요구로 0011 마이그레이션에서 그 컬럼을 지우고 `JournalEntryMood(entry_id, mood)` 연결 테이블로 옮겼다 — `JournalTag`와 달리 mood는 고정된 enum 값 집합이라 별도 이름 엔티티 없이 값 자체를 복합 PK로 쓴다(`journal_tag`처럼 이름 조회/생성 로직이 필요 없음). `JournalMood`는 만족도 스케일 5종(최고/좋음/보통/별로/힘듦)에 구체적 감정 9종(아픔/성취/분노/신남/평온/행복/걱정/피곤/슬픔)을 더해 총 14종이다. 폼에서는 Alpine 배열(`moods: []`)로 다중 토글 pill을 만들고 쉼표로 join한 hidden input 하나로 제출한다(태그 입력과 동일한 패턴, `journal_pages._parse_moods`가 서버에서 다시 분해). pill 목록/이모지는 `journal_pages.py``JOURNAL_MOOD_OPTIONS`에 한 곳에 정의해 `templates.env.globals`로 등록, 작성/수정 폼과 day-detail 표시 양쪽에서 재사용한다.
- **첨부파일은 `/static`이 아니라 인증된 라우트로 서빙한다**: 이 앱의 유일한 `StaticFiles` 마운트(`/static`)는 완전 공개라 사진/영상처럼 유저별로 비공개여야 하는 파일을 두면 안 된다. `journal_service.save_attachment``settings.journal_media_root`(기본 `app/media/journal/{user_id}/{entry_id}/`, `.gitignore`에 등록됨) 아래 로컬 디스크에 저장하고, `GET /api/journal/media/{attachment_id}``JournalAttachment→JournalEntry.user_id` 소유권을 확인한 뒤에만 `FileResponse`로 스트리밍한다. 이미지 첨부는 Pillow로 가로 400px 썸네일(`?thumbnail=true` 쿼리로 구분)을 만들어 목록/캘린더에서 원본 대신 가볍게 로드한다 — 영상은 썸네일을 만들지 않고 재생 링크만 보여준다(ffmpeg 등 별도 도구가 필요해 v1 범위 밖으로 미룸).
- **미디어 라우트를 `/api/` 밑에 둔 이유**: `service-worker.js`의 fetch 핸들러는 `/api/`로 시작하는 GET 요청을 무조건 네트워크로 그냥 통과시키고 캐싱하지 않는다(아래 4단계 캐싱 전략 참고). 저널 미디어를 `/api/journal/media/...`에 둠으로써 이 기존 규칙에 공짜로 올라타 서비스워커를 전혀 건드리지 않고도 "비공개 사진/영상이 클라이언트 캐시에 무기한 남는" 문제를 피했다 — 만약 `/journal/media/...`처럼 `/api/` 밖에 뒀다면 정적 자산과 똑같이 stale-while-revalidate로 캐싱돼버렸을 것.
- **캘린더/day-detail은 `/history`의 기존 패턴을 그대로 재사용했다**: `calendar.Calendar(firstweekday=6).monthdatescalendar()`로 월 그리드를 만들고, 날짜를 클릭하면 htmx로 `#day-detail`에 partial을 swap하는 구조가 동일하다. 다만 의미가 달라 `heatmap_opacity`(습관 완료율 기반 투명도)는 재사용하지 않고, 그날 등장한 카테고리 색상을 점(`.journal-day-dot`)으로 표시하는 방식을 새로 만들었다.
- **엔트리 수정/삭제/첨부삭제는 `HX-Redirect`가 아니라 `#day-detail` partial을 다시 렌더링해서 돌려준다**: `/today`의 체크 토글과 같은 이유 — 이미 htmx로 `#day-detail`에 로드된 상태에서 벌어지는 액션이라 전체 페이지 리다이렉트 대신 그 자리에서 갱신하는 게 자연스럽다. 검증 실패 시에도 같은 partial을 `edit_error`/`editing_entry_id` 컨텍스트와 함께 다시 렌더링해 해당 엔트리의 수정 폼이 열린 채로 에러 메시지를 보여준다(`habit_item.html`의 Alpine `editing` 토글과 같은 아이디어를 서버 렌더링 쪽에서 구현한 것).
- **테스트는 SQLite(단위) + 실제 MariaDB(수동 curl/httpx 스모크)로 이중 검증했다**: "1년 전 오늘" 회상과 랜덤 프롬프트 뽑기를 처음엔 각각 `MONTH()`/`DAY()`, `RANDOM()` 같은 DB 함수로 짜려고 했는데, 이 프로젝트의 pytest는 SQLite 인메모리 DB를 쓰고 운영은 MariaDB라 방언이 다르면(`RANDOM()` vs `RAND()` 등) 테스트만 통과하고 운영에서 깨질 위험이 있었다. 그래서 두 기능 다 파이썬 레벨 필터링/`random.choice()`로 바꿔 방언 종속성을 아예 없앴다 — 개인 규모 데이터라 전체 스캔 비용도 무시할 만하다. 한글 저장/조회가 실제 MariaDB(`utf8mb4`)에서도 깨지지 않는지는 `httpx`로 직접 폼을 제출해 왕복 검증했다(터미널에 출력할 때는 콘솔 코드페이지 때문에 깨져 보일 수 있어도, 문자열 비교 자체는 정상이었다 — 실제 버그가 아니라 표시상의 문제였음을 확인).
## Docker 배포 ## Docker 배포
+3
View File
@@ -18,5 +18,8 @@ class Settings(BaseSettings):
session_cookie_name: str = "habit_session" session_cookie_name: str = "habit_session"
timezone: str = "Asia/Seoul" timezone: str = "Asia/Seoul"
journal_media_root: str = "app/media/journal"
journal_max_upload_mb: int = 20
settings = Settings() settings = Settings()
+5 -1
View File
@@ -1,5 +1,6 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
@@ -9,7 +10,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app.config import settings from app.config import settings
from app.database import SessionLocal from app.database import SessionLocal
from app.routers import auth, habits, logs, pages, push from app.routers import auth, habits, journal, journal_pages, logs, pages, push
from app.routers.pages import templates from app.routers.pages import templates
from app.security import get_current_user_optional from app.security import get_current_user_optional
from app.services import scheduler_service from app.services import scheduler_service
@@ -19,6 +20,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
Path(settings.journal_media_root).mkdir(parents=True, exist_ok=True)
scheduler_service.start_scheduler() scheduler_service.start_scheduler()
yield yield
scheduler_service.shutdown_scheduler() scheduler_service.shutdown_scheduler()
@@ -44,8 +46,10 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(habits.router) app.include_router(habits.router)
app.include_router(journal.router)
app.include_router(logs.router) app.include_router(logs.router)
app.include_router(push.router) app.include_router(push.router)
app.include_router(journal_pages.router)
app.include_router(pages.router) app.include_router(pages.router)
+18
View File
@@ -1,5 +1,15 @@
from app.models.habit import Habit, HabitStatus, HabitType from app.models.habit import Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog from app.models.habit_log import HabitLog
from app.models.journal import (
JournalAttachment,
JournalAttachmentType,
JournalCategory,
JournalEntry,
JournalEntryMood,
JournalMood,
JournalPrompt,
JournalTag,
)
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
from app.models.user import User from app.models.user import User
@@ -9,6 +19,14 @@ __all__ = [
"HabitStatus", "HabitStatus",
"HabitType", "HabitType",
"HabitLog", "HabitLog",
"JournalAttachment",
"JournalAttachmentType",
"JournalCategory",
"JournalEntry",
"JournalEntryMood",
"JournalMood",
"JournalPrompt",
"JournalTag",
"HabitNotificationLog", "HabitNotificationLog",
"SummaryNotificationLog", "SummaryNotificationLog",
"PushSubscription", "PushSubscription",
+131
View File
@@ -0,0 +1,131 @@
import enum
from datetime import date, datetime
from sqlalchemy import Column, Date, Enum, ForeignKey, Integer, String, Table, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
from app.models.habit import _by_value
class JournalMood(str, enum.Enum):
PAIN = "pain" # 아픔
ACHIEVEMENT = "achievement" # 성취
ANGER = "anger" # 분노
EXCITED = "excited" # 신남
CALM = "calm" # 평온
HAPPY = "happy" # 행복
WORRY = "worry" # 걱정
TIRED = "tired" # 피곤
SAD = "sad" # 슬픔
class JournalAttachmentType(str, enum.Enum):
IMAGE = "image"
VIDEO = "video"
journal_entry_tag = Table(
"journal_entry_tag",
Base.metadata,
Column("entry_id", ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", ForeignKey("journal_tag.id", ondelete="CASCADE"), primary_key=True),
)
class JournalCategory(Base):
__tablename__ = "journal_category"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_category_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
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)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
back_populates="category", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntry(Base):
__tablename__ = "journal_entry"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int] = mapped_column(
ForeignKey("journal_category.id", ondelete="CASCADE"), nullable=False
)
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
title: Mapped[str | None] = mapped_column(String(200), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)
category: Mapped["JournalCategory"] = relationship(back_populates="entries")
tags: Mapped[list["JournalTag"]] = relationship(
secondary=journal_entry_tag, back_populates="entries"
)
moods: Mapped[list["JournalEntryMood"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
attachments: Mapped[list["JournalAttachment"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntryMood(Base):
"""엔트리 하나에 여러 감정을 태그처럼 붙일 수 있게 하는 연결 테이블. mood 자체가 고정 enum이라
JournalTag처럼 별도 엔티티(이름 등)를 둘 필요가 없어 journal_entry_tag와 달리 값 자체를 PK로 쓴다."""
__tablename__ = "journal_entry_mood"
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True)
mood: Mapped[JournalMood] = mapped_column(
Enum(JournalMood, native_enum=False, length=20, values_callable=_by_value), primary_key=True
)
entry: Mapped["JournalEntry"] = relationship(back_populates="moods")
class JournalTag(Base):
__tablename__ = "journal_tag"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_tag_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
secondary=journal_entry_tag, back_populates="tags"
)
class JournalAttachment(Base):
__tablename__ = "journal_attachment"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), nullable=False)
media_type: Mapped[JournalAttachmentType] = mapped_column(
Enum(JournalAttachmentType, native_enum=False, length=10, values_callable=_by_value), nullable=False
)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
thumbnail_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entry: Mapped["JournalEntry"] = relationship(back_populates="attachments")
class JournalPrompt(Base):
"""카테고리 무관 전역 질문 뱅크. 특정 카테고리 전용 프롬프트는 v1 범위 밖(향후 확장 여지)."""
__tablename__ = "journal_prompt"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
question_text: Mapped[str] = mapped_column(String(300), nullable=False)
+36
View File
@@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.schemas.journal import JournalCategoryReorderRequest
from app.security import require_login
from app.services import journal_service
router = APIRouter(prefix="/api/journal", tags=["journal"], dependencies=[Depends(require_login)])
@router.get("/media/{attachment_id}")
def get_media(
attachment_id: int,
thumbnail: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
attachment = journal_service.get_attachment(db, attachment_id, current_user.id)
if attachment is None:
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
path = attachment.thumbnail_path if (thumbnail and attachment.thumbnail_path) else attachment.file_path
return FileResponse(path, filename=attachment.original_filename)
@router.post("/categories/reorder")
def reorder_categories(
data: JournalCategoryReorderRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
journal_service.reorder_categories(db, current_user.id, data.category_ids)
return {"ok": True}
+346
View File
@@ -0,0 +1,346 @@
import calendar
from datetime import date
from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.journal import JournalMood
from app.routers.pages import _current_user_or_redirect, templates
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
from app.services import journal_service
router = APIRouter()
# (enum 값, 이모지, 한글 라벨). 기분 선택 pill(작성/수정 폼)과 표시(day-detail)에서 공유해서 쓴다.
# 여러 개를 동시에 고를 수 있어서(다중 선택) 라벨은 전부 명사형으로 통일한다.
JOURNAL_MOOD_OPTIONS = [
(JournalMood.PAIN, "🤕", "아픔"),
(JournalMood.ACHIEVEMENT, "🏆", "성취"),
(JournalMood.ANGER, "😠", "분노"),
(JournalMood.EXCITED, "🤩", "신남"),
(JournalMood.CALM, "😌", "평온"),
(JournalMood.HAPPY, "😊", "행복"),
(JournalMood.WORRY, "😟", "걱정"),
(JournalMood.TIRED, "😪", "피곤"),
(JournalMood.SAD, "😢", "슬픔"),
]
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}
def _parse_moods(raw: str) -> list[JournalMood]:
moods = []
for v in raw.split(","):
v = v.strip()
if not v:
continue
try:
moods.append(JournalMood(v))
except ValueError:
continue
return moods
def _parse_tags(raw: str) -> list[str]:
return [t for t in raw.split(",")]
def _month_context(db: Session, user_id: int, year: int, month: int, category_id: int | None) -> dict:
summary_map = journal_service.get_monthly_journal_summary(db, user_id, year, month, category_id)
weeks = calendar.Calendar(firstweekday=6).monthdatescalendar(year, month)
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
return {
"year": year,
"month": month,
"weeks": weeks,
"summary_map": summary_map,
"prev_year": prev_year,
"prev_month": prev_month,
"next_year": next_year,
"next_month": next_month,
}
def _render_day_detail(request: Request, db: Session, user_id: int, entry_date: date, **extra):
return templates.TemplateResponse(
request,
"partials/journal_day_detail.html",
{
"entry_date": entry_date,
"items": journal_service.get_day_entries(db, user_id, entry_date),
"categories": journal_service.list_categories(db, user_id),
**extra,
},
)
@router.get("/journal")
def journal_page(
request: Request,
tab: str = "calendar",
year: int | None = None,
month: int | None = None,
category_id: int | None = None,
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
journal_service.ensure_default_category(db, current.id)
categories = journal_service.list_categories(db, current.id)
today = date.today()
year = year or today.year
month = month or today.month
tab = "manage" if tab == "manage" else "calendar"
return templates.TemplateResponse(
request,
"journal.html",
{
"logged_in": True,
"current_user": current,
"tab": tab,
"categories": categories,
"category_id": category_id,
"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),
"today_iso": today.isoformat(),
**_month_context(db, current.id, year, month, category_id),
},
)
@router.get("/journal/day/{entry_date}")
def journal_day_detail(request: Request, entry_date: date, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
return _render_day_detail(request, db, current.id, entry_date)
@router.post("/journal/new")
def create_entry_page(
request: Request,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryCreate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
except ValueError:
return HTMLResponse("입력값을 확인해주세요")
entry = journal_service.create_entry(db, current.id, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError as exc:
return HTMLResponse(str(exc))
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/journal?year={parsed_date.year}&month={parsed_date.month}"
return response
@router.post("/journal/{entry_id}/edit")
def edit_entry_page(
request: Request,
entry_id: int,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
original_date = entry.entry_date
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryUpdate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return _render_day_detail(
request, db, current.id, original_date, edit_error=message, editing_entry_id=entry_id
)
except ValueError:
return _render_day_detail(
request,
db,
current.id,
original_date,
edit_error="입력값을 확인해주세요",
editing_entry_id=entry_id,
)
journal_service.update_entry(db, entry, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError:
pass # 첨부 실패는 조용히 넘어간다 — 본문 수정은 이미 반영됐다
return _render_day_detail(request, db, current.id, parsed_date)
@router.post("/journal/{entry_id}/delete")
def delete_entry_page(request: Request, entry_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_entry(db, entry)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/{entry_id}/attachments/{attachment_id}/delete")
def delete_attachment_page(
request: Request, entry_id: int, attachment_id: int, db: Session = Depends(get_db)
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
attachment = journal_service.get_attachment(db, attachment_id, current.id)
if attachment is None or attachment.entry_id != entry.id:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_attachment(db, attachment)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/categories/new")
def create_category_page(
request: Request, name: str = Form(""), color: 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)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.create_category(db, current.id, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/edit")
def edit_category_page(
request: Request,
category_id: int,
name: str = Form(""),
color: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
try:
data = JournalCategoryCreate(name=name, color=color)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.update_category(db, category, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/delete")
def delete_category_page(request: Request, category_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
journal_service.delete_category(db, category)
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
+126
View File
@@ -0,0 +1,126 @@
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
@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
class JournalCategoryCreate(JournalCategoryBase):
pass
class JournalCategoryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
color: 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
+372
View File
@@ -0,0 +1,372 @@
import calendar
import mimetypes
import random
import uuid
from datetime import date
from pathlib import Path
from fastapi import UploadFile
from PIL import Image
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.models.journal import (
JournalAttachment,
JournalAttachmentType,
JournalCategory,
JournalEntry,
JournalEntryMood,
JournalPrompt,
JournalTag,
)
from app.schemas.journal import (
JournalAttachmentOut,
JournalCalendarDay,
JournalCategoryCreate,
JournalDayDetailItem,
JournalEntryCreate,
JournalEntryUpdate,
JournalOnThisDayItem,
)
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
THUMBNAIL_WIDTH = 400
DEFAULT_CATEGORY_NAME = "일상"
# ---- 카테고리 ----
def list_categories(db: Session, user_id: int) -> list[JournalCategory]:
stmt = select(JournalCategory).where(JournalCategory.user_id == user_id)
stmt = stmt.order_by(
JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at
)
return list(db.scalars(stmt))
def get_category(db: Session, category_id: int, user_id: int) -> JournalCategory | None:
return db.scalar(
select(JournalCategory).where(JournalCategory.id == category_id, JournalCategory.user_id == user_id)
)
def create_category(db: Session, user_id: int, data: JournalCategoryCreate) -> JournalCategory:
category = JournalCategory(user_id=user_id, name=data.name, color=data.color)
db.add(category)
db.commit()
db.refresh(category)
return category
def update_category(db: Session, category: JournalCategory, data: JournalCategoryCreate) -> JournalCategory:
category.name = data.name
category.color = data.color
db.commit()
db.refresh(category)
return category
def ensure_default_category(db: Session, user_id: int) -> JournalCategory:
"""유저가 카테고리를 하나도 만든 적 없으면 기본 카테고리를 자동 생성한다."""
existing = db.scalar(
select(JournalCategory)
.where(JournalCategory.user_id == user_id)
.order_by(JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at)
.limit(1)
)
if existing is not None:
return existing
return create_category(db, user_id, JournalCategoryCreate(name=DEFAULT_CATEGORY_NAME))
def reorder_categories(db: Session, user_id: int, ordered_ids: list[int]) -> None:
categories = db.scalars(
select(JournalCategory).where(JournalCategory.id.in_(ordered_ids), JournalCategory.user_id == user_id)
).all()
category_map = {c.id: c for c in categories}
for index, category_id in enumerate(ordered_ids):
category = category_map.get(category_id)
if category is not None:
category.sort_order = index
db.commit()
def count_entries_by_category(db: Session, user_id: int) -> dict[int, int]:
rows = db.execute(
select(JournalEntry.category_id, func.count(JournalEntry.id))
.where(JournalEntry.user_id == user_id)
.group_by(JournalEntry.category_id)
).all()
return {row[0]: row[1] for row in rows}
def delete_category(db: Session, category: JournalCategory) -> None:
"""카테고리를 지우면 안의 엔트리도 함께 지워진다 — 첨부파일 디스크 삭제까지 하려면
ORM cascade에만 맡기지 않고 delete_entry를 하나씩 거쳐야 한다."""
entries = list(db.scalars(select(JournalEntry).where(JournalEntry.category_id == category.id)))
for entry in entries:
delete_entry(db, entry)
db.delete(category)
db.commit()
# ---- 태그 ----
def get_or_create_tags(db: Session, user_id: int, names: list[str]) -> list[JournalTag]:
tags = []
for name in names:
tag = db.scalar(select(JournalTag).where(JournalTag.user_id == user_id, JournalTag.name == name))
if tag is None:
tag = JournalTag(user_id=user_id, name=name)
db.add(tag)
db.flush()
tags.append(tag)
return tags
# ---- 엔트리 ----
def list_entries(
db: Session,
user_id: int,
category_id: int | None = None,
start: date | None = None,
end: date | None = None,
) -> list[JournalEntry]:
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
if start is not None:
stmt = stmt.where(JournalEntry.entry_date >= start)
if end is not None:
stmt = stmt.where(JournalEntry.entry_date <= end)
stmt = stmt.order_by(JournalEntry.entry_date.desc(), JournalEntry.created_at.desc())
return list(db.scalars(stmt))
def get_entry(db: Session, entry_id: int, user_id: int) -> JournalEntry | None:
return db.scalar(select(JournalEntry).where(JournalEntry.id == entry_id, JournalEntry.user_id == user_id))
def create_entry(db: Session, user_id: int, data: JournalEntryCreate) -> JournalEntry:
tags = get_or_create_tags(db, user_id, data.tags)
entry = JournalEntry(
user_id=user_id,
category_id=data.category_id,
entry_date=data.entry_date,
title=data.title,
content=data.content,
tags=tags,
moods=[JournalEntryMood(mood=m) for m in data.moods],
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def update_entry(db: Session, entry: JournalEntry, data: JournalEntryUpdate) -> JournalEntry:
tags = get_or_create_tags(db, entry.user_id, data.tags)
entry.category_id = data.category_id
entry.entry_date = data.entry_date
entry.title = data.title
entry.content = data.content
entry.tags = tags
entry.moods = [JournalEntryMood(mood=m) for m in data.moods]
db.commit()
db.refresh(entry)
return entry
def delete_entry(db: Session, entry: JournalEntry) -> None:
for attachment in list(entry.attachments):
_delete_attachment_files(attachment)
db.delete(entry)
db.commit()
# ---- 첨부파일 ----
def _media_dir(user_id: int, entry_id: int) -> Path:
return Path(settings.journal_media_root) / str(user_id) / str(entry_id)
def _delete_attachment_files(attachment: JournalAttachment) -> None:
for path_str in (attachment.file_path, attachment.thumbnail_path):
if path_str:
Path(path_str).unlink(missing_ok=True)
def save_attachment(db: Session, entry: JournalEntry, upload_file: UploadFile) -> JournalAttachment:
content_type = upload_file.content_type or ""
if content_type in ALLOWED_IMAGE_TYPES:
media_type = JournalAttachmentType.IMAGE
elif content_type in ALLOWED_VIDEO_TYPES:
media_type = JournalAttachmentType.VIDEO
else:
raise ValueError("지원하지 않는 파일 형식이에요 (사진: jpg/png/webp/gif, 영상: mp4/mov)")
data = upload_file.file.read()
max_bytes = settings.journal_max_upload_mb * 1024 * 1024
if len(data) > max_bytes:
raise ValueError(f"파일 용량은 {settings.journal_max_upload_mb}MB를 넘을 수 없어요")
target_dir = _media_dir(entry.user_id, entry.id)
target_dir.mkdir(parents=True, exist_ok=True)
ext = mimetypes.guess_extension(content_type) or Path(upload_file.filename or "").suffix or ""
stored_name = f"{uuid.uuid4().hex}{ext}"
file_path = target_dir / stored_name
file_path.write_bytes(data)
thumbnail_path: Path | None = None
if media_type == JournalAttachmentType.IMAGE:
thumbnail_path = target_dir / f"{uuid.uuid4().hex}_thumb.jpg"
try:
with Image.open(file_path) as img:
img = img.convert("RGB")
w, h = img.size
if w > THUMBNAIL_WIDTH:
img = img.resize((THUMBNAIL_WIDTH, round(h * THUMBNAIL_WIDTH / w)))
img.save(thumbnail_path, "JPEG", quality=85)
except Exception:
# 손상되었거나 Pillow가 못 읽는 이미지여도 원본 업로드 자체는 실패시키지 않는다.
thumbnail_path.unlink(missing_ok=True)
thumbnail_path = None
attachment = JournalAttachment(
entry_id=entry.id,
media_type=media_type,
file_path=str(file_path),
thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
original_filename=upload_file.filename or stored_name,
file_size=len(data),
)
db.add(attachment)
db.commit()
db.refresh(attachment)
return attachment
def get_attachment(db: Session, attachment_id: int, user_id: int) -> JournalAttachment | None:
return db.scalar(
select(JournalAttachment)
.join(JournalEntry, JournalAttachment.entry_id == JournalEntry.id)
.where(JournalAttachment.id == attachment_id, JournalEntry.user_id == user_id)
)
def delete_attachment(db: Session, attachment: JournalAttachment) -> None:
_delete_attachment_files(attachment)
db.delete(attachment)
db.commit()
# ---- 캘린더 / day-detail / 회상 ----
def _to_day_detail_item(entry: JournalEntry) -> JournalDayDetailItem:
return JournalDayDetailItem(
id=entry.id,
category_id=entry.category_id,
category_name=entry.category.name,
category_color=entry.category.color,
title=entry.title,
content=entry.content,
moods=[m.mood for m in entry.moods],
tags=[t.name for t in entry.tags],
attachments=[
JournalAttachmentOut(
id=a.id,
media_type=a.media_type,
original_filename=a.original_filename,
has_thumbnail=a.thumbnail_path is not None,
)
for a in entry.attachments
],
)
def get_monthly_journal_summary(
db: Session, user_id: int, year: int, month: int, category_id: int | None = None
) -> dict[date, JournalCalendarDay]:
"""해당 월의 날짜별 엔트리 개수와, 그날 등장한 카테고리 색상 목록(점 표시용)을 집계한다."""
days_in_month = calendar.monthrange(year, month)[1]
first_day = date(year, month, 1)
last_day = date(year, month, days_in_month)
stmt = (
select(JournalEntry.entry_date, JournalCategory.color)
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
rows = db.execute(stmt).all()
counts: dict[date, int] = {}
colors_by_date: dict[date, list[str]] = {}
for entry_date, color in rows:
counts[entry_date] = counts.get(entry_date, 0) + 1
colors = colors_by_date.setdefault(entry_date, [])
color = color or "var(--color-accent)"
if color not in colors:
colors.append(color)
return {
d: JournalCalendarDay(entry_date=d, total_count=counts[d], category_colors=colors_by_date[d])
for d in counts
}
def get_day_entries(
db: Session, user_id: int, target_date: date, category_id: int | None = None
) -> list[JournalDayDetailItem]:
stmt = select(JournalEntry).where(
JournalEntry.user_id == user_id, JournalEntry.entry_date == target_date
)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
stmt = stmt.order_by(JournalEntry.created_at)
return [_to_day_detail_item(e) for e in db.scalars(stmt)]
def get_on_this_day(db: Session, user_id: int, today: date) -> list[JournalOnThisDayItem]:
"""오늘과 월/일이 같은 과거 연도의 엔트리를 반환한다("1년 전 오늘" 회상 카드).
MONTH()/DAY() 같은 DB 종속 함수 대신 파이썬에서 필터링해 SQLite(테스트)/MariaDB(운영)
양쪽에서 동일하게 동작하게 한다 — 개인 규모 데이터라 전체 스캔 비용도 무시할 만하다.
"""
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id).order_by(JournalEntry.entry_date.desc())
matches = [
e
for e in db.scalars(stmt)
if e.entry_date.month == today.month
and e.entry_date.day == today.day
and e.entry_date.year != today.year
]
items = []
for e in matches:
base = _to_day_detail_item(e)
items.append(
JournalOnThisDayItem(
**base.model_dump(),
entry_date=e.entry_date,
years_ago=today.year - e.entry_date.year,
)
)
return items
# ---- 프롬프트 ----
def get_random_prompt(db: Session) -> JournalPrompt | None:
prompts = list(db.scalars(select(JournalPrompt)))
return random.choice(prompts) if prompts else None
+166 -1
View File
@@ -325,7 +325,10 @@ p {
/* 입력 */ /* 입력 */
input[type="text"], input[type="text"],
input[type="time"], input[type="time"],
input[type="password"] { input[type="date"],
input[type="password"],
select,
textarea {
width: 100%; width: 100%;
font-family: inherit; font-family: inherit;
font-size: 15px; font-size: 15px;
@@ -336,6 +339,33 @@ input[type="password"] {
color: var(--color-text); color: var(--color-text);
} }
textarea {
resize: vertical;
min-height: 90px;
line-height: 1.5;
}
select {
cursor: pointer;
}
input[type="file"] {
width: 100%;
font-family: inherit;
font-size: 14px;
color: var(--color-text-muted);
}
input[type="color"] {
width: 56px;
height: 40px;
padding: 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
cursor: pointer;
}
label { label {
display: block; display: block;
font-size: 13px; font-size: 13px;
@@ -466,6 +496,32 @@ label {
color: #fff; color: #fff;
} }
/* 저널 기분 선택 pill */
.mood-picker {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.mood-pill {
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-muted);
font-size: 14px;
padding: 8px 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.mood-pill.selected {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.difficulty-hint { .difficulty-hint {
font-size: 12px; font-size: 12px;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -810,6 +866,115 @@ label {
color: var(--color-border); color: var(--color-border);
} }
/* 저널링 */
.journal-day-dots {
display: flex;
gap: 2px;
}
.journal-day-dot {
width: 5px;
height: 5px;
border-radius: 50%;
}
.journal-prompt-card,
.on-this-day-card {
margin-top: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-control);
background: var(--color-success-tint);
font-size: 14px;
}
.journal-entry-item {
padding: 10px 0;
border-bottom: 1px solid var(--color-border);
}
.journal-entry-item:last-child {
border-bottom: none;
}
.journal-entry-title {
font-weight: 600;
margin-top: 6px;
}
.journal-entry-content {
white-space: pre-wrap;
margin-top: 4px;
}
.journal-entry-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tag-chip {
font-size: 12px;
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 2px 8px;
}
.mood-icon {
font-size: 18px;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
margin-top: 8px;
}
.attachment-thumb {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-control);
overflow: hidden;
border: 1px solid var(--color-border);
}
.attachment-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.attachment-video-link {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 12px;
text-align: center;
color: var(--color-text-muted);
text-decoration: none;
padding: 4px;
}
.attachment-delete-btn {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
/* 랜딩 페이지 (/) */ /* 랜딩 페이지 (/) */
.landing { .landing {
max-width: 480px; max-width: 480px;
+27
View File
@@ -0,0 +1,27 @@
(function () {
function initSortable() {
var list = document.getElementById("journal-category-list");
if (!list || typeof Sortable === "undefined") return;
Sortable.create(list, {
handle: ".drag-handle",
animation: 150,
forceFallback: true, // iOS Safari는 네이티브 HTML5 D&D 터치 지원이 불안정해서 자체 포인터 시뮬레이션을 강제한다
fallbackTolerance: 3,
onEnd: function () {
var ids = Array.from(list.children)
.map(function (el) { return el.getAttribute("data-category-id"); })
.filter(Boolean)
.map(Number);
fetch("/api/journal/categories/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category_ids: ids }),
});
},
});
}
document.addEventListener("DOMContentLoaded", initSortable);
})();
+2 -1
View File
@@ -1,9 +1,10 @@
const CACHE_NAME = "habit-tracker-v4"; const CACHE_NAME = "habit-tracker-v5";
const APP_SHELL = [ const APP_SHELL = [
"/static/css/style.css", "/static/css/style.css",
"/static/js/app.js", "/static/js/app.js",
"/static/js/push-register.js", "/static/js/push-register.js",
"/static/js/habit-reorder.js", "/static/js/habit-reorder.js",
"/static/js/journal-category-reorder.js",
"/static/js/vendor/htmx.min.js", "/static/js/vendor/htmx.min.js",
"/static/js/vendor/alpine.min.js", "/static/js/vendor/alpine.min.js",
"/static/js/vendor/sortable.min.js", "/static/js/vendor/sortable.min.js",
+6
View File
@@ -20,6 +20,7 @@
<script src="/static/js/push-register.js" defer></script> <script src="/static/js/push-register.js" defer></script>
<script src="/static/js/vendor/sortable.min.js" defer></script> <script src="/static/js/vendor/sortable.min.js" defer></script>
<script src="/static/js/habit-reorder.js" defer></script> <script src="/static/js/habit-reorder.js" defer></script>
<script src="/static/js/journal-category-reorder.js" defer></script>
<script src="/static/js/vendor/alpine.min.js" defer></script> <script src="/static/js/vendor/alpine.min.js" defer></script>
<script src="/static/js/app.js" defer></script> <script src="/static/js/app.js" defer></script>
</head> </head>
@@ -36,6 +37,7 @@
<a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a> <a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a>
<a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a> <a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a>
<a href="/history" class="{% block nav_history %}{% endblock %}">기록</a> <a href="/history" class="{% block nav_history %}{% endblock %}">기록</a>
<a href="/journal" class="{% block nav_journal %}{% endblock %}">일기</a>
</div> </div>
{% if current_user %} {% if current_user %}
<div class="nav-user"> <div class="nav-user">
@@ -60,6 +62,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2" /><line x1="3" y1="10" x2="21" y2="10" /><line x1="8" y1="3" x2="8" y2="7" /><line x1="16" y1="3" x2="16" y2="7" /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2" /><line x1="3" y1="10" x2="21" y2="10" /><line x1="8" y1="3" x2="8" y2="7" /><line x1="16" y1="3" x2="16" y2="7" /></svg>
<span>기록</span> <span>기록</span>
</a> </a>
<a href="/journal" class="tab-item {{ self.nav_journal() }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /></svg>
<span>일기</span>
</a>
</nav> </nav>
{% endif %} {% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
+230
View File
@@ -0,0 +1,230 @@
{% extends "base.html" %}
{% block title %}일기 · 해빗랩{% endblock %}
{% block nav_journal %}active{% endblock %}
{% block shell_class %} wide{% endblock %}
{% block content %}
<h1>일기</h1>
<div style="display:flex; align-items:center; gap: var(--space-1);">
<div class="tabs" style="flex:1; min-width:0;">
<a href="/journal?year={{ year }}&month={{ month }}" class="{{ 'active' if tab != 'manage' and not category_id }}">전체</a>
{% for category in categories %}
<a
href="/journal?year={{ year }}&month={{ month }}&category_id={{ category.id }}"
class="{{ 'active' if tab != 'manage' and category_id == category.id }}"
>{{ category.name }}</a>
{% endfor %}
</div>
<a href="/journal?tab=manage" class="btn btn-secondary{{ ' active' if tab == 'manage' }}" style="flex-shrink:0; margin-bottom: var(--space-2); text-decoration:none;">⚙ 관리</a>
</div>
{% if tab == "manage" %}
{% if categories %}
<div class="card">
<div class="day-detail-list" id="journal-category-list">
{% for category in categories %}
<div class="journal-entry-item" data-category-id="{{ category.id }}" x-data="{ editing: false }">
<div class="day-detail-item" x-show="!editing">
<span style="display:flex; align-items:center; gap: 8px;">
<span class="drag-handle" aria-hidden="true"></span>
<span class="badge" style="border-color: {{ category.color or 'var(--color-accent)' }};">{{ category.name }}</span>
<span class="badge">기록 {{ entry_counts.get(category.id, 0) }}개</span>
</span>
<span style="display:flex; gap:8px;">
<button type="button" class="btn btn-secondary" @click="editing = true">수정</button>
<button
type="button"
class="btn btn-danger-ghost"
hx-post="/journal/categories/{{ category.id }}/delete"
hx-target="body"
hx-swap="none"
hx-confirm="'{{ category.name }}' 카테고리를 삭제할까요? 이 카테고리의 모든 기록({{ entry_counts.get(category.id, 0) }}개)과 첨부파일도 함께 삭제됩니다."
>삭제</button>
</span>
</div>
<form
x-show="editing"
x-cloak
style="padding: 8px 0;"
hx-post="/journal/categories/{{ category.id }}/edit"
hx-target="#journal-category-edit-error-{{ category.id }}"
hx-swap="innerHTML"
>
<div class="field">
<label for="edit-category-name-{{ category.id }}">카테고리 이름</label>
<input type="text" id="edit-category-name-{{ category.id }}" name="name" required value="{{ category.name }}" />
</div>
<div class="field">
<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 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>
<button type="button" class="btn btn-secondary" @click="editing = false">취소</button>
</div>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="card" x-data="{ open: false }">
<button type="button" class="btn btn-secondary btn-block" @click="open = !open">
<span x-show="!open">+ 카테고리 추가</span>
<span x-show="open" x-cloak>닫기</span>
</button>
<form x-show="open" x-cloak style="margin-top: var(--space-2);" hx-post="/journal/categories/new" hx-target="#journal-category-error" hx-swap="innerHTML">
<div class="field">
<label for="new-category-name">카테고리 이름</label>
<input type="text" id="new-category-name" name="name" required placeholder="예: 투자" />
</div>
<div class="field">
<label for="new-category-color">색상 (선택)</label>
<input type="color" id="new-category-color" name="color" value="#d97757" />
</div>
<div id="journal-category-error" class="form-error-text"></div>
<button type="submit" class="btn btn-primary btn-block">추가하기</button>
</form>
</div>
{% else %}
{% if on_this_day %}
<div class="card on-this-day-card">
<h3 style="margin-top:0;">📅 {{ on_this_day|length }}개의 지난 오늘</h3>
{% for item in on_this_day %}
<div class="day-detail-item">
<span>
<span class="badge" style="border-color: {{ item.category_color or 'var(--color-accent)' }};">{{ item.category_name }}</span>
{{ item.entry_date.year }}년 · {{ item.years_ago }}년 전
</span>
<span>{{ item.title or (item.content[:30] ~ ('…' if item.content|length > 30 else '')) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<div
class="card"
x-data="{
open: false,
moods: [],
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
}"
>
<button type="button" class="btn btn-primary btn-block" @click="open = !open">
<span x-show="!open">+ 새 기록 쓰기</span>
<span x-show="open" x-cloak>닫기</span>
</button>
{% if prompt %}
<div class="journal-prompt-card">💭 {{ prompt.question_text }}</div>
{% endif %}
<form
x-show="open"
x-cloak
style="margin-top: var(--space-2);"
hx-post="/journal/new"
hx-target="#journal-form-error"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
>
<div class="field">
<label for="new-entry-category">카테고리</label>
<select id="new-entry-category" name="category_id">
{% for category in categories %}
<option value="{{ category.id }}" {{ 'selected' if category_id == category.id }}>{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="new-entry-date">날짜</label>
<input type="date" id="new-entry-date" name="entry_date" value="{{ today_iso }}" required />
</div>
<div class="field">
<label for="new-entry-title">제목 (선택)</label>
<input type="text" id="new-entry-title" name="title" placeholder="제목을 입력하세요" />
</div>
<div class="field">
<label for="new-entry-content">내용</label>
<textarea id="new-entry-content" name="content" rows="5" required placeholder="오늘 하루는 어땠나요?"></textarea>
</div>
<div class="field">
<label>기분 (선택, 여러 개 선택 가능)</label>
<input type="hidden" name="moods" :value="moods.join(',')" />
<div class="mood-picker">
{% for value, emoji, label in journal_mood_options %}
<button
type="button"
class="mood-pill"
:class="{ selected: moods.includes('{{ value.value }}') }"
@click="toggleMood('{{ value.value }}')"
>{{ emoji }} {{ label }}</button>
{% endfor %}
</div>
</div>
<div class="field">
<label for="new-entry-tags">태그 (선택, 쉼표로 구분)</label>
<input type="text" id="new-entry-tags" name="tags" placeholder="예: 투자, 회고" />
</div>
<div class="field">
<label for="new-entry-files">사진/영상 첨부 (선택)</label>
<input type="file" id="new-entry-files" name="files" accept="image/*,video/*" multiple />
</div>
<div id="journal-form-error" class="form-error-text"></div>
<button type="submit" class="btn btn-primary btn-block">기록 저장</button>
</form>
</div>
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
<a href="/journal?year={{ prev_year }}&month={{ prev_month }}{{ '&category_id=' ~ category_id if category_id }}" class="btn btn-secondary"></a>
<h2 style="margin:0;">{{ year }}년 {{ month }}월</h2>
<a href="/journal?year={{ next_year }}&month={{ next_month }}{{ '&category_id=' ~ category_id if category_id }}" class="btn btn-secondary"></a>
</div>
<div class="calendar-grid calendar-grid-header">
{% for wd in ["일", "월", "화", "수", "목", "금", "토"] %}
<div class="calendar-weekday">{{ wd }}</div>
{% endfor %}
</div>
{% for week in weeks %}
<div class="calendar-grid">
{% for d in week %}
{% set day_summary = summary_map.get(d) %}
<div
class="calendar-cell clickable{{ '' if d.month == month else ' muted' }}"
hx-get="/journal/day/{{ d.isoformat() }}"
hx-target="#day-detail"
hx-swap="innerHTML"
>
<span class="calendar-date">{{ d.day }}</span>
{% if day_summary %}
<span class="journal-day-dots">
{% for color in day_summary.category_colors %}
<span class="journal-day-dot" style="background: {{ color }};"></span>
{% endfor %}
</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div id="day-detail"></div>
{% endif %}
{% endblock %}
@@ -0,0 +1,135 @@
{% set weekday_names = ["월", "화", "수", "목", "금", "토", "일"] %}
<div class="card day-detail-card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
<h3 style="margin:0;">{{ entry_date.strftime("%Y.%m.%d") }} ({{ weekday_names[entry_date.weekday()] }})</h3>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('day-detail').innerHTML=''">닫기</button>
</div>
{% if items %}
<div class="day-detail-list">
{% for item in items %}
<div
class="journal-entry-item"
x-data="{
editing: {{ 'true' if editing_entry_id == item.id else 'false' }},
moods: [{% for m in item.moods %}'{{ m.value }}'{{ ',' if not loop.last }}{% endfor %}],
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
}"
>
<div x-show="!editing">
<div style="display:flex; align-items:center; justify-content:space-between;">
<span class="badge" style="border-color: {{ item.category_color or 'var(--color-accent)' }};">{{ item.category_name }}</span>
{% if item.moods %}
<span class="mood-icon">{% for m in item.moods %}{{ journal_mood_emoji[m.value] }}{% endfor %}</span>
{% endif %}
</div>
{% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %}
<div class="journal-entry-content">{{ item.content }}</div>
{% if item.tags %}
<div class="journal-entry-tags">
{% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %}
</div>
{% endif %}
{% if item.attachments %}
<div class="attachment-grid">
{% for attachment in item.attachments %}
<div class="attachment-thumb">
{% if attachment.media_type.value == "image" %}
<a href="/api/journal/media/{{ attachment.id }}" target="_blank">
<img src="/api/journal/media/{{ attachment.id }}?thumbnail=true" alt="{{ attachment.original_filename }}" />
</a>
{% else %}
<a href="/api/journal/media/{{ attachment.id }}" target="_blank" class="attachment-video-link">▶ {{ attachment.original_filename }}</a>
{% endif %}
<button
type="button"
class="attachment-delete-btn"
hx-post="/journal/{{ item.id }}/attachments/{{ attachment.id }}/delete"
hx-target="#day-detail"
hx-swap="innerHTML"
hx-confirm="이 첨부파일을 삭제할까요?"
aria-label="첨부파일 삭제"
>&times;</button>
</div>
{% endfor %}
</div>
{% endif %}
<div class="habit-item-actions" style="margin-top: 8px;">
<button type="button" class="btn btn-secondary" @click="editing = true">수정</button>
<button
type="button"
class="btn btn-danger-ghost"
hx-post="/journal/{{ item.id }}/delete"
hx-target="#day-detail"
hx-swap="innerHTML"
hx-confirm="이 기록을 삭제할까요?"
>삭제</button>
</div>
</div>
<form
x-show="editing"
x-cloak
style="padding: 12px 4px; border-bottom: 1px solid var(--color-border);"
hx-post="/journal/{{ item.id }}/edit"
hx-target="#day-detail"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
>
<div class="field">
<label>카테고리</label>
<select name="category_id">
{% for category in categories %}
<option value="{{ category.id }}" {{ 'selected' if category.id == item.category_id }}>{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>날짜</label>
<input type="date" name="entry_date" value="{{ entry_date.isoformat() }}" required />
</div>
<div class="field">
<label>제목</label>
<input type="text" name="title" value="{{ item.title or '' }}" />
</div>
<div class="field">
<label>내용</label>
<textarea name="content" rows="4" required>{{ item.content }}</textarea>
</div>
<div class="field">
<label>기분 (여러 개 선택 가능)</label>
<input type="hidden" name="moods" :value="moods.join(',')" />
<div class="mood-picker">
{% for value, emoji, label in journal_mood_options %}
<button
type="button"
class="mood-pill"
:class="{ selected: moods.includes('{{ value.value }}') }"
@click="toggleMood('{{ value.value }}')"
>{{ emoji }} {{ label }}</button>
{% endfor %}
</div>
</div>
<div class="field">
<label>태그 (쉼표로 구분)</label>
<input type="text" name="tags" value="{{ item.tags|join(', ') }}" />
</div>
<div class="field">
<label>사진/영상 추가</label>
<input type="file" name="files" accept="image/*,video/*" multiple />
</div>
{% if edit_error and editing_entry_id == item.id %}
<div class="form-error-text">{{ edit_error }}</div>
{% endif %}
<div style="display:flex; gap:8px;">
<button type="submit" class="btn btn-primary" style="flex:1;">저장</button>
<button type="button" class="btn btn-secondary" @click="editing = false">취소</button>
</div>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">이 날 작성한 기록이 없어요.</div>
{% endif %}
</div>
@@ -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) + ")",
)
# 삭제된 행 자체는 복구하지 않는다(내용을 알 수 없음) — 제약만 원상복구.
+1 -1
View File
@@ -19,12 +19,12 @@ dependencies = [
"py-vapid>=1.9", "py-vapid>=1.9",
"authlib>=1.3", "authlib>=1.3",
"httpx>=0.27", "httpx>=0.27",
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임)
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성용 (런타임 미사용)
"paramiko>=3.4", # scripts/deploy_sftp.py SFTP 배포용 (런타임 미사용) "paramiko>=3.4", # scripts/deploy_sftp.py SFTP 배포용 (런타임 미사용)
] ]
+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