From 54c971875ca1f6616448f8a3685121f20b9144f7 Mon Sep 17 00:00:00 2001 From: shinalok Date: Tue, 4 Aug 2026 18:40:07 +0900 Subject: [PATCH] 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 --- .env.example | 4 + .gitignore | 1 + CLAUDE.md | 15 +- app/config.py | 3 + app/main.py | 6 +- app/models/__init__.py | 18 + app/models/journal.py | 131 ++++++ app/routers/journal.py | 36 ++ app/routers/journal_pages.py | 346 ++++++++++++++++ app/schemas/journal.py | 126 ++++++ app/services/journal_service.py | 372 ++++++++++++++++++ app/static/css/style.css | 167 +++++++- app/static/js/journal-category-reorder.js | 27 ++ app/static/service-worker.js | 3 +- app/templates/base.html | 6 + app/templates/journal.html | 230 +++++++++++ .../partials/journal_day_detail.html | 135 +++++++ .../versions/0010_add_journal_tables.py | 143 +++++++ .../versions/0011_journal_entry_multi_mood.py | 51 +++ .../0012_remove_satisfaction_moods.py | 45 +++ pyproject.toml | 2 +- tests/test_api_journal.py | 83 ++++ tests/test_journal_service.py | 285 ++++++++++++++ tests/test_pages_journal.py | 257 ++++++++++++ 24 files changed, 2485 insertions(+), 7 deletions(-) create mode 100644 app/models/journal.py create mode 100644 app/routers/journal.py create mode 100644 app/routers/journal_pages.py create mode 100644 app/schemas/journal.py create mode 100644 app/services/journal_service.py create mode 100644 app/static/js/journal-category-reorder.js create mode 100644 app/templates/journal.html create mode 100644 app/templates/partials/journal_day_detail.html create mode 100644 migrations/versions/0010_add_journal_tables.py create mode 100644 migrations/versions/0011_journal_entry_multi_mood.py create mode 100644 migrations/versions/0012_remove_satisfaction_moods.py create mode 100644 tests/test_api_journal.py create mode 100644 tests/test_journal_service.py create mode 100644 tests/test_pages_journal.py diff --git a/.env.example b/.env.example index 0ecc2d9..2aa6961 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,7 @@ GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:you@example.com + +# 저널 사진/영상 첨부 저장 경로(로컬 디스크)와 업로드 용량 제한(MB). 둘 다 기본값이 있어 생략 가능. +# JOURNAL_MEDIA_ROOT=app/media/journal +# JOURNAL_MAX_UPLOAD_MB=20 diff --git a/.gitignore b/.gitignore index 1575719..da3197d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ node_modules/ logs/ img*.png image.md +app/media/ diff --git a/CLAUDE.md b/CLAUDE.md index 7b24bd0..68af95f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,8 +36,8 @@ pytest tests/test_habits.py::test_name # 단일 테스트 ### 요청 흐름 (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/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/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`, `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/services/*`, `app/schemas/*`를 공유한다 — 새 기능을 추가할 때 API와 페이지 라우터 양쪽에서 비즈니스 로직을 중복 구현하지 말고 `app/services/`에 두고 재사용할 것. @@ -70,7 +70,7 @@ pytest tests/test_habits.py::test_name # 단일 테스트 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`). 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`). 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`를 모두 정리한 뒤 하나만 새로 띄울 것. @@ -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": ...}`로 바뀌었다. - **리디렉션 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`까지 도달하는지 확인해야 한다. +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 배포 diff --git a/app/config.py b/app/config.py index 140ecb0..7942034 100644 --- a/app/config.py +++ b/app/config.py @@ -18,5 +18,8 @@ class Settings(BaseSettings): session_cookie_name: str = "habit_session" timezone: str = "Asia/Seoul" + journal_media_root: str = "app/media/journal" + journal_max_upload_mb: int = 20 + settings = Settings() diff --git a/app/main.py b/app/main.py index cabff52..ccf5c02 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import logging from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI, Request from fastapi.responses import FileResponse, JSONResponse, RedirectResponse @@ -9,7 +10,7 @@ from starlette.middleware.sessions import SessionMiddleware from app.config import settings 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.security import get_current_user_optional from app.services import scheduler_service @@ -19,6 +20,7 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): + Path(settings.journal_media_root).mkdir(parents=True, exist_ok=True) scheduler_service.start_scheduler() yield 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(habits.router) +app.include_router(journal.router) app.include_router(logs.router) app.include_router(push.router) +app.include_router(journal_pages.router) app.include_router(pages.router) diff --git a/app/models/__init__.py b/app/models/__init__.py index ec4cc29..7afb1fa 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,15 @@ from app.models.habit import Habit, HabitStatus, HabitType 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.push_subscription import PushSubscription from app.models.user import User @@ -9,6 +19,14 @@ __all__ = [ "HabitStatus", "HabitType", "HabitLog", + "JournalAttachment", + "JournalAttachmentType", + "JournalCategory", + "JournalEntry", + "JournalEntryMood", + "JournalMood", + "JournalPrompt", + "JournalTag", "HabitNotificationLog", "SummaryNotificationLog", "PushSubscription", diff --git a/app/models/journal.py b/app/models/journal.py new file mode 100644 index 0000000..6db0e58 --- /dev/null +++ b/app/models/journal.py @@ -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) diff --git a/app/routers/journal.py b/app/routers/journal.py new file mode 100644 index 0000000..5a29f77 --- /dev/null +++ b/app/routers/journal.py @@ -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} diff --git a/app/routers/journal_pages.py b/app/routers/journal_pages.py new file mode 100644 index 0000000..fa90fd4 --- /dev/null +++ b/app/routers/journal_pages.py @@ -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 diff --git a/app/schemas/journal.py b/app/schemas/journal.py new file mode 100644 index 0000000..d569093 --- /dev/null +++ b/app/schemas/journal.py @@ -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 diff --git a/app/services/journal_service.py b/app/services/journal_service.py new file mode 100644 index 0000000..7a885f2 --- /dev/null +++ b/app/services/journal_service.py @@ -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 diff --git a/app/static/css/style.css b/app/static/css/style.css index 4385a80..a342b07 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -325,7 +325,10 @@ p { /* 입력 */ input[type="text"], input[type="time"], -input[type="password"] { +input[type="date"], +input[type="password"], +select, +textarea { width: 100%; font-family: inherit; font-size: 15px; @@ -336,6 +339,33 @@ input[type="password"] { 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 { display: block; font-size: 13px; @@ -466,6 +496,32 @@ label { 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 { font-size: 12px; color: var(--color-text-muted); @@ -810,6 +866,115 @@ label { 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 { max-width: 480px; diff --git a/app/static/js/journal-category-reorder.js b/app/static/js/journal-category-reorder.js new file mode 100644 index 0000000..b073fcd --- /dev/null +++ b/app/static/js/journal-category-reorder.js @@ -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); +})(); diff --git a/app/static/service-worker.js b/app/static/service-worker.js index 661f74b..e92e6cb 100644 --- a/app/static/service-worker.js +++ b/app/static/service-worker.js @@ -1,9 +1,10 @@ -const CACHE_NAME = "habit-tracker-v4"; +const CACHE_NAME = "habit-tracker-v5"; const APP_SHELL = [ "/static/css/style.css", "/static/js/app.js", "/static/js/push-register.js", "/static/js/habit-reorder.js", + "/static/js/journal-category-reorder.js", "/static/js/vendor/htmx.min.js", "/static/js/vendor/alpine.min.js", "/static/js/vendor/sortable.min.js", diff --git a/app/templates/base.html b/app/templates/base.html index 57630f8..b4010f9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -20,6 +20,7 @@ + @@ -36,6 +37,7 @@ 오늘 습관 관리 기록 + 일기 {% if current_user %}