Skip to content

Commit a18a2fa

Browse files
committed
Merge remote-tracking branch 'origin/feature/add-admin-api-for-django-allauth-socials'
2 parents f0b0e55 + 4ee519a commit a18a2fa

13 files changed

Lines changed: 785 additions & 3 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from allauth.account.models import EmailAddress
2+
from allauth.socialaccount.models import SocialAccount, SocialApp
3+
from core.filter.multi_field import MultiFieldOrCharInFilter
4+
from django_filters import rest_framework as filters
5+
6+
7+
def _social_app_provider_choices() -> list[tuple[str, str]]:
8+
"""SocialApp 에 등록된 provider 만 허용. callable 로 두어 매 요청마다 최신 DB 상태를 반영."""
9+
return [(p, p) for p in SocialApp.objects.values_list("provider", flat=True).distinct().order_by("provider")]
10+
11+
12+
class SocialAppProviderInFilter(filters.BaseInFilter, filters.ChoiceFilter):
13+
"""CSV 다중값 입력 + SocialApp.provider 화이트리스트 검증 + `__in` 매칭. 각 값을 단일 choice 로 검증.
14+
15+
`expose_callable_choices_in_schema` 는 `core.openapi.filter_extension.DjangoFilterExtension` 가
16+
스키마 생성 시 callable choices 를 한 번 호출하도록 opt-in.
17+
"""
18+
19+
expose_callable_choices_in_schema = True
20+
21+
22+
class SocialAccountAdminFilterSet(filters.FilterSet):
23+
"""admin 운영자 검색. provider 는 SocialApp 에 등록된 값만 허용 (`?provider=google,kakao`)."""
24+
25+
provider = SocialAppProviderInFilter(field_name="provider", choices=_social_app_provider_choices)
26+
uid = filters.CharFilter(field_name="uid", lookup_expr="icontains")
27+
user_email = filters.CharFilter(field_name="user__email", lookup_expr="icontains")
28+
user_username = filters.CharFilter(field_name="user__username", lookup_expr="icontains")
29+
30+
date_joined_after = filters.DateTimeFilter(field_name="date_joined", lookup_expr="gte")
31+
date_joined_before = filters.DateTimeFilter(field_name="date_joined", lookup_expr="lte")
32+
last_login_after = filters.DateTimeFilter(field_name="last_login", lookup_expr="gte")
33+
last_login_before = filters.DateTimeFilter(field_name="last_login", lookup_expr="lte")
34+
35+
class Meta:
36+
model = SocialAccount
37+
fields = [
38+
"user",
39+
"provider",
40+
"uid",
41+
"user_email",
42+
"user_username",
43+
"date_joined_after",
44+
"date_joined_before",
45+
"last_login_after",
46+
"last_login_before",
47+
]
48+
49+
50+
class EmailAddressAdminFilterSet(filters.FilterSet):
51+
"""`email` 은 EmailAddress.email 과 User.email 양쪽을 OR 매칭. CSV 다중값 지원."""
52+
53+
email = MultiFieldOrCharInFilter(field_names=["email", "user__email"], lookup_expr="icontains")
54+
user_username = filters.CharFilter(field_name="user__username", lookup_expr="icontains")
55+
56+
class Meta:
57+
model = EmailAddress
58+
fields = [
59+
"user",
60+
"email",
61+
"verified",
62+
"primary",
63+
"user_username",
64+
]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from allauth.account.models import EmailAddress
2+
from allauth.socialaccount.models import SocialAccount, SocialApp
3+
from core.serializer.json_schema_serializer import JsonSchemaSerializer
4+
from core.serializer.lowercased_email_field import LowercasedEmailField
5+
from core.serializer.nested_model_serializer import NestedModelSerializer
6+
from rest_framework import serializers
7+
8+
9+
class SocialAppAdminSerializer(JsonSchemaSerializer, serializers.ModelSerializer):
10+
str_repr = serializers.CharField(source="__str__", read_only=True)
11+
12+
class Meta:
13+
model = SocialApp
14+
fields = ("id", "provider", "provider_id", "name", "client_id", "secret", "key", "settings", "str_repr")
15+
read_only_fields = ("id",)
16+
17+
18+
class SocialAccountAdminSerializer(JsonSchemaSerializer, serializers.ModelSerializer):
19+
str_repr = serializers.CharField(source="__str__", read_only=True)
20+
21+
class Meta:
22+
model = SocialAccount
23+
read_only_fields = fields = (
24+
"id",
25+
"user",
26+
"provider",
27+
"uid",
28+
"last_login",
29+
"date_joined",
30+
"extra_data",
31+
"str_repr",
32+
)
33+
34+
35+
class EmailAddressAdminSerializer(JsonSchemaSerializer, serializers.ModelSerializer):
36+
str_repr = serializers.CharField(source="__str__", read_only=True)
37+
email = LowercasedEmailField()
38+
39+
class Meta:
40+
model = EmailAddress
41+
fields = ("id", "user", "email", "verified", "primary", "str_repr")
42+
extra_kwargs = {"id": {"read_only": True}}
43+
44+
45+
class EmailAddressNestedAdminSerializer(JsonSchemaSerializer, NestedModelSerializer):
46+
id = serializers.IntegerField(required=False, help_text="기존 EmailAddress 수정 시 PK 전달, 새로 추가 시 생략")
47+
email = LowercasedEmailField()
48+
49+
class Meta:
50+
model = EmailAddress
51+
fields = ("id", "user", "email", "verified", "primary")
52+
# user 는 NestedFieldSpec.parent_fk_name 으로 부모 인스턴스에서 주입되므로 입력 시 생략 가능.
53+
extra_kwargs = {"user": {"required": False}}
54+
# validators=[] — auto UniqueTogetherValidator(user, email) 가 user 누락 시 required 로 막음.
55+
# DB unique constraint(account_emailaddress_user_id_email) 가 여전히 enforce.
56+
validators: list = []
57+
58+
59+
class SocialAccountNestedAdminSerializer(JsonSchemaSerializer, NestedModelSerializer):
60+
# delete-only nested — id 로 기존 row 매칭만 함. UserAdminSerializer.validate_social_accounts 가 정책 강제.
61+
id = serializers.IntegerField(required=True)
62+
63+
class Meta:
64+
model = SocialAccount
65+
read_only_fields = ("provider", "uid", "last_login", "date_joined", "extra_data")
66+
fields = ("id",) + read_only_fields

