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:
@@ -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
|
||||
|
||||
@@ -13,3 +13,4 @@ node_modules/
|
||||
logs/
|
||||
img*.png
|
||||
image.md
|
||||
app/media/
|
||||
|
||||
@@ -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 배포
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<script src="/static/js/push-register.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/journal-category-reorder.js" defer></script>
|
||||
<script src="/static/js/vendor/alpine.min.js" defer></script>
|
||||
<script src="/static/js/app.js" defer></script>
|
||||
</head>
|
||||
@@ -36,6 +37,7 @@
|
||||
<a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a>
|
||||
<a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a>
|
||||
<a href="/history" class="{% block nav_history %}{% endblock %}">기록</a>
|
||||
<a href="/journal" class="{% block nav_journal %}{% endblock %}">일기</a>
|
||||
</div>
|
||||
{% if current_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>
|
||||
<span>기록</span>
|
||||
</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>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
@@ -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="첨부파일 삭제"
|
||||
>×</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
@@ -19,12 +19,12 @@ dependencies = [
|
||||
"py-vapid>=1.9",
|
||||
"authlib>=1.3",
|
||||
"httpx>=0.27",
|
||||
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임)
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성용 (런타임 미사용)
|
||||
"paramiko>=3.4", # scripts/deploy_sftp.py SFTP 배포용 (런타임 미사용)
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user