From 5988049d49b5b42466dcfbd8ebc78734c7c7c29a Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Sun, 26 Jul 2026 16:25:19 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=20=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EC=8A=B9=EC=9D=B8=20=EC=8B=9C=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=EC=9E=90=20=EC=96=B8=EC=96=B4=EB=A1=9C=20=EC=9E=98=EB=AA=BB?= =?UTF-8?q?=EB=90=9C=20=EB=B2=88=EC=97=AD=20=EC=BB=AC=EB=9F=BC=EC=9D=B4=20?= =?UTF-8?q?=EB=8D=AE=EC=96=B4=EC=8D=A8=EC=A7=80=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modeltranslation의 원본 필드(nickname, title, summary, description, biography)는 현재 활성 언어로 라우팅되는 descriptor다. 수정 요청 시 저장되는 diff에는 원본 필드와 언어별 컬럼이 함께 담기는데, apply_diff_to_model이 원본 필드까지 setattr 하면서 '승인 시점의 언어'로 값이 적용되고 있었다. 어드민 클라이언트는 language를 넘기지 않아 Accept-Language가 항상 ko이고 (useAdminAPI.ts의 useBackendAdminClient), 참가자 포탈은 사용자가 선택한 언어를 보낸다. 그래서 영문 UI로 수정을 요청한 참가자의 심사를 승인하면 영문 값이 한글 컬럼을 덮어썼다. - apply_diff_to_model이 modeltranslation 원본 필드를 건너뛰고 언어별 실제 컬럼 (nickname_ko/nickname_en 등)만 적용하도록 수정 - 승인 처리를 transaction.atomic으로 묶어, 여러 모델에 걸친 수정이 중간에 실패했을 때 일부만 반영된 채 심사 상태가 requested로 남는 문제 방지 diff 스냅샷 자체는 그대로 두어 심사 미리보기(fake_modified_instance) 동작에는 영향이 없다. Signed-off-by: JaehyuckSa --- .../serializers/modification_audit.py | 10 +- app/core/test/django_orm_test.py | 46 ++++++ app/core/util/django_orm.py | 19 +++ .../test/modification_audit_approval_test.py | 137 ++++++++++++++++++ 4 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 app/core/test/django_orm_test.py create mode 100644 app/participant_portal_api/test/modification_audit_approval_test.py diff --git a/app/admin_api/serializers/modification_audit.py b/app/admin_api/serializers/modification_audit.py index 3fb9605..0a174de 100644 --- a/app/admin_api/serializers/modification_audit.py +++ b/app/admin_api/serializers/modification_audit.py @@ -2,6 +2,7 @@ import unicodedata from core.serializer.json_schema_serializer import JsonSchemaSerializer +from django.db import transaction from event.presentation.models import Presentation, PresentationSpeaker from participant_portal_api.models import ModificationAudit, ModificationAuditComment from rest_framework import serializers @@ -63,9 +64,12 @@ def validate(self, attrs: dict) -> dict: def save(self, **kwargs: dict) -> ModificationAudit: instance: ModificationAudit = self.instance - instance.status = ModificationAudit.Status.APPROVED - instance.apply_modification() - instance.save() + # 수정 요청은 여러 모델(예: 발표 + 발표자)에 걸칠 수 있다. 중간에 실패하면 일부만 반영된 채 + # 심사 상태는 requested로 남으므로, 적용과 상태 변경을 한 트랜잭션으로 묶는다. + with transaction.atomic(): + instance.status = ModificationAudit.Status.APPROVED + instance.apply_modification() + instance.save() return instance diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py new file mode 100644 index 0000000..6d59bf9 --- /dev/null +++ b/app/core/test/django_orm_test.py @@ -0,0 +1,46 @@ +"""core.util.django_orm 의 diff 적용 헬퍼 테스트.""" + +import pytest +from core.util.django_orm import apply_diff_to_model, model_to_identifier, translated_original_field_names +from django.utils.translation import override +from event.presentation.models import Presentation +from model_bakery import baker +from user.models import UserExt + + +def test_translated_original_field_names_returns_registered_fields(): + """modeltranslation 에 등록된 원본 필드명을 돌려준다.""" + assert translated_original_field_names(UserExt) == {"nickname"} + assert translated_original_field_names(Presentation) == {"title", "summary", "description"} + + +def test_translated_original_field_names_is_empty_for_unregistered_model(db): + """번역 등록이 없는 모델은 빈 집합을 돌려준다.""" + from file.models import PublicFile + + assert translated_original_field_names(PublicFile) == set() + + +@pytest.mark.parametrize("active_language", ["ko", "en"], ids=["applied_in_ko", "applied_in_en"]) +def test_apply_diff_to_model_ignores_translated_original_field(db, active_language): + """원본 필드(title)는 활성 언어에 따라 라우팅되므로 무시하고, 언어별 컬럼만 적용해야 한다.""" + presentation = baker.make(Presentation, title_ko="한글제목", title_en="EnglishTitle") + diff = {model_to_identifier(presentation): {"title": "NewEnglishTitle", "title_en": "NewEnglishTitle"}} + + with override(active_language): + apply_diff_to_model(diff) + + presentation.refresh_from_db() + assert presentation.title_en == "NewEnglishTitle" + assert presentation.title_ko == "한글제목" + + +def test_apply_diff_to_model_applies_plain_field(db): + """번역 대상이 아닌 필드는 그대로 적용한다.""" + presentation = baker.make(Presentation, slideshow_url="https://example.com/old") + diff = {model_to_identifier(presentation): {"slideshow_url": "https://example.com/new"}} + + apply_diff_to_model(diff) + + presentation.refresh_from_db() + assert presentation.slideshow_url == "https://example.com/new" diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 93a2d5e..02e17c8 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -12,6 +12,8 @@ from django.db.models.fields.files import FieldFile from django.forms import model_to_dict from django.utils.functional import Promise +from modeltranslation.translator import NotRegistered +from modeltranslation.translator import translator as modeltranslation_translator def arbitrary_value_to_basic_type(value: typing.Any) -> str | int | float | bool | None: @@ -251,6 +253,15 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s return resolved_models[key] +def translated_original_field_names(model_class: type[models.Model]) -> set[str]: + """django-modeltranslation이 번역 대상으로 등록한 '원본' 필드명(예: nickname, title) 집합.""" + try: + # TranslationOptions.fields는 버전에 따라 tuple 또는 dict이므로 set()으로 통일해 원본 필드명만 얻는다. + return set(modeltranslation_translator.get_options_for_model(model_class).fields) + except NotRegistered: + return set() + + def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[models.Model]: result_instances: list[models.Model] = [] @@ -258,8 +269,16 @@ def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[m if not (model_instance := identifier_to_model(model_identifier)): raise ValueError(f"Model class not found for identifier: {model_identifier}") + # modeltranslation의 원본 필드(nickname 등)는 현재 활성 언어로 라우팅되는 descriptor다. + # diff를 만든 시점(요청자의 언어)과 적용하는 시점(승인자의 언어)이 다르면 엉뚱한 언어 컬럼을 + # 덮어쓰므로, 적용은 언어별 실제 컬럼(nickname_ko/nickname_en)으로만 수행한다. + skip_fields = translated_original_field_names(type(model_instance)) + # Apply the data to the model instance for field_name, value in model_data.items(): + if field_name in skip_fields: + continue + if is_identifier(value): # If the value is a model identifier, resolve it to a model instance if not (related_model_instance := identifier_to_model(value)): diff --git a/app/participant_portal_api/test/modification_audit_approval_test.py b/app/participant_portal_api/test/modification_audit_approval_test.py new file mode 100644 index 0000000..b510805 --- /dev/null +++ b/app/participant_portal_api/test/modification_audit_approval_test.py @@ -0,0 +1,137 @@ +"""수정 심사 승인(approve) 시 변경 내역이 올바른 언어 컬럼에 반영되는지 검증한다. + +modeltranslation 의 원본 필드(nickname 등)는 '현재 활성 언어'로 라우팅되는 descriptor 다. +수정 요청을 만든 시점(요청자의 언어)과 승인 시점(승인자의 언어)이 다르면 +반대쪽 언어 컬럼을 덮어쓰던 버그가 있었다. +""" + +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest +from core.util.thread_local import thread_local +from django.core.files.base import ContentFile +from django.utils.translation import override +from file.models import PublicFile +from participant_portal_api.models import ModificationAudit +from participant_portal_api.serializers.user import UserPortalSerializer +from rest_framework import status +from rest_framework.test import APIClient +from user.models import UserExt + +APPROVE_URL = "/v1/admin-api/participant_portal_api/modificationaudit/{audit_id}/approve/" + + +@contextmanager +def _acting_as(user: UserExt): + """created_by 등은 thread-local 에서 실행자를 읽으므로, 요청자를 명시적으로 주입한다.""" + thread_local.current_request = SimpleNamespace(user=user) + try: + yield + finally: + if hasattr(thread_local, "current_request"): + del thread_local.current_request + + +@pytest.fixture +def requester(db) -> UserExt: + return UserExt.objects.create_user( + username="requester", email="requester@example.com", nickname_ko="한글이름", nickname_en="EnglishName" + ) + + +@pytest.fixture +def superuser(db) -> UserExt: + return UserExt.objects.create_superuser(username="admin", email="admin@example.com") + + +@pytest.fixture +def admin_client(superuser) -> APIClient: + client = APIClient() + client.force_authenticate(user=superuser) + return client + + +def _request_profile_modification(requester: UserExt, payload: dict, language: str) -> ModificationAudit: + """참가자 포탈에서 주어진 언어로 프로필 수정을 요청하고, 생성된 심사 건을 돌려준다.""" + with override(language), _acting_as(requester): + serializer = UserPortalSerializer(instance=requester, data=payload, partial=True) + assert serializer.is_valid(), serializer.errors + serializer.save() + + return ModificationAudit.objects.get(status=ModificationAudit.Status.REQUESTED) + + +def test_approve_en_nickname_request_under_ko_locale_keeps_ko_nickname(requester, admin_client): + """영문 UI 에서 영문 닉네임만 고친 요청을, 어드민이 한국어 로케일로 승인해도 한글 닉네임은 그대로여야 한다.""" + audit = _request_profile_modification(requester, {"nickname_en": "NewEnglish"}, language="en") + + response = admin_client.patch( + APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="ko" + ) + + assert response.status_code == status.HTTP_200_OK, response.data + requester.refresh_from_db() + assert requester.nickname_en == "NewEnglish" + assert requester.nickname_ko == "한글이름" + + +def test_approve_ko_nickname_request_under_en_locale_keeps_en_nickname(requester, admin_client): + """한국어 UI 에서 한글 닉네임만 고친 요청을, 어드민이 영문 로케일로 승인해도 영문 닉네임은 그대로여야 한다.""" + audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") + + response = admin_client.patch( + APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="en" + ) + + assert response.status_code == status.HTTP_200_OK, response.data + requester.refresh_from_db() + assert requester.nickname_ko == "새한글이름" + assert requester.nickname_en == "EnglishName" + + +@pytest.mark.parametrize("approval_language", ["ko", "en"], ids=["approved_in_ko", "approved_in_en"]) +def test_approve_applies_requested_nickname_regardless_of_approval_locale(requester, admin_client, approval_language): + """요청한 언어의 닉네임 변경 자체는 승인자의 로케일과 무관하게 반영되어야 한다.""" + audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") + + response = admin_client.patch( + APPROVE_URL.format(audit_id=audit.id), + data={}, + format="json", + HTTP_ACCEPT_LANGUAGE=approval_language, + ) + + assert response.status_code == status.HTTP_200_OK, response.data + requester.refresh_from_db() + assert requester.nickname_ko == "새한글이름" + + +def test_approve_applies_non_translated_field(requester, admin_client): + """번역 대상이 아닌 필드(프로필 이미지)는 기존대로 정상 반영되어야 한다.""" + with _acting_as(requester): + image = PublicFile.objects.create( + file=ContentFile(b"image-bytes", name="profile.png"), mimetype="image/png", hash="hash", size=11 + ) + + audit = _request_profile_modification(requester, {"image": image.pk}, language="ko") + + response = admin_client.patch( + APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="ko" + ) + + assert response.status_code == status.HTTP_200_OK, response.data + requester.refresh_from_db() + assert requester.image_id == image.pk + + +def test_approve_marks_audit_as_approved(requester, admin_client): + """승인이 끝나면 심사 상태가 approved 로 바뀌어야 한다.""" + audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") + + response = admin_client.patch(APPROVE_URL.format(audit_id=audit.id), data={}, format="json") + + assert response.status_code == status.HTTP_200_OK, response.data + assert response.data["status"] == ModificationAudit.Status.APPROVED + audit.refresh_from_db() + assert audit.status == ModificationAudit.Status.APPROVED From 62ad34e7394205a960788e9afe7827b255e4a731 Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Sun, 26 Jul 2026 16:39:28 +0900 Subject: [PATCH 2/4] =?UTF-8?q?change:=20=EC=88=98=EC=A0=95=20=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EC=8A=B9=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8/?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자명한 주석 제거, 핵심 근거 주석만 한 줄로 축약 - participant_portal_api/test에 누락된 __init__.py 추가 - 테스트 내 지역 import를 상위로 이동, 불필요한 db fixture 제거 Signed-off-by: JaehyuckSa --- app/admin_api/serializers/modification_audit.py | 2 -- app/core/test/django_orm_test.py | 5 ++--- app/core/util/django_orm.py | 5 +---- app/participant_portal_api/test/__init__.py | 0 .../test/modification_audit_approval_test.py | 7 +------ 5 files changed, 4 insertions(+), 15 deletions(-) create mode 100644 app/participant_portal_api/test/__init__.py diff --git a/app/admin_api/serializers/modification_audit.py b/app/admin_api/serializers/modification_audit.py index 0a174de..978a96e 100644 --- a/app/admin_api/serializers/modification_audit.py +++ b/app/admin_api/serializers/modification_audit.py @@ -64,8 +64,6 @@ def validate(self, attrs: dict) -> dict: def save(self, **kwargs: dict) -> ModificationAudit: instance: ModificationAudit = self.instance - # 수정 요청은 여러 모델(예: 발표 + 발표자)에 걸칠 수 있다. 중간에 실패하면 일부만 반영된 채 - # 심사 상태는 requested로 남으므로, 적용과 상태 변경을 한 트랜잭션으로 묶는다. with transaction.atomic(): instance.status = ModificationAudit.Status.APPROVED instance.apply_modification() diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py index 6d59bf9..c593f0b 100644 --- a/app/core/test/django_orm_test.py +++ b/app/core/test/django_orm_test.py @@ -4,6 +4,7 @@ from core.util.django_orm import apply_diff_to_model, model_to_identifier, translated_original_field_names from django.utils.translation import override from event.presentation.models import Presentation +from file.models import PublicFile from model_bakery import baker from user.models import UserExt @@ -14,10 +15,8 @@ def test_translated_original_field_names_returns_registered_fields(): assert translated_original_field_names(Presentation) == {"title", "summary", "description"} -def test_translated_original_field_names_is_empty_for_unregistered_model(db): +def test_translated_original_field_names_is_empty_for_unregistered_model(): """번역 등록이 없는 모델은 빈 집합을 돌려준다.""" - from file.models import PublicFile - assert translated_original_field_names(PublicFile) == set() diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 02e17c8..99f93f9 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -256,7 +256,6 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s def translated_original_field_names(model_class: type[models.Model]) -> set[str]: """django-modeltranslation이 번역 대상으로 등록한 '원본' 필드명(예: nickname, title) 집합.""" try: - # TranslationOptions.fields는 버전에 따라 tuple 또는 dict이므로 set()으로 통일해 원본 필드명만 얻는다. return set(modeltranslation_translator.get_options_for_model(model_class).fields) except NotRegistered: return set() @@ -269,9 +268,7 @@ def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[m if not (model_instance := identifier_to_model(model_identifier)): raise ValueError(f"Model class not found for identifier: {model_identifier}") - # modeltranslation의 원본 필드(nickname 등)는 현재 활성 언어로 라우팅되는 descriptor다. - # diff를 만든 시점(요청자의 언어)과 적용하는 시점(승인자의 언어)이 다르면 엉뚱한 언어 컬럼을 - # 덮어쓰므로, 적용은 언어별 실제 컬럼(nickname_ko/nickname_en)으로만 수행한다. + # 원본 필드는 활성 언어로 라우팅되는 descriptor라 적용 시점의 언어에 좌우되므로, 언어별 컬럼만 적용한다. skip_fields = translated_original_field_names(type(model_instance)) # Apply the data to the model instance diff --git a/app/participant_portal_api/test/__init__.py b/app/participant_portal_api/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/participant_portal_api/test/modification_audit_approval_test.py b/app/participant_portal_api/test/modification_audit_approval_test.py index b510805..83cf8a3 100644 --- a/app/participant_portal_api/test/modification_audit_approval_test.py +++ b/app/participant_portal_api/test/modification_audit_approval_test.py @@ -1,9 +1,4 @@ -"""수정 심사 승인(approve) 시 변경 내역이 올바른 언어 컬럼에 반영되는지 검증한다. - -modeltranslation 의 원본 필드(nickname 등)는 '현재 활성 언어'로 라우팅되는 descriptor 다. -수정 요청을 만든 시점(요청자의 언어)과 승인 시점(승인자의 언어)이 다르면 -반대쪽 언어 컬럼을 덮어쓰던 버그가 있었다. -""" +"""수정 심사 승인 시 변경 내역이 승인자의 언어와 무관하게 올바른 언어 컬럼에 반영되는지 검증한다.""" from contextlib import contextmanager from types import SimpleNamespace From ce6d2535f5139cd145e1cf5ec260edfabadc4050 Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Sun, 26 Jul 2026 16:45:28 +0900 Subject: [PATCH 3/4] =?UTF-8?q?change:=20=EC=88=98=EC=A0=95=20=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EC=8A=B9=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A4=91=EB=B3=B5/=EB=AC=B4=EC=9D=98?= =?UTF-8?q?=EB=AF=B8=ED=95=9C=20=EC=BC=80=EC=9D=B4=EC=8A=A4=EC=99=80=20doc?= =?UTF-8?q?string=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수정 전에도 통과하던(= 이 버그를 잡지 못하는) 테스트 6건 제거하고, 회귀 감지에 필요한 4건만 남긴다. - 헬퍼 반환값만 단언하던 테스트 2건 제거 - 승인 후 상태 전이/타 언어 무관 반영 등 기존 동작 테스트 4건 제거 - 테스트 및 헬퍼 docstring 전부 제거 Signed-off-by: JaehyuckSa --- app/core/test/django_orm_test.py | 19 +------ app/core/util/django_orm.py | 1 - .../test/modification_audit_approval_test.py | 55 ------------------- 3 files changed, 1 insertion(+), 74 deletions(-) diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py index c593f0b..db00583 100644 --- a/app/core/test/django_orm_test.py +++ b/app/core/test/django_orm_test.py @@ -1,28 +1,12 @@ -"""core.util.django_orm 의 diff 적용 헬퍼 테스트.""" - import pytest -from core.util.django_orm import apply_diff_to_model, model_to_identifier, translated_original_field_names +from core.util.django_orm import apply_diff_to_model, model_to_identifier from django.utils.translation import override from event.presentation.models import Presentation -from file.models import PublicFile from model_bakery import baker -from user.models import UserExt - - -def test_translated_original_field_names_returns_registered_fields(): - """modeltranslation 에 등록된 원본 필드명을 돌려준다.""" - assert translated_original_field_names(UserExt) == {"nickname"} - assert translated_original_field_names(Presentation) == {"title", "summary", "description"} - - -def test_translated_original_field_names_is_empty_for_unregistered_model(): - """번역 등록이 없는 모델은 빈 집합을 돌려준다.""" - assert translated_original_field_names(PublicFile) == set() @pytest.mark.parametrize("active_language", ["ko", "en"], ids=["applied_in_ko", "applied_in_en"]) def test_apply_diff_to_model_ignores_translated_original_field(db, active_language): - """원본 필드(title)는 활성 언어에 따라 라우팅되므로 무시하고, 언어별 컬럼만 적용해야 한다.""" presentation = baker.make(Presentation, title_ko="한글제목", title_en="EnglishTitle") diff = {model_to_identifier(presentation): {"title": "NewEnglishTitle", "title_en": "NewEnglishTitle"}} @@ -35,7 +19,6 @@ def test_apply_diff_to_model_ignores_translated_original_field(db, active_langua def test_apply_diff_to_model_applies_plain_field(db): - """번역 대상이 아닌 필드는 그대로 적용한다.""" presentation = baker.make(Presentation, slideshow_url="https://example.com/old") diff = {model_to_identifier(presentation): {"slideshow_url": "https://example.com/new"}} diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 99f93f9..0198371 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -254,7 +254,6 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s def translated_original_field_names(model_class: type[models.Model]) -> set[str]: - """django-modeltranslation이 번역 대상으로 등록한 '원본' 필드명(예: nickname, title) 집합.""" try: return set(modeltranslation_translator.get_options_for_model(model_class).fields) except NotRegistered: diff --git a/app/participant_portal_api/test/modification_audit_approval_test.py b/app/participant_portal_api/test/modification_audit_approval_test.py index 83cf8a3..4fc41c2 100644 --- a/app/participant_portal_api/test/modification_audit_approval_test.py +++ b/app/participant_portal_api/test/modification_audit_approval_test.py @@ -1,13 +1,9 @@ -"""수정 심사 승인 시 변경 내역이 승인자의 언어와 무관하게 올바른 언어 컬럼에 반영되는지 검증한다.""" - from contextlib import contextmanager from types import SimpleNamespace import pytest from core.util.thread_local import thread_local -from django.core.files.base import ContentFile from django.utils.translation import override -from file.models import PublicFile from participant_portal_api.models import ModificationAudit from participant_portal_api.serializers.user import UserPortalSerializer from rest_framework import status @@ -19,7 +15,6 @@ @contextmanager def _acting_as(user: UserExt): - """created_by 등은 thread-local 에서 실행자를 읽으므로, 요청자를 명시적으로 주입한다.""" thread_local.current_request = SimpleNamespace(user=user) try: yield @@ -48,7 +43,6 @@ def admin_client(superuser) -> APIClient: def _request_profile_modification(requester: UserExt, payload: dict, language: str) -> ModificationAudit: - """참가자 포탈에서 주어진 언어로 프로필 수정을 요청하고, 생성된 심사 건을 돌려준다.""" with override(language), _acting_as(requester): serializer = UserPortalSerializer(instance=requester, data=payload, partial=True) assert serializer.is_valid(), serializer.errors @@ -58,7 +52,6 @@ def _request_profile_modification(requester: UserExt, payload: dict, language: s def test_approve_en_nickname_request_under_ko_locale_keeps_ko_nickname(requester, admin_client): - """영문 UI 에서 영문 닉네임만 고친 요청을, 어드민이 한국어 로케일로 승인해도 한글 닉네임은 그대로여야 한다.""" audit = _request_profile_modification(requester, {"nickname_en": "NewEnglish"}, language="en") response = admin_client.patch( @@ -72,7 +65,6 @@ def test_approve_en_nickname_request_under_ko_locale_keeps_ko_nickname(requester def test_approve_ko_nickname_request_under_en_locale_keeps_en_nickname(requester, admin_client): - """한국어 UI 에서 한글 닉네임만 고친 요청을, 어드민이 영문 로케일로 승인해도 영문 닉네임은 그대로여야 한다.""" audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") response = admin_client.patch( @@ -83,50 +75,3 @@ def test_approve_ko_nickname_request_under_en_locale_keeps_en_nickname(requester requester.refresh_from_db() assert requester.nickname_ko == "새한글이름" assert requester.nickname_en == "EnglishName" - - -@pytest.mark.parametrize("approval_language", ["ko", "en"], ids=["approved_in_ko", "approved_in_en"]) -def test_approve_applies_requested_nickname_regardless_of_approval_locale(requester, admin_client, approval_language): - """요청한 언어의 닉네임 변경 자체는 승인자의 로케일과 무관하게 반영되어야 한다.""" - audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") - - response = admin_client.patch( - APPROVE_URL.format(audit_id=audit.id), - data={}, - format="json", - HTTP_ACCEPT_LANGUAGE=approval_language, - ) - - assert response.status_code == status.HTTP_200_OK, response.data - requester.refresh_from_db() - assert requester.nickname_ko == "새한글이름" - - -def test_approve_applies_non_translated_field(requester, admin_client): - """번역 대상이 아닌 필드(프로필 이미지)는 기존대로 정상 반영되어야 한다.""" - with _acting_as(requester): - image = PublicFile.objects.create( - file=ContentFile(b"image-bytes", name="profile.png"), mimetype="image/png", hash="hash", size=11 - ) - - audit = _request_profile_modification(requester, {"image": image.pk}, language="ko") - - response = admin_client.patch( - APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="ko" - ) - - assert response.status_code == status.HTTP_200_OK, response.data - requester.refresh_from_db() - assert requester.image_id == image.pk - - -def test_approve_marks_audit_as_approved(requester, admin_client): - """승인이 끝나면 심사 상태가 approved 로 바뀌어야 한다.""" - audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") - - response = admin_client.patch(APPROVE_URL.format(audit_id=audit.id), data={}, format="json") - - assert response.status_code == status.HTTP_200_OK, response.data - assert response.data["status"] == ModificationAudit.Status.APPROVED - audit.refresh_from_db() - assert audit.status == ModificationAudit.Status.APPROVED From b159768fada205076b7d0430abcd9205317c37c2 Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Sun, 26 Jul 2026 16:48:47 +0900 Subject: [PATCH 4/4] =?UTF-8?q?change:=20translated=5Foriginal=5Ffield=5Fn?= =?UTF-8?q?ames=EB=A5=BC=20apply=5Fdiff=5Fto=5Fmodel=EC=97=90=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: JaehyuckSa --- app/core/util/django_orm.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 0198371..2af8691 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -253,13 +253,6 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s return resolved_models[key] -def translated_original_field_names(model_class: type[models.Model]) -> set[str]: - try: - return set(modeltranslation_translator.get_options_for_model(model_class).fields) - except NotRegistered: - return set() - - def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[models.Model]: result_instances: list[models.Model] = [] @@ -268,7 +261,10 @@ def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[m raise ValueError(f"Model class not found for identifier: {model_identifier}") # 원본 필드는 활성 언어로 라우팅되는 descriptor라 적용 시점의 언어에 좌우되므로, 언어별 컬럼만 적용한다. - skip_fields = translated_original_field_names(type(model_instance)) + try: + skip_fields = set(modeltranslation_translator.get_options_for_model(type(model_instance)).fields) + except NotRegistered: + skip_fields = set() # Apply the data to the model instance for field_name, value in model_data.items():