2026년 7월 21일 화요일

# fastapi + htmx + petite-vue + jinja2 - html 은 소스보기로 ...



# Fastapi

Fastapi + Htmx + Petite-Vue + Jinja2

```html

{% for row in rows %}


{% for p in range(1, total_pages + 1) %}
  {% set base_class = "px-3 py-1.5 text-xs font-medium rounded-md border transition-all duration-200" %}
  {% set active_class = "bg-blue-600 text-white border-blue-600 shadow-sm hover:bg-blue-700" if p == current_page else "bg-white text-gray-700 border-gray-200 hover:bg-gray-50" %}

  

{% endfor %}
```

```python
# 🌐 1. 메인 대시보드 뼈대 로드
@app.get("/", response_class=HTMLResponse)
@trace
async def index_page(request: Request, db: Session = Depends(get_session)):
  # 📌 최초 메인 진입 시 구글 시트 원본 배열 데이터를 긁어옵니다
  raw_data = get_google_sheet_data()
  rows = raw_data[1:] if raw_data and len(raw_data) > 1 else []

  # 📌 반드시 context 내부에 rows를 동봉하여 index.html로 던져야 include가 정상 동작합니다
  return templates.TemplateResponse(
    request=request,
    name="index.html",
    context={
      "rows": rows,
    },
  )
```

