Skip to content

Commit ec8e437

Browse files
committed
feat: 스캔 코드 관련 메시지 및 오류 처리 추가
1 parent 211cfcb commit ec8e437

6 files changed

Lines changed: 153 additions & 16 deletions

File tree

app/core/const/scancode.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
SCANCODE_MESSAGES = {
2+
"no_token": {
3+
"ko": "유효하지 않은 URL입니다.",
4+
"en": "This link is not valid.",
5+
},
6+
"invalid_token": {
7+
"ko": "유효하지 않은 토큰입니다.",
8+
"en": "This QR code is not valid.",
9+
},
10+
"user_not_found": {
11+
"ko": "인증 정보를 찾을 수 없습니다.",
12+
"en": "We couldn't find this account.",
13+
},
14+
"user_no_valid_order": {
15+
"ko": "최근 6개월 이내에 결제된 유효한 주문이 없습니다 (환불 완료 또는 주문 없음).",
16+
"en": "No valid order was paid in the last six months (fully refunded, or no order at all).",
17+
},
18+
"order_not_found": {
19+
"ko": "주문을 찾을 수 없습니다.",
20+
"en": "We couldn't find this order.",
21+
},
22+
"order_refunded": {
23+
"ko": "전체 환불된 주문은 사용하실 수 없습니다.",
24+
"en": "This order was fully refunded and can no longer be used.",
25+
},
26+
"opr_not_found": {
27+
"ko": "티켓 정보를 찾을 수 없습니다.",
28+
"en": "We couldn't find this ticket.",
29+
},
30+
}
31+
32+
SCANCODE_ERROR_GUIDE = {
33+
"ko": "QR 코드를 다시 스캔해 주세요. 문제가 계속되면 행사 당일 등록 데스크의 스태프에게 문의해 주세요.",
34+
"en": "Please scan the QR code again. If the problem continues, ask a staff member at the registration desk.",
35+
}

app/core/negotiation.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from rest_framework import negotiation, parsers, renderers, request
2+
3+
4+
class IgnoreClientContentNegotiation(negotiation.BaseContentNegotiation):
5+
"""클라이언트의 Accept 헤더를 무시하고 뷰의 첫 렌더러를 강제한다.
6+
QR 스캐너 인앱 브라우저처럼 Accept 를 이상하게 보내는 클라이언트에게 406 대신 정상 응답을 돌려주기 위함.
7+
"""
8+
9+
def select_parser(self, request: request.Request, parsers_: list[parsers.BaseParser]) -> parsers.BaseParser | None:
10+
return parsers_[0] if parsers_ else None
11+
12+
def select_renderer(
13+
self,
14+
request: request.Request,
15+
renderers_: list[renderers.BaseRenderer],
16+
format_suffix: str | None = None,
17+
) -> tuple[renderers.BaseRenderer, str]:
18+
return renderers_[0], renderers_[0].media_type

app/shop/order/templates/scancode_base.html

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
<!DOCTYPE html>
2-
<html lang="ko-kr">
1+
{% is_english as en %}<!DOCTYPE html>
2+
<html lang="{% if en %}en{% else %}ko-kr{% endif %}">
33

44
<head>
55
<meta charSet="utf-8" />
@@ -232,6 +232,18 @@
232232
li::before {
233233
content: "- ";
234234
}
235+
236+
.error-sub {
237+
margin-top: 0.35rem;
238+
text-align: center;
239+
color: #888;
240+
}
241+
242+
.error-guide {
243+
font-size: 0.85rem;
244+
color: #333;
245+
text-align: center;
246+
}
235247
</style>
236248
</head>
237249

@@ -246,6 +258,7 @@
246258
<aside>
247259
{% block content %}{% endblock %}
248260
</aside>
261+
{% block notice %}
249262
<div class="notice">
250263
<strong>안내</strong>
251264
<ul>
@@ -254,6 +267,7 @@
254267
<li>자세한 주문 정보는 <a href="https://pycon.kr">파이콘 한국</a>의 구매 내역에서 조회할 수 있습니다.</li>
255268
</ul>
256269
</div>
270+
{% endblock %}
257271
</section>
258272
</body>
259273

app/shop/order/templates/scancode_error.html

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,24 @@
33
<h4>
44
{{ error_msg }}
55
</h4>
6+
<small class="error-sub">{{ error_msg_sub }}</small>
7+
<hr />
8+
<p class="error-guide">{{ error_guide }}</p>
9+
<small class="error-sub">{{ error_guide_sub }}</small>
10+
{% endblock %}
11+
{% block notice %}
12+
<div class="notice">
13+
<strong>{% if en %}Need help?{% else %}도움이 필요하신가요?{% endif %}</strong>
14+
<ul>
15+
{% if en %}
16+
<li>Check that you opened the full link from your ticket email or the PyCon Korea website.</li>
17+
<li>A QR code becomes invalid once the order is fully refunded.</li>
18+
<li>You can look up your order at <a href="https://pycon.kr">PyCon Korea</a> after signing in.</li>
19+
{% else %}
20+
<li>티켓 안내 메일이나 파이콘 한국 홈페이지의 링크를 그대로 열었는지 확인해 주세요.</li>
21+
<li>전체 환불된 주문의 QR 코드는 더 이상 사용할 수 없습니다.</li>
22+
<li>주문 내역은 <a href="https://pycon.kr">파이콘 한국</a>에 로그인 후 조회할 수 있습니다.</li>
23+
{% endif %}
24+
</ul>
25+
</div>
626
{% endblock %}