app/admin_api/serializers/user.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
import functools
22
import typing
33

4+
from admin_api.serializers.socialaccount import EmailAddressNestedAdminSerializer, SocialAccountNestedAdminSerializer
5+
from admin_api.services.socialaccount import delete_social_accounts_and_cleanup_user_emails
6+
from allauth.account.models import EmailAddress
7+
from allauth.socialaccount.models import SocialAccount
48
from core.const.account import generate_random_password
59
from core.const.serializer import COMMON_ADMIN_FIELDS
610
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
711
from core.serializer.json_schema_serializer import JsonSchemaSerializer
12+
from core.serializer.nested_model_serializer import NestedFieldModelSerializer, NestedFieldSpec
813
from core.serializer.read_only_serializer import ReadOnlyModelSerializer
914
from rest_framework import serializers
1015
from user.models import UserExt
1116
from user.models.organization import Organization
1217

1318

14-
class UserAdminSerializer(JsonSchemaSerializer, serializers.ModelSerializer):
19+
class UserAdminSerializer(JsonSchemaSerializer, NestedFieldModelSerializer):
1520
str_repr = serializers.CharField(source="__str__", read_only=True)
21+
email_addresses = EmailAddressNestedAdminSerializer(many=True, required=False, source="emailaddress_set")
22+
social_accounts = SocialAccountNestedAdminSerializer(many=True, required=False, source="socialaccount_set")
1623