```html

{% if rows %} {% include "table_rows.html" %} {% else %} {% endif %} {% for row in rows %} {{ row[0] }} {{ row[1] }} {{ row[2] }} {% endfor %} ``` ```python # 📥 3. 수동 패킷 적재 API (POST 방식) @app.post("/api/manual-upload", response_class=HTMLResponse) async def manual_upload( request: Request, task_name: str = Form(...), # HTML 내부 input의 name="task_name" 매핑 task_status: str = Form(...), # HTML 내부 select의 name="task_status" 매핑 ): is_htmx = request.headers.get("HX-Request") or request.headers.get("hx-request") if not is_htmx: raise HTTPException(status_code=400, detail="잘못된 접근 방법입니다.") if not task_name.strip(): raise HTTPException(status_code=400, detail="작업 내용을 채워주세요.") # 새로운 데이터 행 동적 생성 current_date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") new_row = [current_date, task_name.strip(), task_status] # 임시 테스트용 데이터 리스트에 추가 (실제 데이터베이스/구글시트 반영 영역) raw_data = get_google_sheet_data() raw_data.append(new_row) rows = raw_data[1:] if raw_data and len(raw_data) > 1 else [] return templates.TemplateResponse( request=request, name="table_rows.html", context={ "rows": rows, }, ) ``` ```html ``` ```python #app/main.py @app.get("/items/{item_id}/edit") def edit_popup(item_id: int, request: Request): # DB에서 데이터를 가져옴 (예시) item_title = "기존에 작성된 게시글 제목" # 전체 페이지가 아니라 팝업창 HTML 파일만 따로 그려서 보냄 return templates.TemplateResponse( request=request, name="_edit_popup.html", context={ "title": item_title, "item_id": item_id, }, ) ``` ```html

안녕하세요!

Tailwind 스타일이 적용된 자바스크립트 없는 팝업창입니다.

``` ```html ...
{% block content %} {% endblock content %}
... {% block content %} {% endblock content %} {% extends base_template %} {% set body_class = "flex flex-col min-h-screen bg-gray-50 text-gray-800" %} {% block content %} {% raw %}
{{ state.time }}
{% endraw %}
00:00:00
{% endblock content %} ``` ```python # app/main.py from fastapi import Request from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.cors import CORSMiddleware from pathlib import Path # import app # ➔ 이 한 줄로 app/__init__.py 내용이 통째로 로드됩니다. # 서버 시작 시 데이터베이스 테이블 자동 생성 # Base.metadata.create_base(bind=engine) from app.config import settings from app.database import get_session # domain 폴더에서 하위 도메인 폴더들을 바로 가져옵니다. from app.domains import answer, question, sheet from app.macro import trace # 파일 이름을 무시하고 폴더에서 다이렉트로 깔끔하게 임포트 가능! from app.middlewares import AdvancedLoggingMiddleware, request_trace_id, request_user_id logger = logging.getLogger("uvicorn_file_logger") # [핵심] uvicorn 내부 로깅 설정을 커스텀하는 표준 함수 def setup_logging(): # print("--- [LOG] 파일 직접 실행 방식을 통해 서버를 구동합니다.") # 1. Uvicorn의 기본 로그 설정 템플릿을 가져옵니다. log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG) # 예시: 로깅 타임스탬프 및 포맷 커스텀 변경 # log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s" # 순정 uvicorn.access 로거의 핸들러와 포맷 설정을 싹 날려서 먹통으로 만듭니다. log_config["loggers"]["uvicorn.access"] = { "handlers": [], # 출구 제거 "level": "CRITICAL", # 에러조차 안 찍히도록 레벨 최고조 상향 "propagate": False, # 부모로 전파 금지 } # 변경된 딕셔너리 설정을 파이썬 로깅 시스템에 강제 주입 logging.config.dictConfig(log_config) # print("✅ uvicorn 커스텀 로깅 설정이 주입되었습니다.") @asynccontextmanager async def lifespan(app: FastAPI): # ➔ 원래 __main__에 들어갔어야 할 초기화 코드를 여기에 배치합니다. setup_logging() yield # 여기서부터 실제 서비스가 시작되어 사용자 요청을 받습니다. # (선택 사항) 서버가 종료될 때 처리할 일 수행 print("🛑 서버가 안전하게 종료됩니다.") # FastAPI 애플리케이션 인스턴스 생성 # FastAPI 객체를 선언할 때 lifespan을 바인딩합니다. app = FastAPI(title="My FastAPI App", debug=settings.DEBUG_MODE, lifespan=lifespan) # [최상단 배치] 모든 유비콘 간섭을 박살 내는 마스터 미들웨어를 가장 먼저 등록합니다. app.add_middleware(AdvancedLoggingMiddleware) # --- 미들웨어 설정 --- # CORS 설정: 실제 서비스에서는 와일드카드('*') 대신 특정 도메인을 지정하는 것이 보안상 좋습니다. origins = [ # "http://127.0.0.1:5173", # "https://your-frontend-domain.com", "*" # 개발 편의를 위해 모든 오리진 허용 ] app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 사용할 때는 '도메인명.router.router' 형태로 등록합니다. app.include_router(question.router.router) app.include_router(answer.router.router) app.include_router(sheet.router.router) # 1. 템플릿 환경을 만들 때 컨텍스트 프로세서를 등록합니다. def htmx_context_processor(request: Request): is_htmx = request.headers.get("HX-Request") == "true" return {"base_template": "htmx_base.html" if is_htmx else "base.html"} CURRENT_FILE_PATH = Path(__file__).resolve() PROJECT_ROOT = CURRENT_FILE_PATH.parent.parent sys.path.append(str(PROJECT_ROOT)) TEMPLATES_DIR = PROJECT_ROOT / "templates" # 4. Jinja2Templates에 절대 경로 문자열로 변환하여 주입합니다. templates = Jinja2Templates(directory=str(TEMPLATES_DIR), context_processors=[htmx_context_processor]) # 💡 2. static 정적 폴더 절대 경로 지정 STATIC_DIR = PROJECT_ROOT / "static" # 💡 3. 하드코딩 대신 '절대 경로 문자열'로 안전하게 마운트합니다. app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # 🌐 1. 메인 대시보드 뼈대 로드 @app.get("/", response_class=HTMLResponse) @trace async def index_page(request: Request, db: Session = Depends(get_session)): # 📌 최초 메인 진입 시 구글 시트 원본 배열 데이터를 긁어옵니다 raw_data = sheet.router.get_google_sheet_data() rows = raw_data[1:] if raw_data and len(raw_data) > 1 else [] # 에러가 발생했을 때 기록하는 예시 # uvicorn_logger.error("→ [에러] 데이터를 가져오지 못했습니다.") """1. 운영툴 최초 접속 시: 메인 전체 화면 렌더링""" # 초기값: 1페이지, 검색어 없음, 10개씩 보기 initial_users = fake_user_db[:10] search: str = "" page: int = 1 size: int = 10 # 검색 필터링 (비즈니스 로직) filtered_users = [u for u in fake_user_db if search.lower() in u["name"].lower()] # 페이징/인덱싱 계산 start_idx = (page - 1) * size end_idx = start_idx + size paginated_users = filtered_users[start_idx:end_idx] total_pages = (len(filtered_users) + size - 1) // size # 로그를 찍을 때 사용합니다. logger.info("답변 저장 로직 실행") # 📌 반드시 context 내부에 rows를 동봉하여 index.html로 던져야 include가 정상 동작합니다 return templates.TemplateResponse( request=request, name="index.html", context={ "rows": rows, "users": paginated_users, "current_page": 1, "total_pages": total_pages, "search": "", }, ) """ #main-content 구멍 안으로 갈아 끼워질 모든 자식 콘텐츠 템플릿 파일들(예: index.html, users.html, settings.html 등)의 맨 첫 줄에 각각 다 넣어주셔야 합니다. index.html {% extends base_template %} {% block content %}

여기는 메인 콘텐츠(Index) 영역입니다.

HTMX로 요청하면 이 알맹이만 가고, F5를 누르면 전체 레이아웃과 합쳐집니다.

{% endblock content %} """ @app.get("/table", response_class=HTMLResponse) def get_user_table_fragment( request: Request, search: str = Query("", description="검색어"), page: int = Query(1, description="현재 인덱싱 페이지 번호"), size: int = Query(10, description="한 페이지당 노출 개수"), ): """2. 🔥 HTMX 전용 엔드포인트: 전체 화면이 아니라 테이블 조각 HTML만 반환""" # 검색 필터링 (비즈니스 로직) filtered_users = [u for u in fake_user_db if search.lower() in u["name"].lower()] # 페이징/인덱싱 계산 start_idx = (page - 1) * size end_idx = start_idx + size paginated_users = filtered_users[start_idx:end_idx] total_pages = (len(filtered_users) + size - 1) // size # ✨ 핵심: 메인 레이아웃이 없는 오직 'table.html' 조각만 리턴합니다. return templates.TemplateResponse( "admin/table.html", { "request": request, "users": paginated_users, "current_page": page, "total_pages": total_pages, "search": search, }, ) # 📥 3. 수동 패킷 적재 API (POST 방식) @app.post("/api/manual-upload", response_class=HTMLResponse) async def manual_upload( request: Request, task_name: str = Form(...), # HTML 내부 input의 name="task_name" 매핑 task_status: str = Form(...), # HTML 내부 select의 name="task_status" 매핑 ): is_htmx = request.headers.get("HX-Request") or request.headers.get("hx-request") if not is_htmx: raise HTTPException(status_code=400, detail="잘못된 접근 방법입니다.") if not task_name.strip(): raise HTTPException(status_code=400, detail="작업 내용을 채워주세요.") # 새로운 데이터 행 동적 생성 current_date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") new_row = [current_date, task_name.strip(), task_status] # 임시 테스트용 데이터 리스트에 추가 (실제 데이터베이스/구글시트 반영 영역) raw_data = sheet.router.get_google_sheet_data() raw_data.append(new_row) rows = raw_data[1:] if raw_data and len(raw_data) > 1 else [] return templates.TemplateResponse(request=request, name="table_rows.html", context={"rows": rows}) # 1. 🏠 로그인 페이지 최초 진입 (화면 1번 그리기) @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request, error: str = None): return templates.TemplateResponse( request=request, name="login.html", context={ "request": request, "error": error, }, # 👈 여기에 글자를 넣으면 처음부터 에러가 뜸! ) # 2. 🔑 로그인 인증 처리 API (HTMX 1번 호출 수신) @app.post("/api/login") async def login_process(request: Request, username: str = Form(...), password: str = Form(...)): # 단순 가상 아이디/비밀번호 검증 (실제로는 DB 조회 필요) if username == "admin" and password == "password123": # 🎯 [HTMX 마법] 인증 성공 시, 전체 브라우저 주소를 메인('/')으로 리다이렉트 시킵니다. response = HTMLResponse(content="") response.headers["HX-Redirect"] = "/" return response # ❌ 로그인 실패 시: 폼 내부의 #error-message 구역에 끼워 넣을 에러 HTML 조각 리턴 error_html = """
🚨 아이디 또는 비밀번호가 일치하지 않습니다.
""" return HTMLResponse(content=error_html) """ # 실패했으므로 로그인 폼 구조를 유지한 채 에러만 상단에 추가하여 통째로 리턴합니다. # login.html의 폼과 구조를 일치시키기 위해 다시 렌더링하거나 조각을 보낼 수 있습니다. return templates.TemplateResponse( request=request, name="login.html", context={"request": request, "error": "아이디 또는 비밀번호가 일치하지 않습니다." } ) """ # 1. 처음 대시보드 진입점 @app.get("/dashboard", response_class=HTMLResponse) async def get_dashboard(request: Request): return templates.TemplateResponse( request=request, name="dashboard.html", ) # 2. [HTMX 요청] 실시간 통제소 메뉴 조각 리턴 @app.get("/api/menu/commander", response_class=HTMLResponse) async def menu_commander(request: Request): return templates.TemplateResponse(request=request, name="menu_commander.html") # 3. [HTMX 요청] 적재 이력 조회 메뉴 조각 리턴 @app.get("/api/menu/history", response_class=HTMLResponse) async def menu_history(request: Request): return templates.TemplateResponse(request=request, name="menu_history.html") # 4. [HTMX 요청] 로그아웃 처리 시 로그인 화면 통째로 리턴 """ @app.post("/api/logout", response_class=HTMLResponse) async def logout(request: Request): # 세션이나 토큰을 날리는 로직을 수행한 후, 로그인 페이지 원본을 반환합니다. return templates.TemplateResponse( request=request, name="login.html") """ @app.post("/api/logout") async def logout(response: Response): # 1. 브라우저에 저장되어 있던 로그인 쿠키(세션)를 안전하게 삭제 response.delete_cookie(key="access_token") # 2. HTMX에게 로그인 주소('/') 또는 ('/login')으로 완전히 새로고침 이동하라고 헤더 전달 response.headers["HX-Redirect"] = "/login" return {"status": "success"} ``` ## templates/ > partials 파일은 '_' 를 붙인다 > 백엔드(Flask, FastAPI) 중심 프로젝트: aaa_bbb.html 사용 (적극 권장) > jinja2 comment: {# {% include ".html" %} #} ## logs ## folder > 📂 FastAPI CRUD 표준 폴더 구조 > README.folder.md ## database > orm > gspread > redis > jwt ## ai

database.py

import os from contextlib import contextmanager # contextmanager 임포트 from typing import Generator from dotenv...