From b35585b7422c8f78768650a415ef4f8aecdb9d75 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 17 May 2026 20:53:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Django=20Allauth=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EA=B3=84=EC=A0=95=20=EA=B4=80=EB=A6=AC=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/socialaccount.py | 66 ++++ app/admin_api/serializers/user.py | 69 +++- app/admin_api/services/__init__.py | 0 app/admin_api/services/socialaccount.py | 14 + app/admin_api/test/socialaccount_test.py | 358 ++++++++++++++++++ app/admin_api/urls.py | 11 + app/admin_api/views/socialaccount.py | 51 +++ app/admin_api/views/user.py | 2 +- app/core/const/tag.py | 1 + app/core/serializer/lowercased_email_field.py | 6 + 10 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 app/admin_api/serializers/socialaccount.py create mode 100644 app/admin_api/services/__init__.py create mode 100644 app/admin_api/services/socialaccount.py create mode 100644 app/admin_api/test/socialaccount_test.py create mode 100644 app/admin_api/views/socialaccount.py create mode 100644 app/core/serializer/lowercased_email_field.py diff --git a/app/admin_api/serializers/socialaccount.py b/app/admin_api/serializers/socialaccount.py new file mode 100644 index 0000000..af9ac56 --- /dev/null +++ b/app/admin_api/serializers/socialaccount.py @@ -0,0 +1,66 @@ +from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount, SocialApp +from core.serializer.json_schema_serializer import JsonSchemaSerializer +from core.serializer.lowercased_email_field import LowercasedEmailField +from core.serializer.nested_model_serializer import NestedModelSerializer +from rest_framework import serializers + + +class SocialAppAdminSerializer(JsonSchemaSerializer, serializers.ModelSerializer): + str_repr = serializers.CharField(source="__str__", read_only=True) + + class Meta: + model = SocialApp + fields = ("id", "provider", "provider_id", "name", "client_id", "secret", "key", "settings", "str_repr") + read_only_fields = ("id",) + + +class SocialAccountAdminSerializer(JsonSchemaSerializer, serializers.ModelSerializer): + str_repr = serializers.CharField(source="__str__", read_only=True) + + class Meta: + model = SocialAccount + read_only_fields = fields = ( + "id", + "user", + "provider", + "uid", + "last_login", + "date_joined", + "extra_data", + "str_repr", + ) + + +class EmailAddressAdminSerializer(JsonSchemaSerializer, serializers.ModelSerializer): + str_repr = serializers.CharField(source="__str__", read_only=True) + email = LowercasedEmailField() + + class Meta: + model = EmailAddress + fields = ("id", "user", "email", "verified", "primary", "str_repr") + extra_kwargs = {"id": {"read_only": True}} + + +class EmailAddressNestedAdminSerializer(JsonSchemaSerializer, NestedModelSerializer): + id = serializers.IntegerField(required=False, help_text="기존 EmailAddress 수정 시 PK 전달, 새로 추가 시 생략") + email = LowercasedEmailField() + + class Meta: + model = EmailAddress + fields = ("id", "user", "email", "verified", "primary") + # user 는 NestedFieldSpec.parent_fk_name 으로 부모 인스턴스에서 주입되므로 입력 시 생략 가능. + extra_kwargs = {"user": {"required": False}} + # validators=[] — auto UniqueTogetherValidator(user, email) 가 user 누락 시 required 로 막음. + # DB unique constraint(account_emailaddress_user_id_email) 가 여전히 enforce. + validators: list = [] + + +class SocialAccountNestedAdminSerializer(JsonSchemaSerializer, NestedModelSerializer): + # delete-only nested — id 로 기존 row 매칭만 함. UserAdminSerializer.validate_social_accounts 가 정책 강제. + id = serializers.IntegerField(required=True) + + class Meta: + model = SocialAccount + read_only_fields = ("provider", "uid", "last_login", "date_joined", "extra_data") + fields = ("id",) + read_only_fields diff --git a/app/admin_api/serializers/user.py b/app/admin_api/serializers/user.py index 6b51b65..b63d549 100644 --- a/app/admin_api/serializers/user.py +++ b/app/admin_api/serializers/user.py @@ -1,18 +1,25 @@ import functools import typing +from admin_api.serializers.socialaccount import EmailAddressNestedAdminSerializer, SocialAccountNestedAdminSerializer +from admin_api.services.socialaccount import delete_social_accounts_and_cleanup_user_emails +from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount from core.const.account import generate_random_password from core.const.serializer import COMMON_ADMIN_FIELDS from core.serializer.base_abstract_serializer import BaseAbstractSerializer from core.serializer.json_schema_serializer import JsonSchemaSerializer +from core.serializer.nested_model_serializer import NestedFieldModelSerializer, NestedFieldSpec from core.serializer.read_only_serializer import ReadOnlyModelSerializer from rest_framework import serializers from user.models import UserExt from user.models.organization import Organization -class UserAdminSerializer(JsonSchemaSerializer, serializers.ModelSerializer): +class UserAdminSerializer(JsonSchemaSerializer, NestedFieldModelSerializer): str_repr = serializers.CharField(source="__str__", read_only=True) + email_addresses = EmailAddressNestedAdminSerializer(many=True, required=False, source="emailaddress_set") + social_accounts = SocialAccountNestedAdminSerializer(many=True, required=False, source="socialaccount_set") class Meta: model = UserExt @@ -28,17 +35,75 @@ class Meta: "str_repr", "date_joined", "last_login", + "email_addresses", + "social_accounts", ) extra_kwargs = { "id": {"read_only": True}, "date_joined": {"read_only": True}, "last_login": {"read_only": True}, } + nested_fields = { + "emailaddress_set": NestedFieldSpec( + related_manager_name="emailaddress_set", + child_model=EmailAddress, + parent_fk_name="user", + ), + "socialaccount_set": NestedFieldSpec( + related_manager_name="socialaccount_set", + child_model=SocialAccount, + parent_fk_name="user", + ), + } + + def validate(self, attrs: dict) -> dict: + # social_accounts=[] 는 마지막 SA cascade 를 트리거해 같은 user 의 EA 전체를 삭제함. + # 같은 PATCH 의 email_addresses 입력은 cascade 로 즉시 사라져 의도와 다른 결과가 되므로, + # 실제로 cascade 가 발생하는 경우(기존 SA 존재 + SA=[] + EA 입력 있음)에만 거부. + if ( + attrs.get("socialaccount_set") == [] + and attrs.get("emailaddress_set") + and self.instance is not None + and self.instance.socialaccount_set.all() + ): + msg = "모든 SocialAccount 를 제거하면 EmailAddress 도 cascade 로 삭제됩니다 — 같은 PATCH 에서 EmailAddress 를 함께 변경할 수 없습니다." + raise serializers.ValidationError(msg) + return attrs + + def validate_social_accounts(self, value: list[dict]) -> list[dict]: + # SocialAccount는 nested에서 delete-only — 모든 입력 id 가 이 유저의 기존 SA 와 매칭돼야 함. + # PATCH(partial=True) 에서는 nested 의 required 가 풀려 id 가 없을 수 있음 — 명시적으로 거부. + if any("id" not in item for item in value): + raise serializers.ValidationError("SocialAccount는 nested API에서 생성할 수 없습니다 (id 필수).") + provided_ids = {item["id"] for item in value} + # UserAdminViewSet 의 prefetch_related 캐시 활용. + existing_ids = {sa.id for sa in self.instance.socialaccount_set.all()} if self.instance else set() + if unknown := provided_ids - existing_ids: + msg = f"존재하지 않거나 이 유저의 것이 아닌 SocialAccount id: {sorted(map(str, unknown))}" + raise serializers.ValidationError(msg) + return value def create(self, validated_data: dict[str, typing.Any]) -> UserExt: password = generate_random_password() self._generated_password = password - return UserExt.objects.create_user(**validated_data, password=password) + nested_data = {k: validated_data.pop(k, []) or [] for k in self.Meta.nested_fields} + instance = UserExt.objects.create_user(**validated_data, password=password) + self._apply_nested_sync(instance, nested_data) + return instance + + def _apply_nested_sync(self, instance: UserExt, nested_data: dict[str, list[dict] | None]) -> None: + # SocialAccount는 delete-only — 기존 set 에서 input 에 없는 것만 삭제. + sa_data = nested_data.pop("socialaccount_set", None) + super()._apply_nested_sync(instance, nested_data) + + if sa_data is None: + return + + provided_ids = {item["id"] for item in sa_data} + # prefetch_related 캐시 활용. + existing_ids = {sa.id for sa in instance.socialaccount_set.all()} + if to_delete_ids := existing_ids - provided_ids: + delete_social_accounts_and_cleanup_user_emails(SocialAccount.objects.filter(id__in=to_delete_ids)) class UserAdminSignInSerializerData(typing.TypedDict): diff --git a/app/admin_api/services/__init__.py b/app/admin_api/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin_api/services/socialaccount.py b/app/admin_api/services/socialaccount.py new file mode 100644 index 0000000..30d174f --- /dev/null +++ b/app/admin_api/services/socialaccount.py @@ -0,0 +1,14 @@ +from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount +from django.db import transaction +from django.db.models import QuerySet + + +def delete_social_accounts_and_cleanup_user_emails(social_accounts: QuerySet[SocialAccount]) -> None: + with transaction.atomic(): + if not (affected_user_ids := set(social_accounts.values_list("user_id", flat=True))): + return + social_accounts.delete() + EmailAddress.objects.filter(user_id__in=affected_user_ids).exclude( + user_id__in=SocialAccount.objects.filter(user_id__in=affected_user_ids).values("user_id") + ).delete() diff --git a/app/admin_api/test/socialaccount_test.py b/app/admin_api/test/socialaccount_test.py new file mode 100644 index 0000000..0cffae8 --- /dev/null +++ b/app/admin_api/test/socialaccount_test.py @@ -0,0 +1,358 @@ +import http + +import pytest +from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount, SocialApp +from django.urls import reverse +from rest_framework.test import APIClient +from user.models import UserExt + +# ---- Fixtures --------------------------------------------------------------- + + +@pytest.fixture +def social_app(db) -> SocialApp: + return SocialApp.objects.create(provider="google", name="Google", client_id="cid", secret="sec") # nosec: B106 + + +@pytest.fixture +def regular_user(db) -> UserExt: + user = UserExt.objects.create_user(username="alice", email="alice@example.com", password="x") # nosec: B106 + SocialAccount.objects.create( + user=user, provider="google", uid="alice-google-1", extra_data={"email": "alice@example.com"} + ) + EmailAddress.objects.create(user=user, email="alice@example.com", verified=True, primary=True) + return user + + +@pytest.fixture +def multi_social_user(db) -> UserExt: + user = UserExt.objects.create_user(username="bob", email="bob@example.com", password="x") # nosec: B106 + SocialAccount.objects.create(user=user, provider="google", uid="bob-google-1", extra_data={}) + SocialAccount.objects.create(user=user, provider="kakao", uid="bob-kakao-1", extra_data={}) + EmailAddress.objects.create(user=user, email="bob@example.com", verified=True, primary=True) + return user + + +# ---- Auth ------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_unauthenticated_social_app_list_rejected(): + response = APIClient().get(reverse("v1:admin-social-app-list")) + assert response.status_code in (http.HTTPStatus.FORBIDDEN, http.HTTPStatus.UNAUTHORIZED) + + +@pytest.mark.django_db +def test_non_superuser_social_app_list_rejected(regular_user): + client = APIClient() + client.force_authenticate(user=regular_user) + response = client.get(reverse("v1:admin-social-app-list")) + assert response.status_code == http.HTTPStatus.FORBIDDEN + + +@pytest.mark.django_db +def test_non_superuser_social_account_list_rejected(regular_user): + client = APIClient() + client.force_authenticate(user=regular_user) + response = client.get(reverse("v1:admin-social-account-list")) + assert response.status_code == http.HTTPStatus.FORBIDDEN + + +@pytest.mark.django_db +def test_non_superuser_email_address_list_rejected(regular_user): + client = APIClient() + client.force_authenticate(user=regular_user) + response = client.get(reverse("v1:admin-email-address-list")) + assert response.status_code == http.HTTPStatus.FORBIDDEN + + +# ---- SocialApp CRUD --------------------------------------------------------- + + +@pytest.mark.django_db +def test_social_app_list(api_client, social_app): + response = api_client.get(reverse("v1:admin-social-app-list")) + assert response.status_code == http.HTTPStatus.OK + rows = response.json() + assert any(row["id"] == social_app.id for row in rows) + + +@pytest.mark.django_db +def test_social_app_retrieve(api_client, social_app): + response = api_client.get(reverse("v1:admin-social-app-detail", kwargs={"pk": social_app.id})) + assert response.status_code == http.HTTPStatus.OK + body = response.json() + assert body["provider"] == "google" + # secret 은 마스킹 없이 평문 노출. + assert body["secret"] == "sec" + + +@pytest.mark.django_db +def test_social_app_create(api_client): + response = api_client.post( + reverse("v1:admin-social-app-list"), + data={ + "provider": "kakao", + "name": "Kakao", + "client_id": "kid", + "secret": "ksec", + }, + format="json", + ) + assert response.status_code == http.HTTPStatus.CREATED, response.json() + assert SocialApp.objects.filter(provider="kakao", name="Kakao").exists() + + +@pytest.mark.django_db +def test_social_app_partial_update(api_client, social_app): + response = api_client.patch( + reverse("v1:admin-social-app-detail", kwargs={"pk": social_app.id}), + data={"name": "Google Renamed"}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + social_app.refresh_from_db() + assert social_app.name == "Google Renamed" + + +@pytest.mark.django_db +def test_social_app_destroy(api_client, social_app): + response = api_client.delete(reverse("v1:admin-social-app-detail", kwargs={"pk": social_app.id})) + assert response.status_code == http.HTTPStatus.NO_CONTENT + assert not SocialApp.objects.filter(pk=social_app.id).exists() + + +# ---- SocialAccount List / Retrieve / Destroy -------------------------------- + + +@pytest.mark.django_db +def test_social_account_list_filter_by_user(api_client, regular_user, multi_social_user): + response = api_client.get(reverse("v1:admin-social-account-list"), {"user": str(regular_user.id)}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json() + assert {row["uid"] for row in rows} == {"alice-google-1"} + + +@pytest.mark.django_db +def test_social_account_no_create_endpoint(api_client, regular_user): + response = api_client.post( + reverse("v1:admin-social-account-list"), + data={"user": regular_user.id, "provider": "naver", "uid": "x"}, + format="json", + ) + assert response.status_code == http.HTTPStatus.METHOD_NOT_ALLOWED + + +@pytest.mark.django_db +def test_social_account_destroy_with_multiple_socials_preserves_emails(api_client, multi_social_user): + sa = SocialAccount.objects.get(user=multi_social_user, provider="google") + response = api_client.delete(reverse("v1:admin-social-account-detail", kwargs={"pk": sa.id})) + assert response.status_code == http.HTTPStatus.NO_CONTENT + # 다른 SA 남아있으므로 EA 는 보존. + assert SocialAccount.objects.filter(user=multi_social_user).count() == 1 + assert EmailAddress.objects.filter(user=multi_social_user).count() == 1 + + +@pytest.mark.django_db +def test_social_account_destroy_last_social_cascades_to_emails(api_client, regular_user): + sa = SocialAccount.objects.get(user=regular_user) + response = api_client.delete(reverse("v1:admin-social-account-detail", kwargs={"pk": sa.id})) + assert response.status_code == http.HTTPStatus.NO_CONTENT + # 마지막 SA 였으므로 같은 user 의 EA 모두 삭제. + assert SocialAccount.objects.filter(user=regular_user).count() == 0 + assert EmailAddress.objects.filter(user=regular_user).count() == 0 + + +# ---- EmailAddress CRUD ------------------------------------------------------ + + +@pytest.mark.django_db +def test_email_address_list_filter_by_user(api_client, regular_user): + response = api_client.get(reverse("v1:admin-email-address-list"), {"user": str(regular_user.id)}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json() + assert {row["email"] for row in rows} == {"alice@example.com"} + + +@pytest.mark.django_db +def test_email_address_create_lowercases_email(api_client, regular_user): + response = api_client.post( + reverse("v1:admin-email-address-list"), + data={"user": regular_user.id, "email": "Alice+Alt@Example.com", "verified": False, "primary": False}, + format="json", + ) + assert response.status_code == http.HTTPStatus.CREATED, response.json() + assert EmailAddress.objects.filter(user=regular_user, email="alice+alt@example.com").exists() + + +@pytest.mark.django_db +def test_email_address_partial_update_toggle_verified(api_client, regular_user): + ea = EmailAddress.objects.create(user=regular_user, email="alt@example.com", verified=False, primary=False) + response = api_client.patch( + reverse("v1:admin-email-address-detail", kwargs={"pk": ea.id}), + data={"verified": True}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + ea.refresh_from_db() + assert ea.verified is True + + +@pytest.mark.django_db +def test_email_address_destroy(api_client, regular_user): + ea = EmailAddress.objects.create(user=regular_user, email="alt@example.com", verified=False, primary=False) + response = api_client.delete(reverse("v1:admin-email-address-detail", kwargs={"pk": ea.id})) + assert response.status_code == http.HTTPStatus.NO_CONTENT + assert not EmailAddress.objects.filter(pk=ea.id).exists() + + +# ---- Nested via UserExt ----------------------------------------------------- + + +@pytest.mark.django_db +def test_user_retrieve_exposes_nested_collections(api_client, regular_user): + response = api_client.get(reverse("v1:admin-user-detail", kwargs={"pk": regular_user.id})) + assert response.status_code == http.HTTPStatus.OK + body = response.json() + assert "email_addresses" in body + assert "social_accounts" in body + assert {ea["email"] for ea in body["email_addresses"]} == {"alice@example.com"} + assert {sa["uid"] for sa in body["social_accounts"]} == {"alice-google-1"} + + +@pytest.mark.django_db +def test_user_patch_email_addresses_add_update_remove(api_client, regular_user): + existing_ea = EmailAddress.objects.get(user=regular_user) + response = api_client.patch( + reverse("v1:admin-user-detail", kwargs={"pk": regular_user.id}), + data={ + "email_addresses": [ + # 기존 EA 의 verified 토글 + {"id": str(existing_ea.id), "email": existing_ea.email, "verified": False, "primary": True}, + # 새 EA 추가 + {"email": "alice+new@example.com", "verified": False, "primary": False}, + ], + }, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + existing_ea.refresh_from_db() + assert existing_ea.verified is False + assert EmailAddress.objects.filter(user=regular_user, email="alice+new@example.com").exists() + + +@pytest.mark.django_db +def test_user_patch_email_addresses_replace_with_subset(api_client, regular_user): + extra = EmailAddress.objects.create(user=regular_user, email="alt@example.com", verified=False, primary=False) + primary = EmailAddress.objects.get(user=regular_user, primary=True) + response = api_client.patch( + reverse("v1:admin-user-detail", kwargs={"pk": regular_user.id}), + data={ + "email_addresses": [ + {"id": str(primary.id), "email": primary.email, "verified": True, "primary": True}, + ], + }, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + assert not EmailAddress.objects.filter(pk=extra.id).exists() + assert EmailAddress.objects.filter(pk=primary.id).exists() + + +@pytest.mark.django_db +def test_user_patch_remove_social_account_with_other_remaining_preserves_emails(api_client, multi_social_user): + keep = SocialAccount.objects.get(user=multi_social_user, provider="kakao") + response = api_client.patch( + reverse("v1:admin-user-detail", kwargs={"pk": multi_social_user.id}), + data={"social_accounts": [{"id": str(keep.id)}]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + assert SocialAccount.objects.filter(user=multi_social_user).count() == 1 + assert EmailAddress.objects.filter(user=multi_social_user).count() == 1 + + +@pytest.mark.django_db +def test_user_patch_remove_last_social_account_cascades_to_emails(api_client, regular_user): + response = api_client.patch( + reverse("v1:admin-user-detail", kwargs={"pk": regular_user.id}), + data={"social_accounts": []}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + assert SocialAccount.objects.filter(user=regular_user).count() == 0 + assert EmailAddress.objects.filter(user=regular_user).count() == 0 + + +@pytest.mark.django_db +def test_user_patch_social_accounts_with_other_users_sa_rejected(api_client, regular_user, multi_social_user): + # 다른 user 의 SocialAccount id 를 보내면 ownership 검증으로 거부. + other_sa = SocialAccount.objects.filter(user=multi_social_user).first() + response = api_client.patch( + reverse("v1:admin-user-detail", kwargs={"pk": regular_user.id}), + data={"social_accounts": [{"id": other_sa.id}]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + # 거부됐으므로 양쪽 user 의 기존 데이터 유지. + assert SocialAccount.objects.filter(user=regular_user).count() == 1 + assert EmailAddress.objects.filter(user=regular_user).count() == 1 + assert SocialAccount.objects.filter(user=multi_social_user).count() == 2 + + +@pytest.mark.django_db +def test_user_patch_social_accounts_create_attempt_rejected(api_client, regular_user): + # id 누락 → DRF UUIDField required 로 거부 + response = api_client.patch( + reverse("v1:admin-user-detail", kwargs={"pk": regular_user.id}), + data={"social_accounts": [{"provider": "naver", "uid": "x"}]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert SocialAccount.objects.filter(user=regular_user).count() == 1 + + +@pytest.mark.django_db +def test_user_patch_social_accounts_ignores_readonly_field_changes(api_client, regular_user): + sa = SocialAccount.objects.get(user=regular_user) + original_uid = sa.uid + response = api_client.patch( + reverse("v1:admin-user-detail", kwargs={"pk": regular_user.id}), + data={"social_accounts": [{"id": str(sa.id), "uid": "changed-uid", "extra_data": {"x": 1}}]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + sa.refresh_from_db() + assert sa.uid == original_uid # read_only 라 변경 무시 + + +@pytest.mark.django_db +def test_user_patch_clear_sa_with_new_email_addresses_rejected(api_client, regular_user): + # SA=[] cascade 가 EA 도 즉시 삭제하므로, 새 EA 입력과 같은 PATCH 로 묶이면 거부. + response = api_client.patch( + reverse("v1:admin-user-detail", kwargs={"pk": regular_user.id}), + data={ + "social_accounts": [], + "email_addresses": [{"email": "alice+new@example.com", "verified": False, "primary": False}], + }, + format="json", + ) + assert response.status_code == http.HTTPStatus.BAD_REQUEST, response.json() + # 거부됐으므로 기존 SA/EA 유지, 새 EA 도 생성되지 않음. + assert SocialAccount.objects.filter(user=regular_user).count() == 1 + assert EmailAddress.objects.filter(user=regular_user).count() == 1 + assert not EmailAddress.objects.filter(email="alice+new@example.com").exists() + + +@pytest.mark.django_db +def test_user_patch_clear_sa_with_empty_email_addresses_allowed(api_client, regular_user): + # 두 컬렉션 모두 빈 리스트는 "전부 정리" 의도가 명확 — 허용. + response = api_client.patch( + reverse("v1:admin-user-detail", kwargs={"pk": regular_user.id}), + data={"social_accounts": [], "email_addresses": []}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + assert SocialAccount.objects.filter(user=regular_user).count() == 0 + assert EmailAddress.objects.filter(user=regular_user).count() == 0 diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py index e151483..6ecee4e 100644 --- a/app/admin_api/urls.py +++ b/app/admin_api/urls.py @@ -29,6 +29,11 @@ TagAdminViewSet, ) from admin_api.views.shop.refund_authorizer import RefundAuthorizerAdminViewSet +from admin_api.views.socialaccount import ( + EmailAddressAdminViewSet, + SocialAccountAdminViewSet, + SocialAppAdminViewSet, +) from admin_api.views.user import OrganizationAdminViewSet, UserAdminViewSet from django.urls import include, path from rest_framework import routers @@ -104,6 +109,11 @@ admin_shop_router.register("option-groups", OptionGroupAdminViewSet, basename="admin-shop-option-group") admin_shop_router.register("refund-authorizer", RefundAuthorizerAdminViewSet, basename="admin-shop-refund-authorizer") +admin_allauth_router = routers.SimpleRouter() +admin_allauth_router.register("social-app", SocialAppAdminViewSet, basename="admin-social-app") +admin_allauth_router.register("social-account", SocialAccountAdminViewSet, basename="admin-social-account") +admin_allauth_router.register("email-address", EmailAddressAdminViewSet, basename="admin-email-address") + urlpatterns = [ path("cms/", include(admin_cms_router.urls)), path("file/", include(admin_file_router.urls)), @@ -115,4 +125,5 @@ path("notification/sms/", include(admin_notification_sms_router.urls)), path("external-api/google/", include(admin_external_api_google_router.urls)), path("shop/", include(admin_shop_router.urls)), + path("allauth/", include(admin_allauth_router.urls)), ] diff --git a/app/admin_api/views/socialaccount.py b/app/admin_api/views/socialaccount.py new file mode 100644 index 0000000..1305ac8 --- /dev/null +++ b/app/admin_api/views/socialaccount.py @@ -0,0 +1,51 @@ +from admin_api.serializers.socialaccount import ( + EmailAddressAdminSerializer, + SocialAccountAdminSerializer, + SocialAppAdminSerializer, +) +from admin_api.services.socialaccount import delete_social_accounts_and_cleanup_user_emails +from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount, SocialApp +from core.authz import IsSuperUser +from core.const.tag import OpenAPITag +from core.viewset.json_schema_viewset import JsonSchemaViewSet +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import mixins, viewsets + +DESTROY_ONLY_METHODS = ["list", "retrieve", "destroy"] +ADMIN_METHODS = DESTROY_ONLY_METHODS + ["create", "partial_update"] + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_ALLAUTH]) for m in ADMIN_METHODS}) +class SocialAppAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + permission_classes = [IsSuperUser] + serializer_class = SocialAppAdminSerializer + queryset = SocialApp.objects.all() + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_ALLAUTH]) for m in DESTROY_ONLY_METHODS}) +class SocialAccountAdminViewSet( + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + JsonSchemaViewSet, + viewsets.GenericViewSet, +): + http_method_names = ["get", "delete"] + permission_classes = [IsSuperUser] + serializer_class = SocialAccountAdminSerializer + queryset = SocialAccount.objects.all().select_related("user") + filterset_fields = ["user"] + + def perform_destroy(self, instance: SocialAccount) -> None: + delete_social_accounts_and_cleanup_user_emails(SocialAccount.objects.filter(pk=instance.pk)) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_ALLAUTH]) for m in ADMIN_METHODS}) +class EmailAddressAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = EmailAddressAdminSerializer + permission_classes = [IsSuperUser] + queryset = EmailAddress.objects.all().select_related("user") + filterset_fields = ["user"] diff --git a/app/admin_api/views/user.py b/app/admin_api/views/user.py index 26be40f..2a2ea27 100644 --- a/app/admin_api/views/user.py +++ b/app/admin_api/views/user.py @@ -31,7 +31,7 @@ class UserAdminViewSet( http_method_names = ["get", "post", "patch", "delete"] serializer_class = UserAdminSerializer permission_classes = [IsSuperUser] - queryset = UserExt.objects.filter(is_active=True) + queryset = UserExt.objects.filter(is_active=True).prefetch_related("emailaddress_set", "socialaccount_set") def create(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: serializer = self.get_serializer(data=request.data) diff --git a/app/core/const/tag.py b/app/core/const/tag.py index 9968341..2730511 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -12,6 +12,7 @@ class OpenAPITag: ADMIN_ACCOUNT = "Admin > Sign-In & Sign-Out" ADMIN_USER = "Admin > User" + ADMIN_ALLAUTH = "Admin > Allauth" ADMIN_CMS = "Admin > CMS" ADMIN_PUBLIC_FILE = "Admin > Public File" ADMIN_EVENT_EVENT = "Admin > Event > Event" diff --git a/app/core/serializer/lowercased_email_field.py b/app/core/serializer/lowercased_email_field.py new file mode 100644 index 0000000..9eefbf3 --- /dev/null +++ b/app/core/serializer/lowercased_email_field.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class LowercasedEmailField(serializers.EmailField): + def to_internal_value(self, data: str) -> str: + return super().to_internal_value(data).lower() From 6771a28d897608a580fd4c86558926dce718e11e Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 17 May 2026 23:27:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20Django-Allauth=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=96=B4=EB=93=9C=EB=AF=BC=EB=93=A4=EC=97=90=20pagination?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/test/socialaccount_test.py | 6 +++--- app/admin_api/views/socialaccount.py | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/admin_api/test/socialaccount_test.py b/app/admin_api/test/socialaccount_test.py index 0cffae8..05ae8c9 100644 --- a/app/admin_api/test/socialaccount_test.py +++ b/app/admin_api/test/socialaccount_test.py @@ -74,7 +74,7 @@ def test_non_superuser_email_address_list_rejected(regular_user): def test_social_app_list(api_client, social_app): response = api_client.get(reverse("v1:admin-social-app-list")) assert response.status_code == http.HTTPStatus.OK - rows = response.json() + rows = response.json()["results"] assert any(row["id"] == social_app.id for row in rows) @@ -130,7 +130,7 @@ def test_social_app_destroy(api_client, social_app): def test_social_account_list_filter_by_user(api_client, regular_user, multi_social_user): response = api_client.get(reverse("v1:admin-social-account-list"), {"user": str(regular_user.id)}) assert response.status_code == http.HTTPStatus.OK - rows = response.json() + rows = response.json()["results"] assert {row["uid"] for row in rows} == {"alice-google-1"} @@ -171,7 +171,7 @@ def test_social_account_destroy_last_social_cascades_to_emails(api_client, regul def test_email_address_list_filter_by_user(api_client, regular_user): response = api_client.get(reverse("v1:admin-email-address-list"), {"user": str(regular_user.id)}) assert response.status_code == http.HTTPStatus.OK - rows = response.json() + rows = response.json()["results"] assert {row["email"] for row in rows} == {"alice@example.com"} diff --git a/app/admin_api/views/socialaccount.py b/app/admin_api/views/socialaccount.py index 1305ac8..2970d7f 100644 --- a/app/admin_api/views/socialaccount.py +++ b/app/admin_api/views/socialaccount.py @@ -8,6 +8,7 @@ from allauth.socialaccount.models import SocialAccount, SocialApp from core.authz import IsSuperUser from core.const.tag import OpenAPITag +from core.pagination import AdminPagination from core.viewset.json_schema_viewset import JsonSchemaViewSet from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import mixins, viewsets @@ -20,8 +21,9 @@ class SocialAppAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): http_method_names = ["get", "post", "patch", "delete"] permission_classes = [IsSuperUser] + pagination_class = AdminPagination serializer_class = SocialAppAdminSerializer - queryset = SocialApp.objects.all() + queryset = SocialApp.objects.all().order_by("provider", "id") @extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_ALLAUTH]) for m in DESTROY_ONLY_METHODS}) @@ -34,8 +36,9 @@ class SocialAccountAdminViewSet( ): http_method_names = ["get", "delete"] permission_classes = [IsSuperUser] + pagination_class = AdminPagination serializer_class = SocialAccountAdminSerializer - queryset = SocialAccount.objects.all().select_related("user") + queryset = SocialAccount.objects.all().select_related("user").order_by("-date_joined", "-id") filterset_fields = ["user"] def perform_destroy(self, instance: SocialAccount) -> None: @@ -47,5 +50,6 @@ class EmailAddressAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): http_method_names = ["get", "post", "patch", "delete"] serializer_class = EmailAddressAdminSerializer permission_classes = [IsSuperUser] - queryset = EmailAddress.objects.all().select_related("user") + pagination_class = AdminPagination + queryset = EmailAddress.objects.all().select_related("user").order_by("-id") filterset_fields = ["user"] From ea675ff38fb365421fe988ed90e0d2cea6462703 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Mon, 18 May 2026 00:05:58 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20Allauth=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20API=EB=93=A4=EC=97=90=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/filtersets/socialaccount.py | 64 ++++++++++++ app/admin_api/test/socialaccount_test.py | 118 ++++++++++++++++++++++ app/admin_api/views/socialaccount.py | 5 +- app/core/openapi/filter_extension.py | 22 ++++ app/core/openapi/schemas.py | 1 + 5 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 app/admin_api/filtersets/socialaccount.py create mode 100644 app/core/openapi/filter_extension.py diff --git a/app/admin_api/filtersets/socialaccount.py b/app/admin_api/filtersets/socialaccount.py new file mode 100644 index 0000000..9eb6780 --- /dev/null +++ b/app/admin_api/filtersets/socialaccount.py @@ -0,0 +1,64 @@ +from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount, SocialApp +from core.filter.multi_field import MultiFieldOrCharInFilter +from django_filters import rest_framework as filters + + +def _social_app_provider_choices() -> list[tuple[str, str]]: + """SocialApp 에 등록된 provider 만 허용. callable 로 두어 매 요청마다 최신 DB 상태를 반영.""" + return [(p, p) for p in SocialApp.objects.values_list("provider", flat=True).distinct().order_by("provider")] + + +class SocialAppProviderInFilter(filters.BaseInFilter, filters.ChoiceFilter): + """CSV 다중값 입력 + SocialApp.provider 화이트리스트 검증 + `__in` 매칭. 각 값을 단일 choice 로 검증. + + `expose_callable_choices_in_schema` 는 `core.openapi.filter_extension.DjangoFilterExtension` 가 + 스키마 생성 시 callable choices 를 한 번 호출하도록 opt-in. + """ + + expose_callable_choices_in_schema = True + + +class SocialAccountAdminFilterSet(filters.FilterSet): + """admin 운영자 검색. provider 는 SocialApp 에 등록된 값만 허용 (`?provider=google,kakao`).""" + + provider = SocialAppProviderInFilter(field_name="provider", choices=_social_app_provider_choices) + uid = filters.CharFilter(field_name="uid", lookup_expr="icontains") + user_email = filters.CharFilter(field_name="user__email", lookup_expr="icontains") + user_username = filters.CharFilter(field_name="user__username", lookup_expr="icontains") + + date_joined_after = filters.DateTimeFilter(field_name="date_joined", lookup_expr="gte") + date_joined_before = filters.DateTimeFilter(field_name="date_joined", lookup_expr="lte") + last_login_after = filters.DateTimeFilter(field_name="last_login", lookup_expr="gte") + last_login_before = filters.DateTimeFilter(field_name="last_login", lookup_expr="lte") + + class Meta: + model = SocialAccount + fields = [ + "user", + "provider", + "uid", + "user_email", + "user_username", + "date_joined_after", + "date_joined_before", + "last_login_after", + "last_login_before", + ] + + +class EmailAddressAdminFilterSet(filters.FilterSet): + """`email` 은 EmailAddress.email 과 User.email 양쪽을 OR 매칭. CSV 다중값 지원.""" + + email = MultiFieldOrCharInFilter(field_names=["email", "user__email"], lookup_expr="icontains") + user_username = filters.CharFilter(field_name="user__username", lookup_expr="icontains") + + class Meta: + model = EmailAddress + fields = [ + "user", + "email", + "verified", + "primary", + "user_username", + ] diff --git a/app/admin_api/test/socialaccount_test.py b/app/admin_api/test/socialaccount_test.py index 05ae8c9..4d7101e 100644 --- a/app/admin_api/test/socialaccount_test.py +++ b/app/admin_api/test/socialaccount_test.py @@ -1,6 +1,7 @@ import http import pytest +import yaml from allauth.account.models import EmailAddress from allauth.socialaccount.models import SocialAccount, SocialApp from django.urls import reverse @@ -134,6 +135,74 @@ def test_social_account_list_filter_by_user(api_client, regular_user, multi_soci assert {row["uid"] for row in rows} == {"alice-google-1"} +@pytest.mark.django_db +def test_social_account_list_filter_by_provider_csv(api_client, regular_user, multi_social_user): + # provider choices 는 SocialApp 등록값 기반 — alice=google, bob=google+kakao 모두 매치되려면 + # google/kakao 두 SocialApp 모두 등록되어 있어야 함. + SocialApp.objects.get_or_create(provider="google", defaults={"name": "Google", "client_id": "g", "secret": "g"}) + SocialApp.objects.get_or_create(provider="kakao", defaults={"name": "Kakao", "client_id": "k", "secret": "k"}) + + response = api_client.get(reverse("v1:admin-social-account-list"), {"provider": "google,kakao"}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json()["results"] + assert {row["uid"] for row in rows} == {"alice-google-1", "bob-google-1", "bob-kakao-1"} + + +@pytest.mark.django_db +def test_social_account_list_filter_by_provider_unknown_rejected(api_client, regular_user, social_app): + # social_app fixture 가 google 만 등록 — kakao 는 SocialApp 에 없으므로 거부. + response = api_client.get(reverse("v1:admin-social-account-list"), {"provider": "kakao"}) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + +@pytest.mark.django_db +def test_social_account_provider_filter_enum_exposed_in_openapi(api_client): + # callable choices 를 OpenAPI 스키마에 enum 으로 노출 (core.openapi.filter_extension). + SocialApp.objects.get_or_create(provider="google", defaults={"name": "G", "client_id": "g", "secret": "g"}) + SocialApp.objects.get_or_create(provider="kakao", defaults={"name": "K", "client_id": "k", "secret": "k"}) + + response = APIClient().get("/api/schema/v1/") + assert response.status_code == http.HTTPStatus.OK + schema = yaml.safe_load(response.content) + + list_path = next(p for p in schema["paths"] if p.endswith("/allauth/social-account/")) + params = schema["paths"][list_path]["get"]["parameters"] + provider = next(p for p in params if p["name"] == "provider") + assert provider["schema"]["items"]["enum"] == ["google", "kakao"] + + +@pytest.mark.django_db +def test_social_account_list_filter_by_uid_icontains(api_client, regular_user, multi_social_user): + response = api_client.get(reverse("v1:admin-social-account-list"), {"uid": "kakao"}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json()["results"] + assert {row["uid"] for row in rows} == {"bob-kakao-1"} + + +@pytest.mark.django_db +def test_social_account_list_filter_by_user_email_and_username(api_client, regular_user, multi_social_user): + response = api_client.get(reverse("v1:admin-social-account-list"), {"user_email": "alice@"}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json()["results"] + assert {row["uid"] for row in rows} == {"alice-google-1"} + + response = api_client.get(reverse("v1:admin-social-account-list"), {"user_username": "bob"}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json()["results"] + assert {row["uid"] for row in rows} == {"bob-google-1", "bob-kakao-1"} + + +@pytest.mark.django_db +def test_social_account_list_filter_by_date_joined_range(api_client, regular_user, multi_social_user): + import datetime as _dt + + # 미래 시점 _after 는 결과 0 건. + future = (_dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(days=1)).isoformat() + response = api_client.get(reverse("v1:admin-social-account-list"), {"date_joined_after": future}) + assert response.status_code == http.HTTPStatus.OK + assert response.json()["results"] == [] + + @pytest.mark.django_db def test_social_account_no_create_endpoint(api_client, regular_user): response = api_client.post( @@ -175,6 +244,55 @@ def test_email_address_list_filter_by_user(api_client, regular_user): assert {row["email"] for row in rows} == {"alice@example.com"} +@pytest.mark.django_db +def test_email_address_list_filter_by_email_matches_emailaddress(api_client, regular_user, multi_social_user): + # EmailAddress.email substring 매칭. + response = api_client.get(reverse("v1:admin-email-address-list"), {"email": "alice@"}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json()["results"] + assert {row["email"] for row in rows} == {"alice@example.com"} + + +@pytest.mark.django_db +def test_email_address_list_filter_by_email_matches_user_email(api_client, regular_user): + # EA.email 은 alt 이지만 user.email 은 alice 라서 ?email=alice 로도 잡혀야 한다. + EmailAddress.objects.create(user=regular_user, email="alt@example.com", verified=False, primary=False) + response = api_client.get(reverse("v1:admin-email-address-list"), {"email": "alice@"}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json()["results"] + # alice 의 primary EA + alt EA (User.email join 으로 매칭) 모두 포함. + assert {row["email"] for row in rows} == {"alice@example.com", "alt@example.com"} + + +@pytest.mark.django_db +def test_email_address_list_filter_by_email_csv(api_client, regular_user, multi_social_user): + response = api_client.get(reverse("v1:admin-email-address-list"), {"email": "alice@,bob@"}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json()["results"] + assert {row["email"] for row in rows} == {"alice@example.com", "bob@example.com"} + + +@pytest.mark.django_db +def test_email_address_list_filter_by_verified_and_primary(api_client, regular_user): + EmailAddress.objects.create(user=regular_user, email="alt@example.com", verified=False, primary=False) + # verified=false + response = api_client.get(reverse("v1:admin-email-address-list"), {"verified": "false"}) + assert response.status_code == http.HTTPStatus.OK + assert {row["email"] for row in response.json()["results"]} == {"alt@example.com"} + # primary=true + response = api_client.get(reverse("v1:admin-email-address-list"), {"primary": "true"}) + assert response.status_code == http.HTTPStatus.OK + assert {row["email"] for row in response.json()["results"]} == {"alice@example.com"} + + +@pytest.mark.django_db +def test_email_address_list_filter_by_user_username(api_client, regular_user, multi_social_user): + response = api_client.get(reverse("v1:admin-email-address-list"), {"user_username": "bob"}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json()["results"] + assert {row["email"] for row in rows} == {"bob@example.com"} + + @pytest.mark.django_db def test_email_address_create_lowercases_email(api_client, regular_user): response = api_client.post( diff --git a/app/admin_api/views/socialaccount.py b/app/admin_api/views/socialaccount.py index 2970d7f..b9d65cc 100644 --- a/app/admin_api/views/socialaccount.py +++ b/app/admin_api/views/socialaccount.py @@ -1,3 +1,4 @@ +from admin_api.filtersets.socialaccount import EmailAddressAdminFilterSet, SocialAccountAdminFilterSet from admin_api.serializers.socialaccount import ( EmailAddressAdminSerializer, SocialAccountAdminSerializer, @@ -39,7 +40,7 @@ class SocialAccountAdminViewSet( pagination_class = AdminPagination serializer_class = SocialAccountAdminSerializer queryset = SocialAccount.objects.all().select_related("user").order_by("-date_joined", "-id") - filterset_fields = ["user"] + filterset_class = SocialAccountAdminFilterSet def perform_destroy(self, instance: SocialAccount) -> None: delete_social_accounts_and_cleanup_user_emails(SocialAccount.objects.filter(pk=instance.pk)) @@ -52,4 +53,4 @@ class EmailAddressAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): permission_classes = [IsSuperUser] pagination_class = AdminPagination queryset = EmailAddress.objects.all().select_related("user").order_by("-id") - filterset_fields = ["user"] + filterset_class = EmailAddressAdminFilterSet diff --git a/app/core/openapi/filter_extension.py b/app/core/openapi/filter_extension.py new file mode 100644 index 0000000..26d4b1a --- /dev/null +++ b/app/core/openapi/filter_extension.py @@ -0,0 +1,22 @@ +from drf_spectacular.contrib.django_filters import DjangoFilterExtension as _BaseDjangoFilterExtension + + +class DjangoFilterExtension(_BaseDjangoFilterExtension): + """기본 확장은 DB 비용 우려로 callable `choices` 를 무시한다. + + Filter 클래스에 `expose_callable_choices_in_schema = True` 를 붙이면 스키마 생성 시 + 한 번만 호출해 enum 으로 노출. 런타임 dynamism 은 유지하면서 OpenAPI 문서에도 + 선택지를 드러내고 싶을 때 사용. + """ + + priority = 1 + + def _get_explicit_filter_choices(self, filter_field): # type: ignore[no-untyped-def] + choices = filter_field.extra.get("choices") + if callable(choices) and getattr(filter_field, "expose_callable_choices_in_schema", False): + try: + resolved = list(choices()) + except Exception: + return [] + return [c for c, _ in resolved] + return super()._get_explicit_filter_choices(filter_field) diff --git a/app/core/openapi/schemas.py b/app/core/openapi/schemas.py index 3724853..05f4c77 100644 --- a/app/core/openapi/schemas.py +++ b/app/core/openapi/schemas.py @@ -1,3 +1,4 @@ +from core.openapi.filter_extension import DjangoFilterExtension # noqa: F401 drf-spectacular 확장 등록 from drf_spectacular.openapi import AutoSchema, OpenApiExample, OpenApiResponse from drf_spectacular.utils import OpenApiParameter from rest_framework import status