"""카페24 예약관리 — 지정 시각에 상세페이지·진열/판매를 자동 적용. 되돌리기(자동 복원)는 쓰지 않는다. 예약은 "그 시각에 이 내용을 적용" 하나뿐이다. 한 예약에서 세 가지를 각각 고를 수 있다. 상세페이지 HTML 편집기에 있는 내용을 그 시각에 적용 (안 고르면 HTML 은 그대로) 진열 진열 / 미진열 / 변경 없음 판매 판매 / 중지 / 변경 없음 예약을 만들 때 편집기의 HTML 을 **DRAFT revision 으로 먼저 저장**하고 예약이 그 버전을 가리킨다. 나중에 편집기에서 내용을 더 고쳐도 예약 내용은 등록 시점 그대로다 (예약해둔 것이 조용히 바뀌면 안 된다). 실제 적용은 웹 프로세스가 아니라 **worker** 가 한다(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`). 브라우저를 닫아도 실행되어야 하기 때문이다. """ from __future__ import annotations import logging from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products from . import store from .common import base_ctx, guard from .routes_products import _no_store logger = logging.getLogger("cafe24.schedules") schedules_router = APIRouter() @schedules_router.get("/schedules", response_class=HTMLResponse) def schedules_page(request: Request) -> HTMLResponse: """예약 목록 — 대기 중인 것이 위, 그다음 최근 처리 순.""" from app.main import render_template # noqa: WPS433 checked = guard(request) if not isinstance(checked, tuple): return checked st, user = checked rows = [] for row in st.list_schedules(limit=200): rows.append( { **row, "status_label": store.SCHEDULE_STATUS_LABELS.get(row.get("status") or "", ""), "action_label": store.describe_schedule_action( has_html=bool(row.get("has_html")), set_display=row.get("set_display"), set_selling=row.get("set_selling"), ), "editable": store.is_editable(row.get("status") or ""), } ) ctx = base_ctx(request, user, active_tab="schedules") ctx.update( { "page_title": "카페24 — 예약관리", "page_subtitle": "지정 시각에 상세페이지·진열/판매 자동 적용", "rows": rows, "pending": sum(1 for r in rows if r["status"] == store.STATUS_PENDING), "flash": request.query_params.get("msg", ""), "flash_error": request.query_params.get("err", ""), } ) return _no_store(render_template(request, "cafe24/schedules.html", ctx)) @schedules_router.post("/schedules") def schedule_create( request: Request, product_no: int = Form(...), scheduled_at: str = Form(""), html: str = Form(""), apply_html: str = Form(""), set_display: str = Form(""), set_selling: str = Form(""), memo: str = Form(""), list_query: str = Form(""), ): """편집기에서 예약을 등록한다. HTML 을 적용하는 예약이면 지금 편집기 내용을 DRAFT revision 으로 저장해 고정한다. 단건 적용과 같은 다듬기(URL 인코딩 → 소스 정리)를 거치므로, 예약이 실행됐을 때 저장되는 값이 화면에서 본 것과 같다. """ checked = guard(request) if not isinstance(checked, tuple): return checked st, user = checked actor = str(user.get("email") or "") base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}" back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}" want_html = bool(apply_html) display_flag = store.parse_tristate(set_display) selling_flag = store.parse_tristate(set_selling) if not want_html and display_flag is None and selling_flag is None: return RedirectResponse( url=f"{back}&err=예약할 내용을 하나 이상 선택하세요(상세페이지 / 진열 / 판매).", status_code=303, ) try: run_at = store.parse_schedule_at(scheduled_at) except ValueError as exc: return RedirectResponse(url=f"{back}&err={exc}", status_code=303) revision_id: int | None = None if want_html: body = store.format_html(store.encode_html_urls(html or "")) if not body.strip(): return RedirectResponse( url=f"{back}&err=상세페이지 내용이 비어 있습니다.", status_code=303 ) revision_id = st.add_revision( product_no=product_no, html_content=body, revision_type=store.REVISION_DRAFT, memo=f"예약 등록 ({run_at.strftime('%Y-%m-%d %H:%M')} 적용 예정)", created_by=actor, ) schedule_id = st.create_schedule( product_no=product_no, scheduled_at=run_at, revision_id=revision_id, set_display=display_flag, set_selling=selling_flag, memo=memo, created_by=actor, ) detail = store.describe_schedule_action( has_html=want_html, set_display=display_flag, set_selling=selling_flag ) st.log_audit( actor=actor, action="schedule_create", product_no=product_no, revision_id=revision_id, schedule_id=schedule_id, result="SUCCESS", detail=f"{run_at.strftime('%Y-%m-%d %H:%M')} — {detail}", ) logger.info("카페24 예약 등록 #%s 상품 %s (%s)", schedule_id, product_no, detail) return RedirectResponse( url=f"/cafe24/schedules?msg={run_at.strftime('%Y-%m-%d %H:%M')} 예약을 등록했습니다 — {detail}", status_code=303, ) @schedules_router.post("/schedules/{schedule_id}/cancel") def schedule_cancel(request: Request, schedule_id: int): """대기 중인 예약 취소. 이미 실행됐거나 실행 중이면 아무것도 하지 않는다.""" checked = guard(request) if not isinstance(checked, tuple): return checked st, user = checked ok = st.cancel_schedule(schedule_id, actor=str(user.get("email") or "")) if ok: return RedirectResponse(url=f"/cafe24/schedules?msg=예약 #{schedule_id} 을 취소했습니다.", status_code=303) return RedirectResponse( url=f"/cafe24/schedules?err=예약 #{schedule_id} 은 이미 처리되었거나 실행 중이라 취소할 수 없습니다.", status_code=303, ) @schedules_router.get("/schedules/preview/{product_no}") def schedule_current_flags(request: Request, product_no: int) -> dict: """예약 폼의 기본값을 위해 현재 진열/판매 상태를 알려준다(JSON, 읽기 전용).""" from .common import require_store # noqa: WPS433 st, _user = require_store(request) api = build_cafe24_api(st) try: raw = products.get_product(api.client, product_no) except Cafe24Error as exc: return {"product_no": product_no, "error": str(exc)} normalized = products.normalize_product(raw) return { "product_no": product_no, "display": normalized["display"], "selling": normalized["selling"], }