Compare commits
4
Commits
9dfcbcec8f
...
c466cc6639
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c466cc6639 | ||
|
|
54c971875c | ||
|
|
ef2a3ea082 | ||
|
|
34a128a79b |
@@ -20,3 +20,7 @@ GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
|
|||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
VAPID_SUBJECT=mailto:you@example.com
|
VAPID_SUBJECT=mailto:you@example.com
|
||||||
|
|
||||||
|
# 저널 사진/영상 첨부 저장 경로(로컬 디스크)와 업로드 용량 제한(MB). 둘 다 기본값이 있어 생략 가능.
|
||||||
|
# JOURNAL_MEDIA_ROOT=app/media/journal
|
||||||
|
# JOURNAL_MAX_UPLOAD_MB=20
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
.env.dev
|
.env.dev
|
||||||
|
deploy.env
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
@@ -12,3 +13,4 @@ node_modules/
|
|||||||
logs/
|
logs/
|
||||||
img*.png
|
img*.png
|
||||||
image.md
|
image.md
|
||||||
|
app/media/
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ pytest tests/test_habits.py::test_name # 단일 테스트
|
|||||||
|
|
||||||
### 요청 흐름 (2가지 라우터 계열이 공존)
|
### 요청 흐름 (2가지 라우터 계열이 공존)
|
||||||
|
|
||||||
- `app/routers/habits.py`, `app/routers/logs.py`, `app/routers/push.py` — `/api/*` 하위, JSON in/out API. 라우터 레벨 `dependencies=[Depends(require_login)]`로 기본 보호되고, 유저 객체가 필요한 각 엔드포인트는 `current_user: User = Depends(require_login)`을 시그니처에 추가로 선언한다(FastAPI가 같은 요청 안에서 dependency를 캐싱하므로 DB 조회가 중복되지 않는다). 미인증 시 401 JSON을 반환.
|
- `app/routers/habits.py`, `app/routers/logs.py`, `app/routers/push.py`, `app/routers/journal.py` — `/api/*` 하위, JSON in/out API. 라우터 레벨 `dependencies=[Depends(require_login)]`로 기본 보호되고, 유저 객체가 필요한 각 엔드포인트는 `current_user: User = Depends(require_login)`을 시그니처에 추가로 선언한다(FastAPI가 같은 요청 안에서 dependency를 캐싱하므로 DB 조회가 중복되지 않는다). 미인증 시 401 JSON을 반환. `journal.py`는 현재 첨부파일 스트리밍(`GET /api/journal/media/{attachment_id}`) 하나뿐이다.
|
||||||
- `app/routers/pages.py` — SSR 페이지(`/login`, `/today`, `/habits`, `/history`)와 htmx가 폼 제출로 호출하는 액션 엔드포인트(`/habits/new`, `/habits/{id}/complete` 등). 미인증 시 401 대신 `/login`으로 303 리다이렉트한다 — `_current_user_or_redirect(request, db)`가 `User`(로그인됨) 또는 `RedirectResponse`(미인증)를 반환하고, 각 핸들러는 `isinstance(current, RedirectResponse)`로 분기한다. 폼 액션은 대부분 처리 후 `HX-Redirect` 헤더로 같은 탭을 새로고침하는 방식으로 단순화되어 있다(부분 DOM 스왑이 아님).
|
- `app/routers/pages.py`, `app/routers/journal_pages.py` — SSR 페이지(`/login`, `/today`, `/habits`, `/history`, `/journal`)와 htmx가 폼 제출로 호출하는 액션 엔드포인트(`/habits/new`, `/habits/{id}/complete`, `/journal/new` 등). 미인증 시 401 대신 `/login`으로 303 리다이렉트한다 — `_current_user_or_redirect(request, db)`가 `User`(로그인됨) 또는 `RedirectResponse`(미인증)를 반환하고, 각 핸들러는 `isinstance(current, RedirectResponse)`로 분기한다. 폼 액션은 대부분 처리 후 `HX-Redirect` 헤더로 같은 탭을 새로고침하는 방식으로 단순화되어 있다(부분 DOM 스왑이 아님) — 단 `/today`의 체크 토글과 `/journal`의 day-detail 내부 액션(수정/삭제)처럼 이미 htmx partial 안에 있는 경우는 예외로, 그 partial을 다시 렌더링해서 돌려준다. `journal_pages.py`는 별도 파일이지만 `pages.py`의 `_current_user_or_redirect`와 `templates`(Jinja2Templates 인스턴스)를 그대로 import해서 재사용한다 — `pages.py`가 계속 비대해지는 걸 막기 위해 기능별로 페이지 라우터 파일을 분리하기 시작한 첫 사례.
|
||||||
- `app/routers/auth.py` — `/auth/*`, 페이지 네비게이션(리다이렉트/폼 POST)이라 `/api/*` 프리픽스를 쓰지 않는다. `GET /auth/google/login`이 구글 동의 화면으로 리다이렉트하고, `GET /auth/google/callback`이 `authlib`로 id_token을 검증해 `User`를 조회/생성한 뒤 세션 쿠키를 발급, `POST /auth/logout`이 쿠키를 지운다. OAuth 핸드셰이크 중 state/nonce를 담는 `SessionMiddleware`(`app/main.py`)는 로그인 유지용 쿠키(`habit_session`)와 별개의 임시 쿠키(`oauth_session`)를 쓴다.
|
- `app/routers/auth.py` — `/auth/*`, 페이지 네비게이션(리다이렉트/폼 POST)이라 `/api/*` 프리픽스를 쓰지 않는다. `GET /auth/google/login`이 구글 동의 화면으로 리다이렉트하고, `GET /auth/google/callback`이 `authlib`로 id_token을 검증해 `User`를 조회/생성한 뒤 세션 쿠키를 발급, `POST /auth/logout`이 쿠키를 지운다. OAuth 핸드셰이크 중 state/nonce를 담는 `SessionMiddleware`(`app/main.py`)는 로그인 유지용 쿠키(`habit_session`)와 별개의 임시 쿠키(`oauth_session`)를 쓴다.
|
||||||
|
|
||||||
두 계열이 같은 `app/services/*`, `app/schemas/*`를 공유한다 — 새 기능을 추가할 때 API와 페이지 라우터 양쪽에서 비즈니스 로직을 중복 구현하지 말고 `app/services/`에 두고 재사용할 것.
|
두 계열이 같은 `app/services/*`, `app/schemas/*`를 공유한다 — 새 기능을 추가할 때 API와 페이지 라우터 양쪽에서 비즈니스 로직을 중복 구현하지 말고 `app/services/`에 두고 재사용할 것.
|
||||||
@@ -70,7 +70,7 @@ pytest tests/test_habits.py::test_name # 단일 테스트
|
|||||||
1. ✅ 기반 셋업 + 습관 CRUD (`/habits` 화면, PIN 로그인 — 7단계에서 구글 OAuth로 대체됨)
|
1. ✅ 기반 셋업 + 습관 CRUD (`/habits` 화면, PIN 로그인 — 7단계에서 구글 OAuth로 대체됨)
|
||||||
2. ✅ 데일리 체크 & `/today` 화면 — `/habits`와 달리 여기는 실제 htmx 부분 갱신을 쓴다: 체크 버튼이 `#today-content`를 통째로 `partials/today_content.html`로 교체한다(개별 아이템만 스왑하지 않는 이유: 진행률 배지도 같이 갱신해야 해서). 이 partial은 `today.html`의 최초 렌더와 토글 응답에서 동일하게 재사용된다 (`app/routers/pages.py`의 `_today_context`).
|
2. ✅ 데일리 체크 & `/today` 화면 — `/habits`와 달리 여기는 실제 htmx 부분 갱신을 쓴다: 체크 버튼이 `#today-content`를 통째로 `partials/today_content.html`로 교체한다(개별 아이템만 스왑하지 않는 이유: 진행률 배지도 같이 갱신해야 해서). 이 partial은 `today.html`의 최초 렌더와 토글 응답에서 동일하게 재사용된다 (`app/routers/pages.py`의 `_today_context`).
|
||||||
3. ✅ 기록 확인 화면 (월별/주별) — `/history`. 두 뷰 모두 순수 SSR(링크 기반 이전/다음 네비게이션)이고 htmx 상호작용은 없다. 월별 집계와 주별 매트릭스는 모두 **현재 active 상태인 습관만** 기준으로 계산한다(`app/services/log_service.py`의 `get_monthly_summary`/`get_weekly_matrix`) — 완료 처리되었거나 삭제된 습관은 과거 날짜라도 집계에서 빠진다. 즉 "그 날 실제로 무엇이 예정되어 있었는가"를 재구성하지 않고 "지금 진행 중인 습관 기준으로 최근 기록이 어떤지"를 보여주는 단순화된 설계다. 히트맵 투명도는 `app/template_utils.py`의 `heatmap_opacity()`가 계산해 `--color-accent-rgb` CSS 변수와 조합한다.
|
3. ✅ 기록 확인 화면 (월별/주별) — `/history`. 두 뷰 모두 순수 SSR(링크 기반 이전/다음 네비게이션)이고 htmx 상호작용은 없다. 월별 집계와 주별 매트릭스는 모두 **현재 active 상태인 습관만** 기준으로 계산한다(`app/services/log_service.py`의 `get_monthly_summary`/`get_weekly_matrix`) — 완료 처리되었거나 삭제된 습관은 과거 날짜라도 집계에서 빠진다. 즉 "그 날 실제로 무엇이 예정되어 있었는가"를 재구성하지 않고 "지금 진행 중인 습관 기준으로 최근 기록이 어떤지"를 보여주는 단순화된 설계다. 히트맵 투명도는 `app/template_utils.py`의 `heatmap_opacity()`가 계산해 `--color-accent-rgb` CSS 변수와 조합한다.
|
||||||
4. ✅ PWA 기본 (manifest, 서비스워커, 아이콘) — `manifest.json`은 `/static/manifest.json`에 있고 `base.html`이 `/static/manifest.json`으로 직접 링크한다(루트 경로 라우트 아님, manifest는 자체 `scope` 필드로 범위를 지정하므로 파일 위치가 중요하지 않음). 반면 `service-worker.js`는 앱 전체를 커버해야 해서 `app/main.py`의 `GET /service-worker.js`가 `app/static/service-worker.js`를 루트 경로로 직접 서빙한다 — 이 둘의 서빙 방식이 다른 이유이니 헷갈리지 말 것. 아이콘은 `scripts/generate_icons.py`(Pillow 필요, 런타임 의존성 아님)로 생성.
|
4. ✅ PWA 기본 (manifest, 서비스워커, 아이콘) — `manifest.json`은 `/static/manifest.json`에 있고 `base.html`이 `/static/manifest.json`으로 직접 링크한다(루트 경로 라우트 아님, manifest는 자체 `scope` 필드로 범위를 지정하므로 파일 위치가 중요하지 않음). 반면 `service-worker.js`는 앱 전체를 커버해야 해서 `app/main.py`의 `GET /service-worker.js`가 `app/static/service-worker.js`를 루트 경로로 직접 서빙한다 — 이 둘의 서빙 방식이 다른 이유이니 헷갈리지 말 것. 아이콘은 `scripts/generate_icons.py`(Pillow 필요)로 생성. Pillow는 8단계(저널링)의 첨부 이미지 썸네일 생성에도 쓰이기 시작해 지금은 런타임 의존성이다(`pyproject.toml`의 `dependencies`에 있음, 예전엔 `dev` 전용이었음).
|
||||||
- **캐싱 전략 (중요)**: 처음엔 모든 GET을 stale-while-revalidate로 캐싱했는데, `/habits`·`/today`처럼 사용자가 직접 데이터를 바꾸고 곧바로 재방문하는 페이지에서 "방금 저장한 게 사라진 것처럼" 보이는 실사용 버그로 이어졌다(습관 생성/수정 후 `HX-Redirect`로 재이동했을 때 캐시된 옛 페이지가 먼저 뜸). `service-worker.js`의 fetch 핸들러는 이제 `request.mode === "navigate"`(페이지 탐색)와 그 외(정적 자산)를 분리한다 — 탐색은 **네트워크 우선**(오프라인일 때만 캐시 폴백), 정적 자산만 캐시 우선 stale-while-revalidate 유지. 서비스워커 캐시 로직을 바꿀 때마다 `CACHE_NAME` 버전을 올려야 `activate` 핸들러가 구버전 캐시를 정리한다(현재 `habit-tracker-v2`).
|
- **캐싱 전략 (중요)**: 처음엔 모든 GET을 stale-while-revalidate로 캐싱했는데, `/habits`·`/today`처럼 사용자가 직접 데이터를 바꾸고 곧바로 재방문하는 페이지에서 "방금 저장한 게 사라진 것처럼" 보이는 실사용 버그로 이어졌다(습관 생성/수정 후 `HX-Redirect`로 재이동했을 때 캐시된 옛 페이지가 먼저 뜸). `service-worker.js`의 fetch 핸들러는 이제 `request.mode === "navigate"`(페이지 탐색)와 그 외(정적 자산)를 분리한다 — 탐색은 **네트워크 우선**(오프라인일 때만 캐시 폴백), 정적 자산만 캐시 우선 stale-while-revalidate 유지. 서비스워커 캐시 로직을 바꿀 때마다 `CACHE_NAME` 버전을 올려야 `activate` 핸들러가 구버전 캐시를 정리한다(현재 `habit-tracker-v2`).
|
||||||
5. ✅ Web Push 알림 (APScheduler 매분 tick + VAPID) — VAPID 키 원시 바이트는 `py_vapid`의 `b64urlencode`로 직접 인코딩해서 `.env`에 저장한다(`Vapid02`에 `public_key_str` 같은 헬퍼가 없음, `scripts/generate_vapid_keys.py` 참고). `scheduler_service._tick()`은 습관별 job을 등록/해제하는 대신 **매분 폴링** 방식으로 전체 active 습관을 훑어 지금 시각+요일이 맞고 오늘 아직 `habit_notification_log`에 없는 것만 발송한다 — 습관 CRUD와 스케줄러 job을 동기화할 필요가 없어서 이 방식을 택함. `push_service.send_to_user(db, user_id, ...)`(멀티유저 전환 전에는 `send_to_all`이었음)는 만료된 구독(404/410)만 자동 삭제하고 다른 오류는 무시하고 다음 구독자로 넘어간다(한 구독자 오류가 전체 발송을 막지 않도록). 프론트엔드 구독 흐름은 `app/static/js/push-register.js`(`window.habitPush`), `/today` 카드에 "알림 켜기" 버튼으로 노출. **주의**: Chrome 네이티브 알림 권한 프롬프트는 브라우저 크롬 UI 영역이라 자동화 도구로 클릭할 수 없다 — 구독 저장/발송/스케줄러 로직은 curl과 직접 함수 호출로 검증했지만, 실제 브라우저 권한 승인 → 진짜 푸시 수신까지의 마지막 단계는 사람이 직접 확인해야 한다.
|
5. ✅ Web Push 알림 (APScheduler 매분 tick + VAPID) — VAPID 키 원시 바이트는 `py_vapid`의 `b64urlencode`로 직접 인코딩해서 `.env`에 저장한다(`Vapid02`에 `public_key_str` 같은 헬퍼가 없음, `scripts/generate_vapid_keys.py` 참고). `scheduler_service._tick()`은 습관별 job을 등록/해제하는 대신 **매분 폴링** 방식으로 전체 active 습관을 훑어 지금 시각+요일이 맞고 오늘 아직 `habit_notification_log`에 없는 것만 발송한다 — 습관 CRUD와 스케줄러 job을 동기화할 필요가 없어서 이 방식을 택함. `push_service.send_to_user(db, user_id, ...)`(멀티유저 전환 전에는 `send_to_all`이었음)는 만료된 구독(404/410)만 자동 삭제하고 다른 오류는 무시하고 다음 구독자로 넘어간다(한 구독자 오류가 전체 발송을 막지 않도록). 프론트엔드 구독 흐름은 `app/static/js/push-register.js`(`window.habitPush`), `/today` 카드에 "알림 켜기" 버튼으로 노출. **주의**: Chrome 네이티브 알림 권한 프롬프트는 브라우저 크롬 UI 영역이라 자동화 도구로 클릭할 수 없다 — 구독 저장/발송/스케줄러 로직은 curl과 직접 함수 호출로 검증했지만, 실제 브라우저 권한 승인 → 진짜 푸시 수신까지의 마지막 단계는 사람이 직접 확인해야 한다.
|
||||||
- **경합 방지**: `_tick()`은 발송 전에 `habit_notification_log`에 먼저 커밋해 "선점"하고(`_claim_notification_slot`), `(habit_id, notify_date)` 유니크 제약을 경합 방지 락처럼 쓴다 — 선점에 실패(`IntegrityError`)하면 이미 다른 워커/틱이 처리한 것으로 보고 조용히 건너뛴다. 이 앱은 `uvicorn --reload`로 개발 중 실행하는 경우가 많은데, **포트가 겹친 채로 오래된 워커 프로세스가 안 죽고 새 프로세스와 동시에 떠 있으면 각자 스케줄러를 따로 띄워서 같은 알림을 동시에 두 번 기록하려다 죽는 사고**가 실제로 있었다(웹 요청도 임의로 둘 중 한 프로세스로 라우팅되어 "가끔 옛날 코드로 응답"하는 것처럼 보이는 증상과 세트로 나타남). 이상 동작이 보이면 먼저 `Get-NetTCPConnection -LocalPort 8000 -ErrorAction SilentlyContinue`(PowerShell, `netstat`보다 정확함)로 실제 리스너가 1개인지 확인하고, 여러 개면 관련 `python.exe`/`uvicorn.exe`를 모두 정리한 뒤 하나만 새로 띄울 것.
|
- **경합 방지**: `_tick()`은 발송 전에 `habit_notification_log`에 먼저 커밋해 "선점"하고(`_claim_notification_slot`), `(habit_id, notify_date)` 유니크 제약을 경합 방지 락처럼 쓴다 — 선점에 실패(`IntegrityError`)하면 이미 다른 워커/틱이 처리한 것으로 보고 조용히 건너뛴다. 이 앱은 `uvicorn --reload`로 개발 중 실행하는 경우가 많은데, **포트가 겹친 채로 오래된 워커 프로세스가 안 죽고 새 프로세스와 동시에 떠 있으면 각자 스케줄러를 따로 띄워서 같은 알림을 동시에 두 번 기록하려다 죽는 사고**가 실제로 있었다(웹 요청도 임의로 둘 중 한 프로세스로 라우팅되어 "가끔 옛날 코드로 응답"하는 것처럼 보이는 증상과 세트로 나타남). 이상 동작이 보이면 먼저 `Get-NetTCPConnection -LocalPort 8000 -ErrorAction SilentlyContinue`(PowerShell, `netstat`보다 정확함)로 실제 리스너가 1개인지 확인하고, 여러 개면 관련 `python.exe`/`uvicorn.exe`를 모두 정리한 뒤 하나만 새로 띄울 것.
|
||||||
@@ -87,6 +87,15 @@ pytest tests/test_habits.py::test_name # 단일 테스트
|
|||||||
- **OAuth 핸드셰이크와 로그인 세션은 별개의 쿠키**: `authlib`가 리다이렉트 도중 state/nonce를 저장하려면 `request.session`이 있어야 해서 `app/main.py`에 `SessionMiddleware`(쿠키명 `oauth_session`, `max_age=600`)를 추가했다. 로그인 유지용 쿠키(`habit_session`, `itsdangerous` 서명, 30일)와는 완전히 다른 메커니즘이니 헷갈리지 말 것 — `create_session_token(user_id)`가 담는 페이로드도 `{"authenticated": True}`에서 `{"user_id": ...}`로 바뀌었다.
|
- **OAuth 핸드셰이크와 로그인 세션은 별개의 쿠키**: `authlib`가 리다이렉트 도중 state/nonce를 저장하려면 `request.session`이 있어야 해서 `app/main.py`에 `SessionMiddleware`(쿠키명 `oauth_session`, `max_age=600`)를 추가했다. 로그인 유지용 쿠키(`habit_session`, `itsdangerous` 서명, 30일)와는 완전히 다른 메커니즘이니 헷갈리지 말 것 — `create_session_token(user_id)`가 담는 페이로드도 `{"authenticated": True}`에서 `{"user_id": ...}`로 바뀌었다.
|
||||||
- **리디렉션 URI는 동적 추론이 아니라 `.env`에 명시**: 이 앱은 Tailscale로 리버스 프록시 없이 HTTPS를 받는 배포가 흔한데(위 6단계 HTTPS 문제 참고), `request.url_for()`로 콜백 URL을 추론하면 프록시 뒤에서 scheme이 `http`로 잘못 잡힐 위험이 있다. 그래서 `GOOGLE_REDIRECT_URI`를 `.env`에 명시적으로 두고 Google Cloud Console의 "승인된 리디렉션 URI"와 정확히 일치시키는 방식을 택했다 — 값이 하나라도 다르면 구글이 `redirect_uri_mismatch`로 콜백을 거부한다.
|
- **리디렉션 URI는 동적 추론이 아니라 `.env`에 명시**: 이 앱은 Tailscale로 리버스 프록시 없이 HTTPS를 받는 배포가 흔한데(위 6단계 HTTPS 문제 참고), `request.url_for()`로 콜백 URL을 추론하면 프록시 뒤에서 scheme이 `http`로 잘못 잡힐 위험이 있다. 그래서 `GOOGLE_REDIRECT_URI`를 `.env`에 명시적으로 두고 Google Cloud Console의 "승인된 리디렉션 URI"와 정확히 일치시키는 방식을 택했다 — 값이 하나라도 다르면 구글이 `redirect_uri_mismatch`로 콜백을 거부한다.
|
||||||
- **테스트 관련 주의**: 실제 구글 계정으로 로그인/동의 화면을 클릭하는 마지막 단계는 브라우저 자동화로 재현할 수 없다(진짜 구글 계정 자격증명이 필요한 영역) — `GET /auth/google/login`이 `accounts.google.com`으로 302 리다이렉트하는지, 로그인 후 발급된 세션 쿠키로 API가 정상 동작하는지는 curl로 검증할 수 있지만, 구글 동의 화면 자체는 사람이 직접 로그인해서 `/today`까지 도달하는지 확인해야 한다.
|
- **테스트 관련 주의**: 실제 구글 계정으로 로그인/동의 화면을 클릭하는 마지막 단계는 브라우저 자동화로 재현할 수 없다(진짜 구글 계정 자격증명이 필요한 영역) — `GET /auth/google/login`이 `accounts.google.com`으로 302 리다이렉트하는지, 로그인 후 발급된 세션 쿠키로 API가 정상 동작하는지는 curl로 검증할 수 있지만, 구글 동의 화면 자체는 사람이 직접 로그인해서 `/today`까지 도달하는지 확인해야 한다.
|
||||||
|
8. ✅ 저널링 — 습관 체크에 곁들이는 회고와, 습관과 무관한 자유 일기를 함께 지원한다. 카테고리(사용자가 자유롭게 만드는 "일상"/"투자" 같은 저널 묶음, `JournalCategory`), 태그, 사진/영상 첨부, 월별 캘린더, 고정 질문 템플릿, 기분 트래킹, "1년 전 오늘" 회상을 이번 출시에 포함했고 AI 기반 프롬프트/피드백만 후속 업데이트로 미뤘다. 별도 프로젝트로 분리하지 않고 기존 구글 OAuth/유저/DB/PWA 인프라를 재사용했다.
|
||||||
|
- **라우터 파일이 하나 더 늘었다**: 위 "요청 흐름" 절에서 설명한 API(`habits.py`/`logs.py`/`push.py`) vs 페이지(`pages.py`) 2계열 구조를 그대로 따르되, `pages.py`를 더 비대하게 만들지 않으려고 저널 전용 파일을 새로 뺐다 — `app/routers/journal.py`(`/api/journal/*`, 현재는 첨부파일 스트리밍 `GET /api/journal/media/{attachment_id}` 하나뿐)와 `app/routers/journal_pages.py`(`/journal` 캘린더·day-detail·엔트리/카테고리 CRUD, `pages.py`의 `_current_user_or_redirect`와 `templates`를 그대로 import해서 재사용). 앞으로 다른 기능도 규모가 커지면 `pages.py`에 계속 얹기보다 이 패턴(기능별 페이지 라우터 파일 분리)을 따를 것.
|
||||||
|
- **데이터 모델**: `app/models/journal.py`에 `JournalCategory`(user별 유니크 이름), `JournalEntry`(entry_date와 created_at 분리 — entry_date는 사용자가 지정 가능해 어제 일을 오늘 쓸 수 있고, 하루+카테고리당 여러 엔트리를 허용하므로 유니크 제약이 없다), `JournalTag`/`JournalEntryTag`(M:N), `JournalEntryMood`, `JournalAttachment`, `JournalPrompt`(카테고리 무관 전역 질문 뱅크, 마이그레이션에서 시드 데이터 삽입)가 있다. 카테고리 FK는 non-null이고, 대신 `journal_service.ensure_default_category`가 유저의 첫 저널 진입 시 카테고리가 하나도 없으면 "일상"을 자동 생성해 "카테고리 없음" 케이스를 아예 없앤다.
|
||||||
|
- **기분(mood)은 엔트리당 하나가 아니라 여러 개를 태그처럼 붙일 수 있다**: 처음엔 `JournalEntry.mood`가 단일 nullable enum 컬럼이었는데(0010 마이그레이션), 감정을 동시에 여러 개 고를 수 있어야 한다는 요구로 0011 마이그레이션에서 그 컬럼을 지우고 `JournalEntryMood(entry_id, mood)` 연결 테이블로 옮겼다 — `JournalTag`와 달리 mood는 고정된 enum 값 집합이라 별도 이름 엔티티 없이 값 자체를 복합 PK로 쓴다(`journal_tag`처럼 이름 조회/생성 로직이 필요 없음). `JournalMood`는 만족도 스케일 5종(최고/좋음/보통/별로/힘듦)에 구체적 감정 9종(아픔/성취/분노/신남/평온/행복/걱정/피곤/슬픔)을 더해 총 14종이다. 폼에서는 Alpine 배열(`moods: []`)로 다중 토글 pill을 만들고 쉼표로 join한 hidden input 하나로 제출한다(태그 입력과 동일한 패턴, `journal_pages._parse_moods`가 서버에서 다시 분해). pill 목록/이모지는 `journal_pages.py`의 `JOURNAL_MOOD_OPTIONS`에 한 곳에 정의해 `templates.env.globals`로 등록, 작성/수정 폼과 day-detail 표시 양쪽에서 재사용한다.
|
||||||
|
- **첨부파일은 `/static`이 아니라 인증된 라우트로 서빙한다**: 이 앱의 유일한 `StaticFiles` 마운트(`/static`)는 완전 공개라 사진/영상처럼 유저별로 비공개여야 하는 파일을 두면 안 된다. `journal_service.save_attachment`는 `settings.journal_media_root`(기본 `app/media/journal/{user_id}/{entry_id}/`, `.gitignore`에 등록됨) 아래 로컬 디스크에 저장하고, `GET /api/journal/media/{attachment_id}`가 `JournalAttachment→JournalEntry.user_id` 소유권을 확인한 뒤에만 `FileResponse`로 스트리밍한다. 이미지 첨부는 Pillow로 가로 400px 썸네일(`?thumbnail=true` 쿼리로 구분)을 만들어 목록/캘린더에서 원본 대신 가볍게 로드한다 — 영상은 썸네일을 만들지 않고 재생 링크만 보여준다(ffmpeg 등 별도 도구가 필요해 v1 범위 밖으로 미룸).
|
||||||
|
- **미디어 라우트를 `/api/` 밑에 둔 이유**: `service-worker.js`의 fetch 핸들러는 `/api/`로 시작하는 GET 요청을 무조건 네트워크로 그냥 통과시키고 캐싱하지 않는다(아래 4단계 캐싱 전략 참고). 저널 미디어를 `/api/journal/media/...`에 둠으로써 이 기존 규칙에 공짜로 올라타 서비스워커를 전혀 건드리지 않고도 "비공개 사진/영상이 클라이언트 캐시에 무기한 남는" 문제를 피했다 — 만약 `/journal/media/...`처럼 `/api/` 밖에 뒀다면 정적 자산과 똑같이 stale-while-revalidate로 캐싱돼버렸을 것.
|
||||||
|
- **캘린더/day-detail은 `/history`의 기존 패턴을 그대로 재사용했다**: `calendar.Calendar(firstweekday=6).monthdatescalendar()`로 월 그리드를 만들고, 날짜를 클릭하면 htmx로 `#day-detail`에 partial을 swap하는 구조가 동일하다. 다만 의미가 달라 `heatmap_opacity`(습관 완료율 기반 투명도)는 재사용하지 않고, 그날 등장한 카테고리 색상을 점(`.journal-day-dot`)으로 표시하는 방식을 새로 만들었다.
|
||||||
|
- **엔트리 수정/삭제/첨부삭제는 `HX-Redirect`가 아니라 `#day-detail` partial을 다시 렌더링해서 돌려준다**: `/today`의 체크 토글과 같은 이유 — 이미 htmx로 `#day-detail`에 로드된 상태에서 벌어지는 액션이라 전체 페이지 리다이렉트 대신 그 자리에서 갱신하는 게 자연스럽다. 검증 실패 시에도 같은 partial을 `edit_error`/`editing_entry_id` 컨텍스트와 함께 다시 렌더링해 해당 엔트리의 수정 폼이 열린 채로 에러 메시지를 보여준다(`habit_item.html`의 Alpine `editing` 토글과 같은 아이디어를 서버 렌더링 쪽에서 구현한 것).
|
||||||
|
- **테스트는 SQLite(단위) + 실제 MariaDB(수동 curl/httpx 스모크)로 이중 검증했다**: "1년 전 오늘" 회상과 랜덤 프롬프트 뽑기를 처음엔 각각 `MONTH()`/`DAY()`, `RANDOM()` 같은 DB 함수로 짜려고 했는데, 이 프로젝트의 pytest는 SQLite 인메모리 DB를 쓰고 운영은 MariaDB라 방언이 다르면(`RANDOM()` vs `RAND()` 등) 테스트만 통과하고 운영에서 깨질 위험이 있었다. 그래서 두 기능 다 파이썬 레벨 필터링/`random.choice()`로 바꿔 방언 종속성을 아예 없앴다 — 개인 규모 데이터라 전체 스캔 비용도 무시할 만하다. 한글 저장/조회가 실제 MariaDB(`utf8mb4`)에서도 깨지지 않는지는 `httpx`로 직접 폼을 제출해 왕복 검증했다(터미널에 출력할 때는 콘솔 코드페이지 때문에 깨져 보일 수 있어도, 문자열 비교 자체는 정상이었다 — 실제 버그가 아니라 표시상의 문제였음을 확인).
|
||||||
|
|
||||||
## Docker 배포
|
## Docker 배포
|
||||||
|
|
||||||
@@ -97,4 +106,4 @@ pytest tests/test_habits.py::test_name # 단일 테스트
|
|||||||
- `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml`의 `env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함.
|
- `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml`의 `env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함.
|
||||||
- **컨테이너는 반드시 1개만 실행**해야 한다 — `scheduler_service`가 프로세스 안에서 APScheduler를 직접 돌리므로, replica를 늘리면 각자 스케줄러를 따로 띄워 같은 알림을 중복 처리하려 든다(`_claim_notification_slot`의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다).
|
- **컨테이너는 반드시 1개만 실행**해야 한다 — `scheduler_service`가 프로세스 안에서 APScheduler를 직접 돌리므로, replica를 늘리면 각자 스케줄러를 따로 띄워 같은 알림을 중복 처리하려 든다(`_claim_notification_slot`의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다).
|
||||||
- **타임존**: `date.today()`(`/today`, 완료율/스트릭 계산 등 날짜 관련 로직 전반)는 컨테이너의 시스템 로컬 타임존을 그대로 쓴다. `python:3.13-slim` 베이스 이미지는 기본 타임존이 UTC라서, `Dockerfile`에 `TZ=Asia/Seoul` + `tzdata` 설치 + `/etc/localtime` 심볼릭 링크를 명시하지 않으면 자정~오전 9시(KST) 사이에 서버가 "아직 어제"로 날짜를 계산한다 — 실제로 이 때문에 매일 아침 `/today`가 전날 체크 상태 그대로 보이고 날짜가 안 넘어가는 버그가 있었다. 코드 로직(`date.today()`) 자체는 문제가 아니라 컨테이너 타임존 설정 누락이 원인이었으니, 비슷한 날짜 관련 이상 증상이 배포 환경에서만 재현되면 먼저 컨테이너 타임존을 의심할 것.
|
- **타임존**: `date.today()`(`/today`, 완료율/스트릭 계산 등 날짜 관련 로직 전반)는 컨테이너의 시스템 로컬 타임존을 그대로 쓴다. `python:3.13-slim` 베이스 이미지는 기본 타임존이 UTC라서, `Dockerfile`에 `TZ=Asia/Seoul` + `tzdata` 설치 + `/etc/localtime` 심볼릭 링크를 명시하지 않으면 자정~오전 9시(KST) 사이에 서버가 "아직 어제"로 날짜를 계산한다 — 실제로 이 때문에 매일 아침 `/today`가 전날 체크 상태 그대로 보이고 날짜가 안 넘어가는 버그가 있었다. 코드 로직(`date.today()`) 자체는 문제가 아니라 컨테이너 타임존 설정 누락이 원인이었으니, 비슷한 날짜 관련 이상 증상이 배포 환경에서만 재현되면 먼저 컨테이너 타임존을 의심할 것.
|
||||||
- **HTTPS는 배포 대상에 따라 둘 중 하나**: (1) 집 PC를 직접 서버로 쓰는 경우 → Tailscale(`tailscale serve --bg 8000`), 컨테이너 8000번이 호스트 8000번에 그대로 매핑되므로(`ports: ["8000:8000"]`) 프로세스로 직접 띄우든 컨테이너로 띄우든 Tailscale 입장에서 차이 없음. (2) **이미 리버스 프록시(nginx 등)가 앞단에 있는 서버에 배포하는 경우 → Tailscale 불필요**, 프록시가 도메인의 TLS를 처리하고 컨테이너의 8000번으로 평문 HTTP 프록시하면 된다. 이 앱 자체는 어느 쪽이든 코드 변경 없이 평문 HTTP로만 응답하면 되므로(`app/main.py`에 HTTPS 강제/리다이렉트 로직 없음), 배포 방식은 순전히 인프라 레이어에서 결정된다. 프록시가 컨테이너와 같은 호스트에서 돈다면 `docker-compose.yml`의 포트 매핑을 `"127.0.0.1:8000:8000"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
|
- **HTTPS는 배포 대상에 따라 둘 중 하나**: (1) 집 PC를 직접 서버로 쓰는 경우 → Tailscale(`tailscale serve --bg 8000`), 컨테이너 8000번이 호스트 8000번에 그대로 매핑되므로(`ports: ["8000:8000"]`) 프로세스로 직접 띄우든 컨테이너로 띄우든 Tailscale 입장에서 차이 없음. (2) **이미 리버스 프록시(nginx 등)가 앞단에 있는 서버에 배포하는 경우 → Tailscale 불필요**, 프록시가 도메인의 TLS를 처리하고 컨테이너의 8000번으로 평문 HTTP 프록시하면 된다. 이 앱은 리버스 프록시가 보내주는 `X-Forwarded-Proto` 헤더를 보고 `http`면 301로 `https`로 리다이렉트한다(`app/main.py`의 `redirect_http_to_https` 미들웨어) — 프록시가 이 헤더를 안 보내주면(로컬 `uvicorn` 직접 실행 등) 그냥 통과하므로 로컬 개발엔 영향 없다. 이 미들웨어가 실제로 동작하려면 **프록시가 HTTP(80)와 HTTPS(443) 요청을 모두 앱까지 전달하면서 각각 `X-Forwarded-Proto: http`/`https`를 명시적으로 설정**해야 한다 — 시놀로지 NAS 역방향 프록시처럼 리다이렉트 기능 자체가 없는 프록시 뒤에 배포할 때 특히 이 헤더 설정을 빠뜨리기 쉽다(80번 포트에 대한 프록시 규칙 자체가 없으면 트래픽이 앱에 도달하지도 못하고 NAS 자체 관리 페이지 등 엉뚱한 곳으로 샐 수 있음 — 실제로 이 문제가 있었음). 프록시가 컨테이너와 같은 호스트에서 돈다면 `docker-compose.yml`의 포트 매핑을 `"127.0.0.1:8000:8000"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 습관 트래커
|
# 해빗랩
|
||||||
|
|
||||||
개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다.
|
개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -18,5 +18,8 @@ class Settings(BaseSettings):
|
|||||||
session_cookie_name: str = "habit_session"
|
session_cookie_name: str = "habit_session"
|
||||||
timezone: str = "Asia/Seoul"
|
timezone: str = "Asia/Seoul"
|
||||||
|
|
||||||
|
journal_media_root: str = "app/media/journal"
|
||||||
|
journal_max_upload_mb: int = 20
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
+23
-3
@@ -1,15 +1,16 @@
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.routers import auth, habits, logs, pages, push
|
from app.routers import auth, habits, journal, journal_pages, logs, pages, push
|
||||||
from app.routers.pages import templates
|
from app.routers.pages import templates
|
||||||
from app.security import get_current_user_optional
|
from app.security import get_current_user_optional
|
||||||
from app.services import scheduler_service
|
from app.services import scheduler_service
|
||||||
@@ -19,23 +20,36 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
Path(settings.journal_media_root).mkdir(parents=True, exist_ok=True)
|
||||||
scheduler_service.start_scheduler()
|
scheduler_service.start_scheduler()
|
||||||
yield
|
yield
|
||||||
scheduler_service.shutdown_scheduler()
|
scheduler_service.shutdown_scheduler()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="습관 트래커", lifespan=lifespan)
|
app = FastAPI(title="해빗랩", lifespan=lifespan)
|
||||||
|
|
||||||
# 구글 OAuth 핸드셰이크 중 state/nonce를 임시로 저장하는 데만 쓰는 세션이다.
|
# 구글 OAuth 핸드셰이크 중 state/nonce를 임시로 저장하는 데만 쓰는 세션이다.
|
||||||
# 로그인 유지용 쿠키(habit_session)와는 별개.
|
# 로그인 유지용 쿠키(habit_session)와는 별개.
|
||||||
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key, session_cookie="oauth_session", max_age=600)
|
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key, session_cookie="oauth_session", max_age=600)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def redirect_http_to_https(request: Request, call_next):
|
||||||
|
# 리버스 프록시가 X-Forwarded-Proto로 원래 스킴을 알려줄 때만 동작한다
|
||||||
|
# (로컬 uvicorn 직접 실행 시에는 이 헤더가 없어 그냥 통과).
|
||||||
|
if request.headers.get("x-forwarded-proto") == "http":
|
||||||
|
return RedirectResponse(str(request.url.replace(scheme="https")), status_code=301)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(habits.router)
|
app.include_router(habits.router)
|
||||||
|
app.include_router(journal.router)
|
||||||
app.include_router(logs.router)
|
app.include_router(logs.router)
|
||||||
app.include_router(push.router)
|
app.include_router(push.router)
|
||||||
|
app.include_router(journal_pages.router)
|
||||||
app.include_router(pages.router)
|
app.include_router(pages.router)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,3 +89,9 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
|
|||||||
def service_worker():
|
def service_worker():
|
||||||
# 서비스워커 scope가 앱 전체를 커버하려면 /static/ 하위가 아닌 루트 경로로 서빙해야 한다.
|
# 서비스워커 scope가 앱 전체를 커버하려면 /static/ 하위가 아닌 루트 경로로 서빙해야 한다.
|
||||||
return FileResponse("app/static/service-worker.js", media_type="application/javascript")
|
return FileResponse("app/static/service-worker.js", media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/.well-known/assetlinks.json")
|
||||||
|
def assetlinks():
|
||||||
|
# Android TWA(kr.co.alphalok.habit.twa)의 도메인 소유권 증명용 — 반드시 /.well-known/ 루트 경로여야 한다.
|
||||||
|
return FileResponse("app/static/.well-known/assetlinks.json", media_type="application/json")
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
from app.models.habit import Habit, HabitStatus, HabitType
|
from app.models.habit import Habit, HabitStatus, HabitType
|
||||||
from app.models.habit_log import HabitLog
|
from app.models.habit_log import HabitLog
|
||||||
|
from app.models.journal import (
|
||||||
|
JournalAttachment,
|
||||||
|
JournalAttachmentType,
|
||||||
|
JournalCategory,
|
||||||
|
JournalEntry,
|
||||||
|
JournalEntryMood,
|
||||||
|
JournalMood,
|
||||||
|
JournalPrompt,
|
||||||
|
JournalTag,
|
||||||
|
)
|
||||||
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
|
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
|
||||||
from app.models.push_subscription import PushSubscription
|
from app.models.push_subscription import PushSubscription
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -9,6 +19,14 @@ __all__ = [
|
|||||||
"HabitStatus",
|
"HabitStatus",
|
||||||
"HabitType",
|
"HabitType",
|
||||||
"HabitLog",
|
"HabitLog",
|
||||||
|
"JournalAttachment",
|
||||||
|
"JournalAttachmentType",
|
||||||
|
"JournalCategory",
|
||||||
|
"JournalEntry",
|
||||||
|
"JournalEntryMood",
|
||||||
|
"JournalMood",
|
||||||
|
"JournalPrompt",
|
||||||
|
"JournalTag",
|
||||||
"HabitNotificationLog",
|
"HabitNotificationLog",
|
||||||
"SummaryNotificationLog",
|
"SummaryNotificationLog",
|
||||||
"PushSubscription",
|
"PushSubscription",
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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)
|
||||||
|
# 이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워주는 틀(예: Story/Feelings/Decisions/... 회고 양식).
|
||||||
|
# 사용자가 직접 타이핑을 시작하면 더 이상 덮어쓰지 않는다(프론트에서 처리).
|
||||||
|
content_template: Mapped[str | None] = mapped_column(Text, 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,355 @@
|
|||||||
|
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}
|
||||||
|
# Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면
|
||||||
|
# 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다.
|
||||||
|
templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
"category_templates": {str(c.id): c.content_template or "" for c in categories},
|
||||||
|
"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),
|
||||||
|
content_template: 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, content_template=content_template)
|
||||||
|
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),
|
||||||
|
content_template: 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, content_template=content_template)
|
||||||
|
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
|
||||||
+48
-4
@@ -8,12 +8,13 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.habit import HabitDifficulty, HabitStatus, HabitType
|
from app.models.habit import HabitDifficulty, HabitStatus, HabitType
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.habit import HabitCreate, HabitUpdate
|
from app.schemas.habit import HabitCreate, HabitUpdate
|
||||||
from app.security import get_current_user_optional
|
from app.security import get_current_user_optional
|
||||||
from app.services import habit_service, log_service
|
from app.services import habit_service, log_service, user_service
|
||||||
from app.template_utils import (
|
from app.template_utils import (
|
||||||
difficulty_label,
|
difficulty_label,
|
||||||
goal_progress,
|
goal_progress,
|
||||||
@@ -46,14 +47,57 @@ def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectR
|
|||||||
def index(request: Request, db: Session = Depends(get_db)):
|
def index(request: Request, db: Session = Depends(get_db)):
|
||||||
if get_current_user_optional(request, db) is not None:
|
if get_current_user_optional(request, db) is not None:
|
||||||
return RedirectResponse(url="/today", status_code=303)
|
return RedirectResponse(url="/today", status_code=303)
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
# 로그인 없이도 앱 목적을 설명하는 페이지가 있어야 한다(구글 OAuth 동의 화면 "홈페이지" 요건).
|
||||||
|
return templates.TemplateResponse(request, "home.html", {"logged_in": False, "current_user": None})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
def login_page(request: Request, db: Session = Depends(get_db)):
|
def login_page(request: Request, deleted: bool = False, db: Session = Depends(get_db)):
|
||||||
if get_current_user_optional(request, db) is not None:
|
if get_current_user_optional(request, db) is not None:
|
||||||
return RedirectResponse(url="/today", status_code=303)
|
return RedirectResponse(url="/today", status_code=303)
|
||||||
return templates.TemplateResponse(request, "login.html", {"logged_in": False, "current_user": None})
|
return templates.TemplateResponse(
|
||||||
|
request, "login.html", {"logged_in": False, "current_user": None, "deleted": deleted}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/privacy")
|
||||||
|
def privacy_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
user = get_current_user_optional(request, db)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "privacy.html", {"logged_in": user is not None, "current_user": user}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/account")
|
||||||
|
def account_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
current = _current_user_or_redirect(request, db)
|
||||||
|
if isinstance(current, RedirectResponse):
|
||||||
|
return current
|
||||||
|
return templates.TemplateResponse(request, "account.html", {"logged_in": True, "current_user": current})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/account/delete")
|
||||||
|
def delete_account_page(request: Request, confirm_email: str = Form(""), db: Session = Depends(get_db)):
|
||||||
|
current = _current_user_or_redirect(request, db)
|
||||||
|
if isinstance(current, RedirectResponse):
|
||||||
|
return current
|
||||||
|
|
||||||
|
if confirm_email.strip().lower() != current.email.lower():
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"account.html",
|
||||||
|
{
|
||||||
|
"logged_in": True,
|
||||||
|
"current_user": current,
|
||||||
|
"delete_error": "입력한 이메일이 계정 이메일과 일치하지 않습니다.",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_service.delete_account(db, current)
|
||||||
|
response = RedirectResponse(url="/login?deleted=1", status_code=303)
|
||||||
|
response.delete_cookie(settings.session_cookie_name)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _today_context(db: Session, user_id: int, **extra) -> dict:
|
def _today_context(db: Session, user_id: int, **extra) -> dict:
|
||||||
|
|||||||
+1
-1
@@ -42,5 +42,5 @@ def unsubscribe(
|
|||||||
|
|
||||||
@router.post("/test")
|
@router.post("/test")
|
||||||
def send_test(db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
def send_test(db: Session = Depends(get_db), current_user: User = Depends(require_login)):
|
||||||
sent = push_service.send_to_user(db, current_user.id, title="습관 트래커", body="테스트 알림입니다.")
|
sent = push_service.send_to_user(db, current_user.id, title="해빗랩", body="테스트 알림입니다.")
|
||||||
return {"sent": sent}
|
return {"sent": sent}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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
|
||||||
|
content_template: 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
|
||||||
|
|
||||||
|
@field_validator("content_template")
|
||||||
|
@classmethod
|
||||||
|
def blank_template_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
|
||||||
|
content_template: 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,398 @@
|
|||||||
|
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 = "일상"
|
||||||
|
DEFAULT_CATEGORY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?**
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
**2. Feelings : 오늘 들었던 생각과 나의 감정은?**
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
**3. Decisions : 오늘 내가 내린 결정이 있나요?**
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.
|
||||||
|
|
||||||
|
**5. Actions : 내가 다음에 할 행동은 무엇인가요?**
|
||||||
|
|
||||||
|
- """
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 카테고리 ----
|
||||||
|
|
||||||
|
|
||||||
|
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, content_template=data.content_template
|
||||||
|
)
|
||||||
|
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
|
||||||
|
category.content_template = data.content_template
|
||||||
|
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, content_template=DEFAULT_CATEGORY_TEMPLATE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def delete_account(db: Session, user: User) -> None:
|
||||||
|
"""계정과 그에 딸린 모든 데이터를 삭제한다.
|
||||||
|
|
||||||
|
Habit/PushSubscription/HabitLog/HabitNotificationLog/SummaryNotificationLog는 전부
|
||||||
|
user.id 기준 DB 레벨 ON DELETE CASCADE로 연결돼 있어(마이그레이션 0004/0005 참고),
|
||||||
|
User 행만 지우면 나머지는 MariaDB가 알아서 정리한다.
|
||||||
|
"""
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[{
|
||||||
|
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "kr.co.alphalok.habit.twa",
|
||||||
|
"sha256_cert_fingerprints": ["2A:BC:19:7D:91:65:3A:88:68:02:8C:3B:1D:58:42:C7:17:02:FA:B9:C9:4F:01:02:3D:60:B7:C9:CE:6F:05:20"]
|
||||||
|
}
|
||||||
|
}]
|
||||||
+327
-1
@@ -111,6 +111,16 @@ p {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4) 0 var(--space-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer a {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* iOS 홈 화면 추가 안내 배너 */
|
/* iOS 홈 화면 추가 안내 배너 */
|
||||||
.ios-install-banner {
|
.ios-install-banner {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -315,7 +325,10 @@ p {
|
|||||||
/* 입력 */
|
/* 입력 */
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="time"],
|
input[type="time"],
|
||||||
input[type="password"] {
|
input[type="date"],
|
||||||
|
input[type="password"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -326,6 +339,33 @@ input[type="password"] {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 90px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="color"] {
|
||||||
|
width: 56px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--color-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -456,6 +496,32 @@ label {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 저널 기분 선택 pill */
|
||||||
|
.mood-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-pill.selected {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.difficulty-hint {
|
.difficulty-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -799,3 +865,263 @@ label {
|
|||||||
.wm-dash {
|
.wm-dash {
|
||||||
color: var(--color-border);
|
color: var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 저널링 */
|
||||||
|
.journal-day-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-day-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-prompt-card,
|
||||||
|
.on-this-day-card {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--color-success-tint);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entry-item {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entry-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entry-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entry-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-entry-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-video-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 랜딩 페이지 (/) */
|
||||||
|
.landing {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-4) var(--space-2) var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-brand img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-wrap: balance;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-tagline {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 요일 스트릭 시연: 실제 습관 체크 인터랙션을 그대로 축소해 보여준다 */
|
||||||
|
.landing-streak {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-day {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-day.done {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0;
|
||||||
|
animation: landing-day-in 0.35s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-day.done svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-day:nth-of-type(1).done { animation-delay: 0.1s; }
|
||||||
|
.landing-day:nth-of-type(2).done { animation-delay: 0.25s; }
|
||||||
|
.landing-day:nth-of-type(3).done { animation-delay: 0.4s; }
|
||||||
|
.landing-day:nth-of-type(4).done { animation-delay: 0.55s; }
|
||||||
|
.landing-day:nth-of-type(5).done { animation-delay: 0.7s; }
|
||||||
|
|
||||||
|
@keyframes landing-day-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.6);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.landing-day.done {
|
||||||
|
opacity: 1;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-copy {
|
||||||
|
font-size: 15.5px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-copy strong {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-features {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-feature {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-feature svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-feature p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-feature p strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14.5px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "습관 트래커",
|
"name": "해빗랩",
|
||||||
"short_name": "습관 트래커",
|
"short_name": "해빗랩",
|
||||||
"description": "형성하고 싶은 습관과 끊고 싶은 습관을 요일별로 관리하고 매일 체크하는 개인용 습관 관리 앱",
|
"description": "형성하고 싶은 습관과 끊고 싶은 습관을 요일별로 관리하고 매일 체크하는 개인용 습관 관리 앱",
|
||||||
"start_url": "/today",
|
"start_url": "/today",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>오프라인 - 습관 트래커</title>
|
<title>오프라인 - 해빗랩</title>
|
||||||
<link rel="icon" href="/static/icons/icon-192.png" />
|
<link rel="icon" href="/static/icons/icon-192.png" />
|
||||||
<link rel="stylesheet" href="/static/css/style.css" />
|
<link rel="stylesheet" href="/static/css/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
const CACHE_NAME = "habit-tracker-v4";
|
const CACHE_NAME = "habit-tracker-v5";
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
"/static/css/style.css",
|
"/static/css/style.css",
|
||||||
"/static/js/app.js",
|
"/static/js/app.js",
|
||||||
"/static/js/push-register.js",
|
"/static/js/push-register.js",
|
||||||
"/static/js/habit-reorder.js",
|
"/static/js/habit-reorder.js",
|
||||||
|
"/static/js/journal-category-reorder.js",
|
||||||
"/static/js/vendor/htmx.min.js",
|
"/static/js/vendor/htmx.min.js",
|
||||||
"/static/js/vendor/alpine.min.js",
|
"/static/js/vendor/alpine.min.js",
|
||||||
"/static/js/vendor/sortable.min.js",
|
"/static/js/vendor/sortable.min.js",
|
||||||
@@ -78,7 +79,7 @@ self.addEventListener("push", (event) => {
|
|||||||
if (!event.data) return;
|
if (!event.data) return;
|
||||||
const data = event.data.json();
|
const data = event.data.json();
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(data.title || "습관 트래커", {
|
self.registration.showNotification(data.title || "해빗랩", {
|
||||||
body: data.body || "",
|
body: data.body || "",
|
||||||
icon: "/static/icons/icon-192.png",
|
icon: "/static/icons/icon-192.png",
|
||||||
badge: "/static/icons/icon-192.png",
|
badge: "/static/icons/icon-192.png",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}페이지를 찾을 수 없어요 · 습관 트래커{% endblock %}
|
{% block title %}페이지를 찾을 수 없어요 · 해빗랩{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
|
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}오류가 발생했어요 · 습관 트래커{% endblock %}
|
{% block title %}오류가 발생했어요 · 해빗랩{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
|
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}계정 · 해빗랩{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>계정</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p><strong>이메일</strong><br />{{ current_user.email }}</p>
|
||||||
|
{% if current_user.name %}<p><strong>이름</strong><br />{{ current_user.name }}</p>{% endif %}
|
||||||
|
<p><strong>가입일</strong><br />{{ current_user.created_at.strftime('%Y-%m-%d') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>계정 삭제</h2>
|
||||||
|
<p>
|
||||||
|
계정을 삭제하면 등록한 모든 습관, 체크 기록, 알림 구독 정보가 즉시 영구적으로 삭제되며 되돌릴 수 없습니다.
|
||||||
|
</p>
|
||||||
|
{% if delete_error %}<p style="color: var(--color-danger);">{{ delete_error }}</p>{% endif %}
|
||||||
|
<form method="post" action="/account/delete">
|
||||||
|
<label for="confirm_email">확인을 위해 본인 이메일({{ current_user.email }})을 입력하세요</label>
|
||||||
|
<input type="text" id="confirm_email" name="confirm_email" autocomplete="off" />
|
||||||
|
<button type="submit" class="btn btn-danger-ghost btn-block" style="margin-top: var(--space-2);">계정 영구 삭제</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+13
-4
@@ -3,14 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>{% block title %}습관 트래커{% endblock %}</title>
|
<title>{% block title %}해빗랩{% endblock %}</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/static/manifest.json" />
|
<link rel="manifest" href="/static/manifest.json" />
|
||||||
<meta name="theme-color" content="#d97757" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#d97757" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="theme-color" content="#e08962" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#e08962" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="습관 트래커" />
|
<meta name="apple-mobile-web-app-title" content="해빗랩" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-apple-180.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-apple-180.png" />
|
||||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" />
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" />
|
||||||
<link rel="icon" href="/static/icons/icon-192.png" />
|
<link rel="icon" href="/static/icons/icon-192.png" />
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<script src="/static/js/push-register.js" defer></script>
|
<script src="/static/js/push-register.js" defer></script>
|
||||||
<script src="/static/js/vendor/sortable.min.js" defer></script>
|
<script src="/static/js/vendor/sortable.min.js" defer></script>
|
||||||
<script src="/static/js/habit-reorder.js" defer></script>
|
<script src="/static/js/habit-reorder.js" defer></script>
|
||||||
|
<script src="/static/js/journal-category-reorder.js" defer></script>
|
||||||
<script src="/static/js/vendor/alpine.min.js" defer></script>
|
<script src="/static/js/vendor/alpine.min.js" defer></script>
|
||||||
<script src="/static/js/app.js" defer></script>
|
<script src="/static/js/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -31,16 +32,17 @@
|
|||||||
<button type="button" onclick="dismissIosInstallBanner()" aria-label="닫기">×</button>
|
<button type="button" onclick="dismissIosInstallBanner()" aria-label="닫기">×</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="top-nav">
|
<nav class="top-nav">
|
||||||
<span class="brand">습관 트래커</span>
|
<span class="brand">해빗랩</span>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a>
|
<a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a>
|
||||||
<a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a>
|
<a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a>
|
||||||
<a href="/history" class="{% block nav_history %}{% endblock %}">기록</a>
|
<a href="/history" class="{% block nav_history %}{% endblock %}">기록</a>
|
||||||
|
<a href="/journal" class="{% block nav_journal %}{% endblock %}">일기</a>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
<div class="nav-user">
|
<div class="nav-user">
|
||||||
{% if account_level %}<span class="nav-level-pill">Lv.{{ account_level.level }}</span>{% endif %}
|
{% if account_level %}<span class="nav-level-pill">Lv.{{ account_level.level }}</span>{% endif %}
|
||||||
<span>{{ current_user.name or current_user.email }}</span>
|
<a href="/account">{{ current_user.name or current_user.email }}</a>
|
||||||
<form method="post" action="/auth/logout">
|
<form method="post" action="/auth/logout">
|
||||||
<button type="submit" class="btn-link">로그아웃</button>
|
<button type="submit" class="btn-link">로그아웃</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -60,9 +62,16 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2" /><line x1="3" y1="10" x2="21" y2="10" /><line x1="8" y1="3" x2="8" y2="7" /><line x1="16" y1="3" x2="16" y2="7" /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2" /><line x1="3" y1="10" x2="21" y2="10" /><line x1="8" y1="3" x2="8" y2="7" /><line x1="16" y1="3" x2="16" y2="7" /></svg>
|
||||||
<span>기록</span>
|
<span>기록</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/journal" class="tab-item {{ self.nav_journal() }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /></svg>
|
||||||
|
<span>일기</span>
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
<footer class="app-footer">
|
||||||
|
<a href="/privacy">개인정보처리방침</a>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}습관 관리 · 습관 트래커{% endblock %}
|
{% block title %}습관 관리 · 해빗랩{% endblock %}
|
||||||
{% block nav_habits %}active{% endblock %}
|
{% block nav_habits %}active{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>습관 관리</h1>
|
<h1>습관 관리</h1>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}기록 · 습관 트래커{% endblock %}
|
{% block title %}기록 · 해빗랩{% endblock %}
|
||||||
{% block nav_history %}active{% endblock %}
|
{% block nav_history %}active{% endblock %}
|
||||||
{% block shell_class %} wide{% endblock %}
|
{% block shell_class %} wide{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}해빗랩 · 습관이 만들어지는 진짜 시간{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="landing">
|
||||||
|
<div class="landing-hero">
|
||||||
|
<div class="landing-brand"><img src="/static/icons/icon-192.png" alt="" /> 해빗랩</div>
|
||||||
|
<h1>습관이 만들어지는<br />진짜 시간</h1>
|
||||||
|
<p class="landing-tagline">21일의 법칙 대신, 습관마다 다른 진짜 목표 기간을 알려드려요</p>
|
||||||
|
|
||||||
|
<div class="landing-streak" aria-hidden="true">
|
||||||
|
<div class="landing-day done">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||||
|
</div>
|
||||||
|
<div class="landing-day done">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||||
|
</div>
|
||||||
|
<div class="landing-day done">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||||
|
</div>
|
||||||
|
<div class="landing-day done">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||||
|
</div>
|
||||||
|
<div class="landing-day done">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
|
||||||
|
</div>
|
||||||
|
<div class="landing-day">토</div>
|
||||||
|
<div class="landing-day">일</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="landing-copy">
|
||||||
|
<p>
|
||||||
|
"습관은 21일이면 만들어진다"는 말, 많이 들어보셨을 거예요. 사실 근거가 약한 통설이에요.
|
||||||
|
실증 연구(<strong>Lally et al., 2010, UCL</strong>)에 따르면 습관이 몸에 붙기까지 걸리는 시간은
|
||||||
|
사람마다, 습관마다 달라서 짧게는 3주, 길게는 8개월 넘게 걸리기도 해요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>해빗랩</strong>은 여러분이 만들고 싶은 습관도, 멈추고 싶은 습관도 함께 만들어가는 동반자예요.
|
||||||
|
검증된 연구를 시스템에 그대로 담아 목표 기간과 진행 상황을 짚어드리고, 그 여정을 끝까지 응원할게요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="landing-features">
|
||||||
|
<div class="landing-feature">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="5" /><circle cx="12" cy="12" r="1" /></svg>
|
||||||
|
<p><strong>연구 기반 목표 기간</strong>속설이 아니라 실제 연구값(21·66·254일)으로 목표를 잡아요</p>
|
||||||
|
</div>
|
||||||
|
<div class="landing-feature">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="6" /><line x1="8" y1="9" x2="8" y2="15" /><line x1="5" y1="12" x2="11" y2="12" /><circle cx="17" cy="12" r="6" /><line x1="14" y1="12" x2="20" y2="12" /></svg>
|
||||||
|
<p><strong>만들기와 끊기, 동시에</strong>새 습관을 만드는 것과 나쁜 습관을 끊는 것을 똑같은 무게로 다뤄요</p>
|
||||||
|
</div>
|
||||||
|
<div class="landing-feature">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||||
|
<p><strong>광고·구독 없음</strong>습관관리에만 집중해요, 그 외엔 아무것도 없어요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 시작하기</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
{% 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 class="field">
|
||||||
|
<label for="edit-category-template-{{ category.id }}">기본 작성 틀 (선택)</label>
|
||||||
|
<textarea id="edit-category-template-{{ category.id }}" name="content_template" rows="4" placeholder="이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워줄 틀을 입력하세요">{{ category.content_template or '' }}</textarea>
|
||||||
|
</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 class="field">
|
||||||
|
<label for="new-category-template">기본 작성 틀 (선택)</label>
|
||||||
|
<textarea id="new-category-template" name="content_template" rows="4" placeholder="이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워줄 틀을 입력하세요"></textarea>
|
||||||
|
</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) },
|
||||||
|
categoryTemplates: {{ category_templates|tojson|forceescape }},
|
||||||
|
content: '',
|
||||||
|
lastAutoFilled: '',
|
||||||
|
onCategoryChange(categoryId) {
|
||||||
|
const tmpl = this.categoryTemplates[categoryId] || '';
|
||||||
|
if (this.content === this.lastAutoFilled) {
|
||||||
|
this.content = tmpl;
|
||||||
|
this.lastAutoFilled = tmpl;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<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" x-ref="categorySelect" @change="onCategoryChange($event.target.value)">
|
||||||
|
{% 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="8" required placeholder="오늘 하루는 어땠나요?" x-model="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 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 %}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}로그인 · 습관 트래커{% endblock %}
|
{% block title %}로그인 · 해빗랩{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="min-height: 70vh; display: flex; align-items: center; justify-content: center;">
|
<div style="min-height: 70vh; display: flex; align-items: center; justify-content: center;">
|
||||||
<div class="card" style="width: 100%; max-width: 320px;">
|
<div class="card" style="width: 100%; max-width: 320px;">
|
||||||
<h1 style="text-align:center;">습관 트래커</h1>
|
<h1 style="text-align:center;">해빗랩</h1>
|
||||||
|
{% if deleted %}<p style="text-align:center; color: var(--color-success);">계정이 삭제되었습니다.</p>{% endif %}
|
||||||
<p style="text-align:center;">구글 계정으로 로그인하세요</p>
|
<p style="text-align:center;">구글 계정으로 로그인하세요</p>
|
||||||
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 로그인</a>
|
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 로그인</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -14,13 +14,13 @@
|
|||||||
{% if item.reminder_time %}<div class="habit-item-meta">알람 {{ item.reminder_time }}</div>{% endif %}
|
{% if item.reminder_time %}<div class="habit-item-meta">알람 {{ item.reminder_time }}</div>{% endif %}
|
||||||
{% if item.scheduled_days > 0 %}
|
{% if item.scheduled_days > 0 %}
|
||||||
<div class="habit-item-stats">
|
<div class="habit-item-stats">
|
||||||
|
<span class="badge level-badge">{{ level_tier_emoji(item.level_info.level) }} Lv.{{ item.level_info.level }}</span>
|
||||||
<span class="badge">완료율 {{ item.completion_rate }}%</span>
|
<span class="badge">완료율 {{ item.completion_rate }}%</span>
|
||||||
{% if item.current_streak > 0 %}
|
{% if item.current_streak > 0 %}
|
||||||
<span class="badge{{ ' badge-milestone' if is_milestone_streak(item.current_streak) }}">
|
<span class="badge{{ ' badge-milestone' if is_milestone_streak(item.current_streak) }}">
|
||||||
{{ '🏆' if is_milestone_streak(item.current_streak) else '🔥' }} 연속 {{ item.current_streak }}일
|
{{ '🏆' if is_milestone_streak(item.current_streak) else '🔥' }} 연속 {{ item.current_streak }}일
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="badge level-badge">{{ level_tier_emoji(item.level_info.level) }} Lv.{{ item.level_info.level }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="xp-bar-wrap">
|
<div class="xp-bar-wrap">
|
||||||
<div class="xp-bar"><div class="xp-bar-fill" style="width: {{ item.level_info.progress_pct }}%;"></div></div>
|
<div class="xp-bar"><div class="xp-bar-fill" style="width: {{ item.level_info.progress_pct }}%;"></div></div>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}개인정보처리방침 · 해빗랩{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 640px; margin: 0 auto; padding: 24px 16px 48px;">
|
||||||
|
<div class="card">
|
||||||
|
<h1>개인정보처리방침</h1>
|
||||||
|
<p>시행일자: 2026년 7월 22일</p>
|
||||||
|
|
||||||
|
<p>해빗랩(이하 "서비스")는 이용자의 개인정보를 소중히 다루며, 아래와 같이 개인정보를 수집·이용·보관합니다.</p>
|
||||||
|
|
||||||
|
<h2>1. 수집하는 개인정보 항목</h2>
|
||||||
|
<p><strong>가. 구글 로그인 시 (필수)</strong><br />
|
||||||
|
이메일 주소, 이름, 프로필 사진 URL, 구글 계정 고유 식별자(sub). 서비스는 구글 계정 정보를 이용해 자동으로 계정을 생성하며, 비밀번호는 별도로 수집·저장하지 않습니다.</p>
|
||||||
|
<p><strong>나. 서비스 이용 중 직접 입력하는 정보</strong><br />
|
||||||
|
등록한 습관의 이름, 유형(만들기/끊기), 요일 스케줄, 목표 난이도, 달성 조건, 알림 시각, 날짜별 체크·실패 기록.</p>
|
||||||
|
<p><strong>다. 알림(Web Push)을 켠 경우에만</strong><br />
|
||||||
|
브라우저가 발급하는 푸시 구독 정보(endpoint, 암호화 키)와 브라우저 종류(User-Agent). 알림 중복 발송을 막기 위한 발송 이력도 함께 기록됩니다.</p>
|
||||||
|
<p><strong>라. 자동 수집 정보</strong><br />
|
||||||
|
로그인 유지를 위한 서명된 세션 쿠키. 별도의 광고·분석·트래킹 쿠키는 사용하지 않습니다.</p>
|
||||||
|
|
||||||
|
<h2>2. 개인정보의 수집 및 이용 목적</h2>
|
||||||
|
<p>구글 계정 인증 및 로그인 유지, 습관 등록·체크·통계(완료율, 연속 달성일 등) 제공, 설정한 시각에 맞춘 습관 알림(Web Push) 발송을 위해서만 이용합니다. 광고, 마케팅, 프로필링 목적으로는 이용하지 않습니다.</p>
|
||||||
|
|
||||||
|
<h2>3. 개인정보의 보유 및 이용 기간</h2>
|
||||||
|
<p>회원 탈퇴 시까지 보관하며, 탈퇴 시 계정 정보와 습관·체크 기록, 푸시 구독 정보를 지체 없이 삭제합니다. 관계 법령에 따라 보존이 필요한 경우가 아니면 별도 보관하지 않습니다.</p>
|
||||||
|
|
||||||
|
<h2>4. 개인정보의 제3자 제공</h2>
|
||||||
|
<p>서비스는 이용자의 개인정보를 원칙적으로 외부에 제공하지 않습니다. 다만 구글 로그인 인증 과정에서 구글(Google LLC)과 통신이 발생하며, 이는 이용자 인증을 위한 목적에 한정됩니다.</p>
|
||||||
|
|
||||||
|
<h2>5. 이용자의 권리와 행사 방법</h2>
|
||||||
|
<p>이용자는 언제든지 자신의 개인정보 열람·정정·삭제를 요청할 수 있습니다. 로그인 후 <a href="/account">계정</a> 페이지에서 본인 이메일 확인 후 즉시 계정과 모든 데이터를 직접 삭제할 수 있으며, 앱을 이용하기 어려운 경우 아래 문의처로 요청하셔도 확인 후 지체 없이 처리해드립니다.</p>
|
||||||
|
|
||||||
|
<h2>6. 보안을 위한 조치</h2>
|
||||||
|
<p>비밀번호를 직접 저장하지 않는 구글 OAuth 인증 방식을 사용하며, 로그인 세션은 서명된 쿠키로 관리합니다. 모든 통신은 HTTPS로 암호화되며, 각 이용자의 데이터는 계정 단위로 분리되어 다른 이용자가 접근할 수 없습니다.</p>
|
||||||
|
|
||||||
|
<h2>7. 문의처</h2>
|
||||||
|
<p>개인정보 관련 문의, 열람·정정·삭제 요청은 아래로 연락해주세요.</p>
|
||||||
|
<p>해빗랩 운영자<br />
|
||||||
|
이메일: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a></p>
|
||||||
|
|
||||||
|
<h2>8. 고지의 의무</h2>
|
||||||
|
<p>이 개인정보처리방침은 법령이나 서비스 변경사항을 반영하기 위해 수정될 수 있으며, 변경 시 이 페이지를 통해 고지합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}오늘 · 습관 트래커{% endblock %}
|
{% block title %}오늘 · 해빗랩{% endblock %}
|
||||||
{% block nav_today %}active{% endblock %}
|
{% block nav_today %}active{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="today-content">
|
<div id="today-content">
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 이 파일을 복사해 deploy.env로 저장한 뒤 값을 채워넣으세요. deploy.env는 git에 커밋되지 않습니다.
|
||||||
|
# scripts/deploy_sftp.py가 이 값들로 SFTP 접속해 시놀로지 NAS에 소스를 동기화합니다.
|
||||||
|
|
||||||
|
SFTP_HOST=your-domain-or-ip
|
||||||
|
SFTP_PORT=22
|
||||||
|
SFTP_USERNAME=
|
||||||
|
SFTP_PASSWORD=
|
||||||
|
# 시놀로지 SFTP는 보통 /volume1을 세션 루트("/")로 보여주므로 /volume1 접두어 없이 적는다
|
||||||
|
SFTP_REMOTE_PATH=/docker/habit-tracker
|
||||||
@@ -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) + ")",
|
||||||
|
)
|
||||||
|
# 삭제된 행 자체는 복구하지 않는다(내용을 알 수 없음) — 제약만 원상복구.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""add content_template to journal_category, seed the default 일상 category's template
|
||||||
|
|
||||||
|
Revision ID: 0013_journal_category_template
|
||||||
|
Revises: 0012_remove_satisfaction_moods
|
||||||
|
Create Date: 2026-08-04
|
||||||
|
|
||||||
|
카테고리별로 "새 기록 쓰기" 폼의 내용칸을 미리 채워주는 틀(예: Story/Feelings/Decisions/
|
||||||
|
Insights/Actions 회고 양식)을 지정할 수 있게 한다. 이미 존재하는 "일상" 카테고리에는 이 틀을
|
||||||
|
바로 채워 넣는다(새로 자동 생성되는 "일상"은 ensure_default_category가 채운다).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0013_journal_category_template"
|
||||||
|
down_revision: Union[str, None] = "0012_remove_satisfaction_moods"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
DAILY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?**
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
**2. Feelings : 오늘 들었던 생각과 나의 감정은?**
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
**3. Decisions : 오늘 내가 내린 결정이 있나요?**
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.
|
||||||
|
|
||||||
|
**5. Actions : 내가 다음에 할 행동은 무엇인가요?**
|
||||||
|
|
||||||
|
- """
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("journal_category", sa.Column("content_template", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
journal_category = sa.table(
|
||||||
|
"journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
journal_category.update()
|
||||||
|
.where(journal_category.c.name == "일상")
|
||||||
|
.values(content_template=DAILY_TEMPLATE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("journal_category", "content_template")
|
||||||
+2
-1
@@ -19,12 +19,13 @@ dependencies = [
|
|||||||
"py-vapid>=1.9",
|
"py-vapid>=1.9",
|
||||||
"authlib>=1.3",
|
"authlib>=1.3",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임)
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성용 (런타임 미사용)
|
"paramiko>=3.4", # scripts/deploy_sftp.py SFTP 배포용 (런타임 미사용)
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""SFTP로 시놀로지 NAS에 소스를 동기화하는 배포 스크립트.
|
||||||
|
|
||||||
|
deploy.env(git 미포함, deploy.env.example 참고)의 접속 정보를 읽어, Dockerfile이 COPY하는
|
||||||
|
파일/디렉터리(pyproject.toml, alembic.ini, app/, migrations/, scripts/)와 Dockerfile,
|
||||||
|
docker-compose.yml을 원격 경로로 동기화한다. 로컬 mtime이 원격보다 최신인 파일만 올리고,
|
||||||
|
원격에만 있는 파일은 건드리지 않는다(단방향 추가/갱신, 삭제 없음). .env는 절대 동기화하지 않는다
|
||||||
|
(원격 프로덕션 .env를 덮어쓰면 안 되므로).
|
||||||
|
|
||||||
|
컨테이너 재시작/재빌드는 이 스크립트가 하지 않는다 — 파일을 올린 뒤 직접 재시작할 것.
|
||||||
|
|
||||||
|
사용법: python scripts/deploy_sftp.py [--dry-run]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEPLOY_ENV_PATH = PROJECT_ROOT / "deploy.env"
|
||||||
|
|
||||||
|
# Dockerfile이 COPY하는 것과 동일한 목록 + 컨테이너 정의 파일
|
||||||
|
SYNC_TARGETS = ["pyproject.toml", "alembic.ini", "Dockerfile", "docker-compose.yml", "app", "migrations", "scripts"]
|
||||||
|
SKIP_NAMES = {"__pycache__"}
|
||||||
|
SKIP_SUFFIXES = {".pyc"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_deploy_env(path: Path) -> dict[str, str]:
|
||||||
|
if not path.exists():
|
||||||
|
print(f"{path}가 없습니다. deploy.env.example을 복사해 값을 채워주세요.")
|
||||||
|
sys.exit(1)
|
||||||
|
values = {}
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
values[key.strip()] = value.strip()
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def iter_local_files(target: Path):
|
||||||
|
if target.is_file():
|
||||||
|
yield target
|
||||||
|
return
|
||||||
|
for path in target.rglob("*"):
|
||||||
|
if path.is_dir():
|
||||||
|
continue
|
||||||
|
if any(part in SKIP_NAMES for part in path.parts):
|
||||||
|
continue
|
||||||
|
if path.suffix in SKIP_SUFFIXES:
|
||||||
|
continue
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_remote_dir(sftp: paramiko.SFTPClient, remote_dir: str) -> None:
|
||||||
|
parts = remote_dir.strip("/").split("/")
|
||||||
|
current = ""
|
||||||
|
for part in parts:
|
||||||
|
current += "/" + part
|
||||||
|
try:
|
||||||
|
sftp.stat(current)
|
||||||
|
except FileNotFoundError:
|
||||||
|
sftp.mkdir(current)
|
||||||
|
|
||||||
|
|
||||||
|
def remote_mtime(sftp: paramiko.SFTPClient, remote_path: str) -> float | None:
|
||||||
|
try:
|
||||||
|
return sftp.stat(remote_path).st_mtime
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
dry_run = "--dry-run" in sys.argv
|
||||||
|
env = load_deploy_env(DEPLOY_ENV_PATH)
|
||||||
|
|
||||||
|
host = env["SFTP_HOST"]
|
||||||
|
port = int(env.get("SFTP_PORT", "22"))
|
||||||
|
username = env["SFTP_USERNAME"]
|
||||||
|
password = env["SFTP_PASSWORD"]
|
||||||
|
remote_root = env["SFTP_REMOTE_PATH"].rstrip("/")
|
||||||
|
|
||||||
|
print(f"{host}:{port} ({username}) -> {remote_root} 로 동기화{'(dry-run)' if dry_run else ''}")
|
||||||
|
|
||||||
|
transport = paramiko.Transport((host, port))
|
||||||
|
transport.connect(username=username, password=password)
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
assert sftp is not None
|
||||||
|
|
||||||
|
uploaded, skipped = 0, 0
|
||||||
|
try:
|
||||||
|
for target_name in SYNC_TARGETS:
|
||||||
|
local_target = PROJECT_ROOT / target_name
|
||||||
|
if not local_target.exists():
|
||||||
|
continue
|
||||||
|
for local_file in iter_local_files(local_target):
|
||||||
|
rel_path = local_file.relative_to(PROJECT_ROOT).as_posix()
|
||||||
|
remote_path = f"{remote_root}/{rel_path}"
|
||||||
|
local_mtime = local_file.stat().st_mtime
|
||||||
|
existing_mtime = remote_mtime(sftp, remote_path)
|
||||||
|
|
||||||
|
if existing_mtime is not None and existing_mtime >= local_mtime:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" 업로드: {rel_path}")
|
||||||
|
if not dry_run:
|
||||||
|
ensure_remote_dir(sftp, str(Path(remote_path).parent.as_posix()))
|
||||||
|
sftp.put(str(local_file), remote_path)
|
||||||
|
uploaded += 1
|
||||||
|
finally:
|
||||||
|
sftp.close()
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
print(f"완료: {uploaded}개 업로드, {skipped}개 변경 없음. 컨테이너 재시작/재빌드는 직접 해주세요.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 습관 트래커 서버를 상시 실행하기 위한 스크립트.
|
# 해빗랩 서버를 상시 실행하기 위한 스크립트.
|
||||||
# Windows 작업 스케줄러 등록 시 이 스크립트를 대상으로 지정한다 (--reload 없이, conda activate 없이
|
# Windows 작업 스케줄러 등록 시 이 스크립트를 대상으로 지정한다 (--reload 없이, conda activate 없이
|
||||||
# 대상 conda 환경의 python.exe를 직접 호출해 셸 활성화 없는 예약 작업에서도 안정적으로 동작하게 한다).
|
# 대상 conda 환경의 python.exe를 직접 호출해 셸 활성화 없는 예약 작업에서도 안정적으로 동작하게 한다).
|
||||||
|
|
||||||
|
|||||||
@@ -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,298 @@
|
|||||||
|
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
|
||||||
|
assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE.strip()
|
||||||
|
|
||||||
|
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_category_content_template_persists_and_updates(db_session, test_user):
|
||||||
|
category = journal_service.create_category(
|
||||||
|
db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2")
|
||||||
|
)
|
||||||
|
assert category.content_template == "질문 1\n\n질문 2"
|
||||||
|
|
||||||
|
updated = journal_service.update_category(
|
||||||
|
db_session, category, JournalCategoryCreate(name="회고", content_template=" ")
|
||||||
|
)
|
||||||
|
assert updated.content_template is None
|
||||||
|
|
||||||
|
|
||||||
|
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,282 @@
|
|||||||
|
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="일상", content_template=None):
|
||||||
|
return journal_service.create_category(
|
||||||
|
db_session, user_id, JournalCategoryCreate(name=name, content_template=content_template)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_journal_page_embeds_category_content_template(auth_client):
|
||||||
|
response = auth_client.get("/journal")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Story" in response.text
|
||||||
|
assert "메타인지" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_journal_page_template_with_double_quotes_does_not_break_x_data_attribute(
|
||||||
|
auth_client, db_session, test_user
|
||||||
|
):
|
||||||
|
baseline_form_count = auth_client.get("/journal").text.count("<form")
|
||||||
|
|
||||||
|
_make_category(db_session, test_user.id, name="따옴표카테고리", content_template='내용에 "큰따옴표"가 있어요')
|
||||||
|
|
||||||
|
response = auth_client.get("/journal")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# x-data="..." 속성 안에 이스케이프 안 된 "가 섞이면 속성이 거기서 끊겨 뒤 마크업이 전부
|
||||||
|
# 속성값으로 흡수되는데, 그러면 폼의 <form 태그들이 열린 속성값 텍스트에 파묻혀 개수가
|
||||||
|
# 줄어든다 — 카테고리 하나 늘었다고 <form 개수가 그대로인지로 attribute-injection 여부를 검증한다.
|
||||||
|
assert response.text.count("<form") == baseline_form_count
|
||||||
|
assert "\\"큰따옴표\\"" 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