Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/admin_api/serializers/event/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class Meta:
"summary_en",
"slideshow_url",
"public_slideshow_file",
"public_slideshow_url",
"image",
"description_ko",
"description_en",
Expand Down
10 changes: 0 additions & 10 deletions app/admin_api/serializers/shop/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,16 +293,6 @@ class Meta:
model = OrderProductRelationTag
fields = COMMON_ADMIN_FIELDS + ("code", "name", "priority")

def validate_code(self, value: str) -> str:
duplicated = (
OrderProductRelationTag.objects.filter_active()
.filter(code=value)
.exclude(pk=self.instance.pk if self.instance else None)
)
if duplicated.exists():
raise serializers.ValidationError("이미 있는 코드입니다.")
return value


class OrderProductRelationTagAssignResultSerializer(serializers.Serializer):
affected = serializers.IntegerField()
20 changes: 20 additions & 0 deletions app/admin_api/test/shop/order_product_notifications_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,26 @@ def test_opr_notification_preview_falls_back_to_customer_info(api_client, opr_em
assert recipient["context"]["participant_name"] == "홍길동"


@pytest.mark.django_db
def test_opr_notification_preview_skips_participant_without_channel_recipient(
api_client, opr_email_template, ticket_opr
):
# 참가자 정보는 있으나 해당 채널 수신처(email)가 비어 있으면 발송 대상에서 제외한다.
TicketInfo.objects.create(
order_product_relation=ticket_opr,
name="참가자",
phone="01099998888",
email="",
)

response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
{"channel": "email", "template_id": str(opr_email_template.id)}
)

assert response.status_code == HTTP_200_OK
assert response.json()["recipients"] == []


@pytest.mark.django_db
def test_opr_notification_preview_includes_refunded_product(api_client, opr_email_template, two_ticket_order):
refunded = two_ticket_order.products.first()
Expand Down
10 changes: 10 additions & 0 deletions app/admin_api/test/shop/order_product_relation_tags_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ def test_order_detail_exposes_tags(api_client, speaker_tag, order_factory):
assert body["products"][0]["tags"] == [{"id": str(speaker_tag.id), "code": "speaker", "name": "발표자"}]


@pytest.mark.django_db
def test_assign_rejects_invalid_filter_value(api_client, speaker_tag, order_factory):
order_factory(status="completed")

response = api_client.post(f"{_assign_url(speaker_tag.id)}?price_min=비싼거")

assert response.status_code == HTTP_400_BAD_REQUEST
assert speaker_tag.order_product_relations.count() == 0


@pytest.mark.django_db
def test_assign_rejects_blank_filter_value(api_client, speaker_tag, order_factory):
# django-filter 는 빈 값을 무시하므로 `?id=` 가 전체 태깅이 되면 안 된다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Generated by Django 6.0.6 on 2026-08-25 11:37

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("file", "0001_initial"),
("presentation", "0017_presentationbookmark"),
]

operations = [
migrations.AddField(
model_name="historicalpresentation",
name="public_slideshow_url",
field=models.URLField(
blank=True,
default="",
help_text="최종 외부 공개용 발표 자료 (URL)",
null=True,
),
),
migrations.AddField(
model_name="presentation",
name="public_slideshow_url",
field=models.URLField(
blank=True,
default="",
help_text="최종 외부 공개용 발표 자료 (URL)",
null=True,
),
),
migrations.AlterField(
model_name="historicalpresentation",
name="public_slideshow_file",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="최종 외부 공개용 발표 자료 (파일)",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="file.publicfile",
),
),
migrations.AlterField(
model_name="presentation",
name="public_slideshow_file",
field=models.ForeignKey(
blank=True,
help_text="최종 외부 공개용 발표 자료 (파일)",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="file.publicfile",
),
),
]
5 changes: 4 additions & 1 deletion app/event/presentation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,12 @@ class Presentation(BaseAbstractModel):
on_delete=models.PROTECT,
null=True,
blank=True,
help_text="최종 외부 공개용 발표 자료",
help_text="최종 외부 공개용 발표 자료 (파일)",
related_name="+",
)
public_slideshow_url = models.URLField(
null=True, blank=True, default="", help_text="최종 외부 공개용 발표 자료 (URL)"
)

