Initial commit: habit tracker PWA with Google OAuth, push notifications
FastAPI + SQLAlchemy/Alembic + MariaDB backend with Jinja2/htmx/Alpine server-rendered frontend. Multi-user via Google OAuth, daily habit tracking, monthly/weekly history views, Web Push reminders via APScheduler, and PWA support (manifest, service worker, offline caching). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""PIN 로그인 시절부터 쌓인, 소유자 없는(user_id IS NULL) 습관을 지정한 이메일의 계정으로 연결합니다.
|
||||
|
||||
먼저 구글 로그인을 한 번 해서 계정이 생성된 뒤에 실행하세요.
|
||||
|
||||
사용법:
|
||||
python scripts/claim_orphan_habits.py <이메일>
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.habit import Habit
|
||||
from app.models.user import User
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit("사용법: python scripts/claim_orphan_habits.py <이메일>")
|
||||
|
||||
email = sys.argv[1]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
if user is None:
|
||||
raise SystemExit(f"'{email}' 계정을 찾을 수 없습니다. 먼저 구글 로그인을 한 번 해주세요.")
|
||||
|
||||
result = db.execute(
|
||||
update(Habit).where(Habit.user_id.is_(None)).values(user_id=user.id)
|
||||
)
|
||||
db.commit()
|
||||
print(f"{result.rowcount}개의 습관을 '{email}' 계정으로 연결했습니다.")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 컨테이너 시작 시마다 마이그레이션을 적용한다. 이미 적용된 리비전은 그냥 건너뛰므로
|
||||
# 여러 번 실행해도 안전하다(idempotent).
|
||||
alembic upgrade head
|
||||
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
@@ -0,0 +1,50 @@
|
||||
"""PWA 아이콘(icon-192, icon-512, icon-maskable-512)을 생성한다.
|
||||
Pillow가 필요하다 (런타임 의존성 아님, 아이콘을 새로 만들 때만 `pip install pillow` 후 1회 실행).
|
||||
|
||||
사용법: python scripts/generate_icons.py
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ACCENT = (217, 119, 87, 255) # --color-accent
|
||||
WHITE = (255, 255, 255, 255)
|
||||
|
||||
OUT_DIR = Path(__file__).resolve().parent.parent / "app" / "static" / "icons"
|
||||
|
||||
# 24x24 viewBox 기준 체크마크 좌표 (elbow 3점)
|
||||
CHECK_POINTS = [(4.8, 12.0), (9.0, 16.2), (19.0, 6.4)]
|
||||
|
||||
|
||||
def draw_icon(size: int) -> Image.Image:
|
||||
img = Image.new("RGBA", (size, size), ACCENT)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
check_span = size * 0.58 # 24유닛이 차지할 실제 픽셀 크기
|
||||
scale = check_span / 24
|
||||
offset = (size - check_span) / 2
|
||||
|
||||
points = [(offset + x * scale, offset + y * scale) for x, y in CHECK_POINTS]
|
||||
stroke_width = max(2, round(size * 0.066))
|
||||
draw.line(points, fill=WHITE, width=stroke_width, joint="curve")
|
||||
|
||||
# 선 끝을 둥글게 마무리
|
||||
r = stroke_width / 2
|
||||
for x, y in (points[0], points[-1]):
|
||||
draw.ellipse([x - r, y - r, x + r, y + r], fill=WHITE)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
draw_icon(192).save(OUT_DIR / "icon-192.png")
|
||||
draw_icon(512).save(OUT_DIR / "icon-512.png")
|
||||
# 배경이 전체를 채우고 체크마크가 중앙 58%에만 있어 마스커블 세이프존(중앙 80%)을 만족한다.
|
||||
draw_icon(512).save(OUT_DIR / "icon-maskable-512.png")
|
||||
print(f"generated icons in {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Web Push용 VAPID 키쌍을 생성합니다. 출력값을 .env의 VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY에 붙여넣으세요.
|
||||
|
||||
사용법: python scripts/generate_vapid_keys.py
|
||||
"""
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from py_vapid import Vapid02, b64urlencode
|
||||
|
||||
if __name__ == "__main__":
|
||||
vapid = Vapid02()
|
||||
vapid.generate_keys()
|
||||
|
||||
private_raw = vapid.private_key.private_numbers().private_value.to_bytes(32, "big")
|
||||
public_raw = vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
print("VAPID_PUBLIC_KEY=" + b64urlencode(public_raw))
|
||||
print("VAPID_PRIVATE_KEY=" + b64urlencode(private_raw))
|
||||
@@ -0,0 +1,23 @@
|
||||
# 습관 트래커 서버를 상시 실행하기 위한 스크립트.
|
||||
# Windows 작업 스케줄러 등록 시 이 스크립트를 대상으로 지정한다 (--reload 없이, conda activate 없이
|
||||
# 대상 conda 환경의 python.exe를 직접 호출해 셸 활성화 없는 예약 작업에서도 안정적으로 동작하게 한다).
|
||||
|
||||
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
$PythonExe = Join-Path $env:USERPROFILE "anaconda3\envs\py_web\python.exe"
|
||||
if (-not (Test-Path $PythonExe)) {
|
||||
Write-Error "conda 환경의 python.exe를 찾을 수 없습니다: $PythonExe (경로를 확인하세요)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$LogDir = Join-Path $ProjectRoot "logs"
|
||||
if (-not (Test-Path $LogDir)) {
|
||||
New-Item -ItemType Directory -Path $LogDir | Out-Null
|
||||
}
|
||||
$LogFile = Join-Path $LogDir ("server_{0}.log" -f (Get-Date -Format "yyyyMMdd"))
|
||||
|
||||
# 네이티브 프로세스의 stderr(uvicorn의 INFO 로그 포함)를 PowerShell 5.1에서 *>>로 직접 리다이렉션하면
|
||||
# 각 줄이 NativeCommandError로 감싸져 $ErrorActionPreference=Stop과 충돌해 즉시 종료되는 문제가 있어
|
||||
# ErrorActionPreference를 건드리지 않고 기본값(Continue)으로 둔 채 리다이렉션한다.
|
||||
& $PythonExe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 *>> $LogFile
|
||||
Reference in New Issue
Block a user