app/shop/order/test/scancode_api_test.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import pytest
44
import shortuuid
5+
from core.const.scancode import SCANCODE_ERROR_GUIDE, SCANCODE_MESSAGES
56
from django.conf import settings
7+
from django.urls import reverse
8+
from django.utils.html import escape
69
from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
710
from shop.order.models import Order
811
from shop.test.helpers import ScanCodeApi
@@ -141,3 +144,43 @@ def test_scancode_token_rejected_after_old_salt_removed(anon_client, order_facto
141144

142145
monkeypatch.setattr(settings.SHOP, "order_scancode_salts", ["new_salt"])
143146
assert ScanCodeApi(http_client=anon_client).list({"token": old_token}).status_code == HTTP_404_NOT_FOUND
147+
148+
149+
@pytest.mark.django_db
150+
def test_scancode_error_page_shows_both_languages_and_guide(anon_client, order_factory):
151+
order = order_factory(status="completed")
152+
tampered = order.scancode_token[:-1] + "A"
153+
154+
response = anon_client.get(reverse("v1:scancode-list"), {"token": tampered}, HTTP_ACCEPT_LANGUAGE="ko-KR,ko;q=0.9")
155+
body = response.content.decode()
156+
assert response.status_code == HTTP_404_NOT_FOUND
157+
assert escape(SCANCODE_MESSAGES["order_not_found"]["ko"]) in body
158+
assert escape(SCANCODE_MESSAGES["order_not_found"]["en"]) in body # 보조 언어 병기.
159+
assert escape(SCANCODE_ERROR_GUIDE["ko"]) in body
160+
161+
162+
@pytest.mark.django_db
163+
def test_scancode_error_page_is_english_for_english_client(anon_client, order_factory):
164+
order = order_factory(status="completed")
165+
tampered = order.scancode_token[:-1] + "A"
166+
167+
response = anon_client.get(reverse("v1:scancode-list"), {"token": tampered}, HTTP_ACCEPT_LANGUAGE="en-US,en;q=0.9")
168+
body = response.content.decode()
169+
assert '<html lang="en">' in body
170+
assert f"<h4>\n {escape(SCANCODE_MESSAGES['order_not_found']['en'])}\n</h4>" in body # 영어가 주 문구.
171+
assert escape(SCANCODE_ERROR_GUIDE["en"]) in body
172+
173+
174+
@pytest.mark.django_db
175+
def test_scancode_renders_html_even_when_client_asks_for_json(anon_client, order_factory):
176+
# 일부 QR 스캐너 인앱 브라우저의 Accept 헤더 때문에 406 이 나가면 안 된다.
177+
order = order_factory(status="completed")
178+
179+
ok = anon_client.get(reverse("v1:scancode-list"), {"token": order.scancode_token}, HTTP_ACCEPT="application/json")
180+
assert ok.status_code == HTTP_200_OK
181+
assert ok["Content-Type"].startswith("text/html")
182+
183+
tampered = order.scancode_token[:-1] + "A"
184+
error = anon_client.get(reverse("v1:scancode-list"), {"token": tampered}, HTTP_ACCEPT="application/json")
185+
assert error.status_code == HTTP_404_NOT_FOUND
186+
assert SCANCODE_MESSAGES["order_not_found"]["ko"] in error.content.decode()

app/shop/order/views/scancode.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
from re import compile
33
from typing import Any
44

5+
from core.const.scancode import SCANCODE_ERROR_GUIDE, SCANCODE_MESSAGES
56
from core.const.tag import OpenAPITag
7+
from core.negotiation import IgnoreClientContentNegotiation
68
from core.openapi.schemas import build_html_responses
9+
from core.templatetags.i18n_extras import is_english
710
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
811
from drf_spectacular.utils import extend_schema
912
from rest_framework import permissions, renderers, request, response, status, viewsets
@@ -18,29 +21,32 @@
1821

1922

2023
class _ScanCodeError(Exception):
21-
def __init__(self, msg: str, code: int) -> None:
22-
super().__init__(msg, code)
23-
self.msg = msg
24+
def __init__(self, key: str, code: int) -> None:
25+
super().__init__(key, code)
26+
self.key = key
2427
self.code = code
2528

2629
def to_response(self) -> response.Response:
30+
lang, sub_lang = ("en", "ko") if is_english() else ("ko", "en")
2731
return response.Response(
28-
data={"error_msg": self.msg},
32+
data={
33+
"error_msg": SCANCODE_MESSAGES[self.key][lang],
34+
"error_msg_sub": SCANCODE_MESSAGES[self.key][sub_lang],
35+
"error_guide": SCANCODE_ERROR_GUIDE[lang],
36+
"error_guide_sub": SCANCODE_ERROR_GUIDE[sub_lang],
37+
},
2938
status=self.code,
3039
template_name="scancode_error.html",
3140
)
3241

3342

3443
def _render_user(token: str) -> response.Response:
3544
if not (user := UserExt.from_scancode_token(token)):
36-
raise _ScanCodeError(msg="인증 정보를 찾을 수 없습니다.", code=status.HTTP_403_FORBIDDEN)
45+
raise _ScanCodeError(key="user_not_found", code=status.HTTP_403_FORBIDDEN)
3746
# 환불된 주문도 응답에 포함(현장 스태프의 이력 확인용). 단 모두 refunded 면 게이트.
3847
orders = list(Order.objects.filter_purchased_by(user).filter_in_last_six_months())
3948
if not any(o.current_status != PaymentHistoryStatus.refunded for o in orders):
40-
raise _ScanCodeError(
41-
msg="최근 6개월 이내에 결제된 유효한 주문이 없습니다 (환불 완료 또는 주문 없음).",
42-
code=status.HTTP_403_FORBIDDEN,
43-
)
49+
raise _ScanCodeError(key="user_no_valid_order", code=status.HTTP_403_FORBIDDEN)
4450
return response.Response(
4551
data={
4652
"user": UserScanCodeSerializer(instance=user).data,
@@ -53,9 +59,9 @@ def _render_user(token: str) -> response.Response:
5359

5460
def _render_order(token: str) -> response.Response:
5561
if not (order := Order.from_scancode_token(token)):
56-
raise _ScanCodeError(msg="주문을 찾을 수 없습니다.", code=status.HTTP_404_NOT_FOUND)
62+
raise _ScanCodeError(key="order_not_found", code=status.HTTP_404_NOT_FOUND)
5763
if order.current_status == PaymentHistoryStatus.refunded:
58-
raise _ScanCodeError(msg="전체 환불된 주문은 사용하실 수 없습니다.", code=status.HTTP_404_NOT_FOUND)
64+
raise _ScanCodeError(key="order_refunded", code=status.HTTP_404_NOT_FOUND)
5965
return response.Response(
6066
data={"order": OrderScanCodeSerializer(instance=order).data},
6167
status=status.HTTP_200_OK,
@@ -65,7 +71,7 @@ def _render_order(token: str) -> response.Response:
6571

6672
def _render_opr(token: str) -> response.Response:
6773
if not (opr := OrderProductRelation.from_scancode_token(token)):
68-
raise _ScanCodeError(msg="티켓 정보를 찾을 수 없습니다.", code=status.HTTP_403_FORBIDDEN)
74+
raise _ScanCodeError(key="opr_not_found", code=status.HTTP_403_FORBIDDEN)
6975
return response.Response(
7076
data={"order_product": OrderProductScanCodeSerializer(instance=opr).data},
7177
status=status.HTTP_200_OK,
@@ -87,6 +93,7 @@ class ScanCodeViewSet(viewsets.GenericViewSet):
8793
permission_classes = [permissions.AllowAny]
8894
authentication_classes: list = []
8995
renderer_classes = [renderers.TemplateHTMLRenderer]
96+
content_negotiation_class = IgnoreClientContentNegotiation # 스캐너 인앱 브라우저 Accept 로 406 이 나가지 않도록.
9097

9198
@extend_schema(
9299
summary="QR 코드 페이지 (token 으로 dispatch)",
@@ -103,9 +110,9 @@ class ScanCodeViewSet(viewsets.GenericViewSet):
103110
def list(self, request: request.Request, *args: tuple[Any], **kwargs: dict[str, Any]) -> response.Response:
104111
try:
105112
if not (token := request.query_params.get("token")):
106-
raise _ScanCodeError(msg="유효하지 않은 URL입니다.", code=status.HTTP_404_NOT_FOUND)
113+
raise _ScanCodeError(key="no_token", code=status.HTTP_404_NOT_FOUND)
107114
if not (match := _SCANCODE_REGEX.match(token)):
108-
raise _ScanCodeError(msg="유효하지 않은 토큰입니다.", code=status.HTTP_404_NOT_FOUND)
115+
raise _ScanCodeError(key="invalid_token", code=status.HTTP_404_NOT_FOUND)
109116
return _DISPATCH[match["prefix"]](token)
110117
except _ScanCodeError as e:
111118
return e.to_response()

0 commit comments

Comments
 (0)