categories = models.ManyToManyField(to="PresentationCategory", through="PresentationCategoryRelation")
objects: PresentationQuerySet = PresentationQuerySet.as_manager()
Expand Down
1 change: 1 addition & 0 deletions app/event/presentation/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class Meta:
"description",
"slideshow_url",
"public_slideshow_file",
"public_slideshow_url",
"image",
"categories",
"speakers",
Expand Down
9 changes: 9 additions & 0 deletions app/shop/order/test/opr_model_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ def test_build_verify_display_maps_frozen_context():
}


@pytest.mark.django_db
def test_build_notification_context_falls_back_to_participant_info(ticket_opr):
# participant 를 넘기지 않으면 ticket_info/customer_info 를 스스로 찾는다.
context = ticket_opr.build_notification_context()

assert context["participant_name"] == ticket_opr.participant_info.name
assert context["participant_email"] == ticket_opr.participant_info.email


@pytest.mark.django_db
def test_is_document_downloadable_by_only_order_owner(used_ticket_opr, other_user):
assert used_ticket_opr.is_document_downloadable_by(used_ticket_opr.order.user) is True
Expand Down
17 changes: 13 additions & 4 deletions app/shop/order/test/scancode_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
from shop.test.helpers import ScanCodeApi


def tamper(token: str) -> str:
"""token 의 salt 마지막 글자를 다른 글자로 바꿔 검증을 깨뜨린다.

salt 는 32byte HMAC 의 base64 라 마지막 글자가 16종("048AEIMQUYcgkosw") 중 하나다.
무조건 "A" 로 바꾸면 원래 "A" 인 1/16 확률로 변조가 no-op 이 되어 테스트가 간헐 실패한다.
"""
return token[:-1] + ("A" if token[-1] != "A" else "B")


@pytest.mark.django_db
def test_scancode_rejects_missing_token(anon_client):
response = ScanCodeApi(http_client=anon_client).list()
Expand Down Expand Up @@ -72,7 +81,7 @@ def test_scancode_opr_token_with_invalid_salt_rejects(anon_client, order_factory
completed_order = order_factory(status="completed")
opr = completed_order.products.first()
# token 의 salt 부분 변조 — from_scancode_token 이 None 반환 → 403.
tampered = opr.scancode_token[:-1] + ("A" if opr.scancode_token[-1] != "A" else "B")
tampered = tamper(opr.scancode_token)
response = ScanCodeApi(http_client=anon_client).list({"token": tampered})
assert response.status_code == HTTP_403_FORBIDDEN

Expand Down Expand Up @@ -149,7 +158,7 @@ def test_scancode_token_rejected_after_old_salt_removed(anon_client, order_facto
@pytest.mark.django_db
def test_scancode_error_page_shows_both_languages_and_guide(anon_client, order_factory):
order = order_factory(status="completed")
tampered = order.scancode_token[:-1] + "A"
tampered = tamper(order.scancode_token)

response = anon_client.get(reverse("v1:scancode-list"), {"token": tampered}, HTTP_ACCEPT_LANGUAGE="ko-KR,ko;q=0.9")
body = response.content.decode()
Expand All @@ -162,7 +171,7 @@ def test_scancode_error_page_shows_both_languages_and_guide(anon_client, order_f
@pytest.mark.django_db
def test_scancode_error_page_is_english_for_english_client(anon_client, order_factory):
order = order_factory(status="completed")
tampered = order.scancode_token[:-1] + "A"
tampered = tamper(order.scancode_token)

response = anon_client.get(reverse("v1:scancode-list"), {"token": tampered}, HTTP_ACCEPT_LANGUAGE="en-US,en;q=0.9")
body = response.content.decode()
Expand All @@ -180,7 +189,7 @@ def test_scancode_renders_html_even_when_client_asks_for_json(anon_client, order
assert ok.status_code == HTTP_200_OK
assert ok["Content-Type"].startswith("text/html")

tampered = order.scancode_token[:-1] + "A"
tampered = tamper(order.scancode_token)
error = anon_client.get(reverse("v1:scancode-list"), {"token": tampered}, HTTP_ACCEPT="application/json")
assert error.status_code == HTTP_404_NOT_FOUND
assert SCANCODE_MESSAGES["order_not_found"]["ko"] in error.content.decode()
Loading