diff --git a/app/admin_api/serializers/event/presentation.py b/app/admin_api/serializers/event/presentation.py index 8e9a58d..ae21cf6 100644 --- a/app/admin_api/serializers/event/presentation.py +++ b/app/admin_api/serializers/event/presentation.py @@ -49,6 +49,7 @@ class Meta: "summary_en", "slideshow_url", "public_slideshow_file", + "public_slideshow_url", "image", "description_ko", "description_en", diff --git a/app/admin_api/serializers/shop/orders.py b/app/admin_api/serializers/shop/orders.py index 7d85d4b..3d8ad6a 100644 --- a/app/admin_api/serializers/shop/orders.py +++ b/app/admin_api/serializers/shop/orders.py @@ -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() diff --git a/app/admin_api/test/shop/order_product_notifications_api_test.py b/app/admin_api/test/shop/order_product_notifications_api_test.py index a436ae0..c9c8990 100644 --- a/app/admin_api/test/shop/order_product_notifications_api_test.py +++ b/app/admin_api/test/shop/order_product_notifications_api_test.py @@ -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() diff --git a/app/admin_api/test/shop/order_product_relation_tags_api_test.py b/app/admin_api/test/shop/order_product_relation_tags_api_test.py index be174bc..4daa33d 100644 --- a/app/admin_api/test/shop/order_product_relation_tags_api_test.py +++ b/app/admin_api/test/shop/order_product_relation_tags_api_test.py @@ -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=` 가 전체 태깅이 되면 안 된다. diff --git a/app/event/presentation/migrations/0018_historicalpresentation_public_slideshow_url_and_more.py b/app/event/presentation/migrations/0018_historicalpresentation_public_slideshow_url_and_more.py new file mode 100644 index 0000000..9b4ad5e --- /dev/null +++ b/app/event/presentation/migrations/0018_historicalpresentation_public_slideshow_url_and_more.py @@ -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", + ), + ), + ] diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py index e409d79..54e23cf 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -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() diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py index de67b10..ffce059 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -98,6 +98,7 @@ class Meta: "description", "slideshow_url", "public_slideshow_file", + "public_slideshow_url", "image", "categories", "speakers", diff --git a/app/shop/order/test/opr_model_test.py b/app/shop/order/test/opr_model_test.py index 44dcc5e..af5301c 100644 --- a/app/shop/order/test/opr_model_test.py +++ b/app/shop/order/test/opr_model_test.py @@ -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 diff --git a/app/shop/order/test/scancode_api_test.py b/app/shop/order/test/scancode_api_test.py index ecefc11..24cd3f1 100644 --- a/app/shop/order/test/scancode_api_test.py +++ b/app/shop/order/test/scancode_api_test.py @@ -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() @@ -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 @@ -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() @@ -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() @@ -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()