feat(cafe24): 진열/판매 배지 클릭으로 상태 토글
편집기 오른쪽 위 배지를 눌러 진열·판매를 바로 바꾼다. 상태만 바꾸려고
카페24 관리자에 들어갈 필요가 없어진다.
- POST /cafe24/products/{no}/status (JSON) 추가. 상세설명은 건드리지 않아
BACKUP revision 을 만들지 않는다 - 되돌릴 HTML 이 없고 다시 눌러 복구된다.
- 요청값을 낙관적으로 반영하지 않고 쓰기 후 카페24가 돌려준 실제 상태로
화면을 다시 그린다. 실패해도 화면과 카페24가 어긋나지 않는다.
- 왼쪽 목록의 점과 정렬용 data-display/data-selling 도 함께 갱신(목록을
다시 받지 않으므로).
- 카페24 조회 실패 시에는 현재 상태를 믿을 수 없어 배지를 버튼으로 만들지
않는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,13 +34,13 @@ import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi import APIRouter, Body, Form, HTTPException, 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 .common import base_ctx, guard, require_store
|
||||
|
||||
logger = logging.getLogger("cafe24.products")
|
||||
|
||||
@@ -229,6 +229,64 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse:
|
||||
return _no_store(render_template(request, "cafe24/_editor.html", ctx))
|
||||
|
||||
|
||||
@products_router.post("/products/{product_no}/status")
|
||||
def product_status(
|
||||
request: Request,
|
||||
product_no: int,
|
||||
payload: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
"""진열/판매 상태만 바꾼다 — 편집기 오른쪽 위 배지 클릭용(JSON API).
|
||||
|
||||
상세설명은 건드리지 않는다(`build_update_payload` 는 준 필드만 보낸다). 그래서
|
||||
BACKUP revision 도 만들지 않는다 — 되돌릴 HTML 이 없고, 상태는 다시 눌러
|
||||
되돌릴 수 있다.
|
||||
|
||||
`value` 는 클라이언트가 **원하는 결과값**이다(현재값을 뒤집지 않는다). 화면의
|
||||
배지가 카페24와 어긋나 있어도 사용자가 누른 대로 되는 편이 예측 가능하다.
|
||||
응답에는 쓰기 후 카페24가 돌려준 실제 상태를 담아 화면을 그것에 맞춘다.
|
||||
"""
|
||||
st, user = require_store(request)
|
||||
actor = str(user.get("email") or "")
|
||||
|
||||
field = str(payload.get("field") or "").strip()
|
||||
if field not in ("display", "selling"):
|
||||
raise HTTPException(status_code=400, detail="field 는 display 또는 selling 이어야 합니다.")
|
||||
want = bool(payload.get("value"))
|
||||
|
||||
api = build_cafe24_api(st)
|
||||
try:
|
||||
updated = products.update_product(api.client, product_no, **{field: want})
|
||||
except Cafe24Error as exc:
|
||||
st.log_audit(
|
||||
actor=actor, action=f"set_{field}", product_no=product_no,
|
||||
result="FAIL", detail=f"{want} 설정 실패: {exc}",
|
||||
)
|
||||
logger.warning("카페24 상품 %s %s 변경 실패: %s", product_no, field, exc)
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
|
||||
if "display" not in updated or "selling" not in updated:
|
||||
try:
|
||||
updated = products.get_product(api.client, product_no)
|
||||
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
|
||||
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
|
||||
updated = {}
|
||||
|
||||
info = products.normalize_product(updated) if updated else {}
|
||||
if info.get("product_no"):
|
||||
st.upsert_products([info])
|
||||
state = {
|
||||
"display": bool(info.get("display", want if field == "display" else True)),
|
||||
"selling": bool(info.get("selling", want if field == "selling" else True)),
|
||||
}
|
||||
st.log_audit(
|
||||
actor=actor, action=f"set_{field}", product_no=product_no,
|
||||
result="SUCCESS", detail=f"{field}={'T' if want else 'F'}",
|
||||
)
|
||||
logger.info("카페24 상품 %s %s=%s (%s)", product_no, field, want, actor)
|
||||
return {"ok": True, **state}
|
||||
|
||||
|
||||
@products_router.get("/products/{product_no}")
|
||||
def product_redirect(request: Request, product_no: int):
|
||||
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
|
||||
|
||||
Reference in New Issue
Block a user