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:
+77
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.routers import auth, habits, logs, pages, push
|
||||
from app.routers.pages import templates
|
||||
from app.security import get_current_user_optional
|
||||
from app.services import scheduler_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
scheduler_service.start_scheduler()
|
||||
yield
|
||||
scheduler_service.shutdown_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(title="습관 트래커", lifespan=lifespan)
|
||||
|
||||
# 구글 OAuth 핸드셰이크 중 state/nonce를 임시로 저장하는 데만 쓰는 세션이다.
|
||||
# 로그인 유지용 쿠키(habit_session)와는 별개.
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key, session_cookie="oauth_session", max_age=600)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(habits.router)
|
||||
app.include_router(logs.router)
|
||||
app.include_router(push.router)
|
||||
app.include_router(pages.router)
|
||||
|
||||
|
||||
def _is_api_path(path: str) -> bool:
|
||||
return path.startswith("/api/") or path.startswith("/static/")
|
||||
|
||||
|
||||
def _current_user_context(request: Request) -> dict:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
current_user = get_current_user_optional(request, db)
|
||||
finally:
|
||||
db.close()
|
||||
return {"logged_in": current_user is not None, "current_user": current_user}
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||
if exc.status_code == 404 and not _is_api_path(request.url.path):
|
||||
return templates.TemplateResponse(
|
||||
request, "404.html", _current_user_context(request), status_code=404
|
||||
)
|
||||
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
logger.exception("처리되지 않은 오류")
|
||||
if _is_api_path(request.url.path):
|
||||
return JSONResponse({"detail": "서버 오류가 발생했습니다"}, status_code=500)
|
||||
return templates.TemplateResponse(
|
||||
request, "500.html", _current_user_context(request), status_code=500
|
||||
)
|
||||
|
||||
|
||||
@app.get("/service-worker.js")
|
||||
def service_worker():
|
||||
# 서비스워커 scope가 앱 전체를 커버하려면 /static/ 하위가 아닌 루트 경로로 서빙해야 한다.
|
||||
return FileResponse("app/static/service-worker.js", media_type="application/javascript")
|
||||
Reference in New Issue
Block a user