1724
class Meta:
1825
model = UserExt
@@ -28,17 +35,75 @@ class Meta:
2835
"str_repr",
2936
"date_joined",
3037
"last_login",
38+
"email_addresses",
39+
"social_accounts",
3140
)
3241
extra_kwargs = {
3342
"id": {"read_only": True},
3443
"date_joined": {"read_only": True},
3544
"last_login": {"read_only": True},
3645
}
46+
nested_fields = {
47+
"emailaddress_set": NestedFieldSpec(
48+
related_manager_name="emailaddress_set",
49+
child_model=EmailAddress,
50+
parent_fk_name="user",
51+
),
52+
"socialaccount_set": NestedFieldSpec(
53+
related_manager_name="socialaccount_set",
54+
child_model=SocialAccount,
55+
parent_fk_name="user",
56+
),
57+
}
58+
59+
def validate(self, attrs: dict) -> dict:
60+
# social_accounts=[] 는 마지막 SA cascade 를 트리거해 같은 user 의 EA 전체를 삭제함.
61+
# 같은 PATCH 의 email_addresses 입력은 cascade 로 즉시 사라져 의도와 다른 결과가 되므로,
62+
# 실제로 cascade 가 발생하는 경우(기존 SA 존재 + SA=[] + EA 입력 있음)에만 거부.
63+
if (
64+
attrs.get("socialaccount_set") == []
65+
and attrs.get("emailaddress_set")
66+
and self.instance is not None
67+
and self.instance.socialaccount_set.all()
68+
):
69+
msg = "모든 SocialAccount 를 제거하면 EmailAddress 도 cascade 로 삭제됩니다 — 같은 PATCH 에서 EmailAddress 를 함께 변경할 수 없습니다."
70+
raise serializers.ValidationError(msg)
71+
return attrs
72+
73+
def validate_social_accounts(self, value: list[dict]) -> list[dict]:
74+
# SocialAccount는 nested에서 delete-only — 모든 입력 id 가 이 유저의 기존 SA 와 매칭돼야 함.
75+
# PATCH(partial=True) 에서는 nested 의 required 가 풀려 id 가 없을 수 있음 — 명시적으로 거부.
76+
if any("id" not in item for item in value):
77+
raise serializers.ValidationError("SocialAccount는 nested API에서 생성할 수 없습니다 (id 필수).")
78+
provided_ids = {item["id"] for item in value}
79+
# UserAdminViewSet 의 prefetch_related 캐시 활용.
80+
existing_ids = {sa.id for sa in self.instance.socialaccount_set.all()} if self.instance else set()
81+
if unknown := provided_ids - existing_ids:
82+
msg = f"존재하지 않거나 이 유저의 것이 아닌 SocialAccount id: {sorted(map(str, unknown))}"
83+
raise serializers.ValidationError(msg)
84+
return value
3785

3886
def create(self, validated_data: dict[str, typing.Any]) -> UserExt:
3987
password = generate_random_password()
4088
self._generated_password = password
41-
return UserExt.objects.create_user(**validated_data, password=password)
89+
nested_data = {k: validated_data.pop(k, []) or [] for k in self.Meta.nested_fields}
90+
instance = UserExt.objects.create_user(**validated_data, password=password)
91+
self._apply_nested_sync(instance, nested_data)
92+
return instance
93+
94+
def _apply_nested_sync(self, instance: UserExt, nested_data: dict[str, list[dict] | None]) -> None:
95+
# SocialAccount는 delete-only — 기존 set 에서 input 에 없는 것만 삭제.
96+
sa_data = nested_data.pop("socialaccount_set", None)
97+
super()._apply_nested_sync(instance, nested_data)
98+
99+
if sa_data is None:
100+
return
101+
102+
provided_ids = {item["id"] for item in sa_data}
103+
# prefetch_related 캐시 활용.
104+
existing_ids = {sa.id for sa in instance.socialaccount_set.all()}
105+
if to_delete_ids := existing_ids - provided_ids:
106+
delete_social_accounts_and_cleanup_user_emails(SocialAccount.objects.filter(id__in=to_delete_ids))
42107

43108

44109
class UserAdminSignInSerializerData(typing.TypedDict):

app/admin_api/services/__init__.py

Whitespace-only changes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from allauth.account.models import EmailAddress
2+
from allauth.socialaccount.models import SocialAccount
3+
from django.db import transaction
4+
from django.db.models import QuerySet
5+
6+
7+
def delete_social_accounts_and_cleanup_user_emails(social_accounts: QuerySet[SocialAccount]) -> None:
8+
with transaction.atomic():
9+
if not (affected_user_ids := set(social_accounts.values_list("user_id", flat=True))):
10+
return
11+
social_accounts.delete()
12+
EmailAddress.objects.filter(user_id__in=affected_user_ids).exclude(
13+
user_id__in=SocialAccount.objects.filter(user_id__in=affected_user_ids).values("user_id")
14+
).delete()

0 commit comments

Comments
 (0)