Skip to content

Commit 4ee519a

Browse files
committed
feat: Allauth 어드민 API들에 필터 추가
1 parent e74e353 commit 4ee519a

5 files changed

Lines changed: 208 additions & 2 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+
]

app/admin_api/test/socialaccount_test.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import http
22

33
import pytest
4+
import yaml
45
from allauth.account.models import EmailAddress
56
from allauth.socialaccount.models import SocialAccount, SocialApp
67
from django.urls import reverse
@@ -134,6 +135,74 @@ def test_social_account_list_filter_by_user(api_client, regular_user, multi_soci
134135
assert {row["uid"] for row in rows} == {"alice-google-1"}
135136

136137

138+
@pytest.mark.django_db
139+
def test_social_account_list_filter_by_provider_csv(api_client, regular_user, multi_social_user):
140+
# provider choices 는 SocialApp 등록값 기반 — alice=google, bob=google+kakao 모두 매치되려면
141+
# google/kakao 두 SocialApp 모두 등록되어 있어야 함.
142+
SocialApp.objects.get_or_create(provider="google", defaults={"name": "Google", "client_id": "g", "secret": "g"})
143+
SocialApp.objects.get_or_create(provider="kakao", defaults={"name": "Kakao", "client_id": "k", "secret": "k"})
144+
145+
response = api_client.get(reverse("v1:admin-social-account-list"), {"provider": "google,kakao"})
146+
assert response.status_code == http.HTTPStatus.OK
147+
rows = response.json()["results"]
148+
assert {row["uid"] for row in rows} == {"alice-google-1", "bob-google-1", "bob-kakao-1"}
149+
150+
151+
@pytest.mark.django_db
152+
def test_social_account_list_filter_by_provider_unknown_rejected(api_client, regular_user, social_app):
153+
# social_app fixture 가 google 만 등록 — kakao 는 SocialApp 에 없으므로 거부.
154+
response = api_client.get(reverse("v1:admin-social-account-list"), {"provider": "kakao"})
155+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
156+
157+
158+
@pytest.mark.django_db
159+
def test_social_account_provider_filter_enum_exposed_in_openapi(api_client):
160+
# callable choices 를 OpenAPI 스키마에 enum 으로 노출 (core.openapi.filter_extension).
161+
SocialApp.objects.get_or_create(provider="google", defaults={"name": "G", "client_id": "g", "secret": "g"})
162+
SocialApp.objects.get_or_create(provider="kakao", defaults={"name": "K", "client_id": "k", "secret": "k"})
163+
164+
response = APIClient().get("/api/schema/v1/")
165+
assert response.status_code == http.HTTPStatus.OK
166+
schema = yaml.safe_load(response.content)
167+
168+
list_path = next(p for p in schema["paths"] if p.endswith("/allauth/social-account/"))
169+
params = schema["paths"][list_path]["get"]["parameters"]
170+
provider = next(p for p in params if p["name"] == "provider")
171+
assert provider["schema"]["items"]["enum"] == ["google", "kakao"]
172+
173+
174+
@pytest.mark.django_db
175+
def test_social_account_list_filter_by_uid_icontains(api_client, regular_user, multi_social_user):
176+
response = api_client.get(reverse("v1:admin-social-account-list"), {"uid": "kakao"})
177+
assert response.status_code == http.HTTPStatus.OK
178+
rows = response.json()["results"]
179+
assert {row["uid"] for row in rows} == {"bob-kakao-1"}
180+
181+
182+
@pytest.mark.django_db
183+
def test_social_account_list_filter_by_user_email_and_username(api_client, regular_user, multi_social_user):
184+
response = api_client.get(reverse("v1:admin-social-account-list"), {"user_email": "alice@"})
185+
assert response.status_code == http.HTTPStatus.OK
186+
rows = response.json()["results"]
187+
assert {row["uid"] for row in rows} == {"alice-google-1"}
188+
189+
response = api_client.get(reverse("v1:admin-social-account-list"), {"user_username": "bob"})
190+
assert response.status_code == http.HTTPStatus.OK
191+
rows = response.json()["results"]
192+
assert {row["uid"] for row in rows} == {"bob-google-1", "bob-kakao-1"}
193+
194+
195+
@pytest.mark.django_db
196+
def test_social_account_list_filter_by_date_joined_range(api_client, regular_user, multi_social_user):
197+
import datetime as _dt
198+
199+
# 미래 시점 _after 는 결과 0 건.
200+
future = (_dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(days=1)).isoformat()
201+
response = api_client.get(reverse("v1:admin-social-account-list"), {"date_joined_after": future})
202+
assert response.status_code == http.HTTPStatus.OK
203+
assert response.json()["results"] == []
204+
205+
137206
@pytest.mark.django_db
138207
def test_social_account_no_create_endpoint(api_client, regular_user):
139208
response = api_client.post(
@@ -175,6 +244,55 @@ def test_email_address_list_filter_by_user(api_client, regular_user):
175244
assert {row["email"] for row in rows} == {"alice@example.com"}
176245

177246

247+
@pytest.mark.django_db
248+
def test_email_address_list_filter_by_email_matches_emailaddress(api_client, regular_user, multi_social_user):
249+
# EmailAddress.email substring 매칭.
250+
response = api_client.get(reverse("v1:admin-email-address-list"), {"email": "alice@"})
251+
assert response.status_code == http.HTTPStatus.OK
252+
rows = response.json()["results"]
253+
assert {row["email"] for row in rows} == {"alice@example.com"}
254+
255+
256+
@pytest.mark.django_db
257+
def test_email_address_list_filter_by_email_matches_user_email(api_client, regular_user):
258+
# EA.email 은 alt 이지만 user.email 은 alice 라서 ?email=alice 로도 잡혀야 한다.
259+
EmailAddress.objects.create(user=regular_user, email="alt@example.com", verified=False, primary=False)
260+
response = api_client.get(reverse("v1:admin-email-address-list"), {"email": "alice@"})
261+
assert response.status_code == http.HTTPStatus.OK
262+
rows = response.json()["results"]
263+
# alice 의 primary EA + alt EA (User.email join 으로 매칭) 모두 포함.
264+
assert {row["email"] for row in rows} == {"alice@example.com", "alt@example.com"}
265+
266+
267+
@pytest.mark.django_db
268+
def test_email_address_list_filter_by_email_csv(api_client, regular_user, multi_social_user):
269+
response = api_client.get(reverse("v1:admin-email-address-list"), {"email": "alice@,bob@"})
270+
assert response.status_code == http.HTTPStatus.OK
271+
rows = response.json()["results"]
272+
assert {row["email"] for row in rows} == {"alice@example.com", "bob@example.com"}
273+
274+
275+
@pytest.mark.django_db
276+
def test_email_address_list_filter_by_verified_and_primary(api_client, regular_user):
277+
EmailAddress.objects.create(user=regular_user, email="alt@example.com", verified=False, primary=False)
278+
# verified=false
279+
response = api_client.get(reverse("v1:admin-email-address-list"), {"verified": "false"})
280+
assert response.status_code == http.HTTPStatus.OK
281+
assert {row["email"] for row in response.json()["results"]} == {"alt@example.com"}
282+
# primary=true
283+
response = api_client.get(reverse("v1:admin-email-address-list"), {"primary": "true"})
284+
assert response.status_code == http.HTTPStatus.OK
285+
assert {row["email"] for row in response.json()["results"]} == {"alice@example.com"}
286+
287+
288+
@pytest.mark.django_db
289+
def test_email_address_list_filter_by_user_username(api_client, regular_user, multi_social_user):
290+
response = api_client.get(reverse("v1:admin-email-address-list"), {"user_username": "bob"})
291+
assert response.status_code == http.HTTPStatus.OK
292+
rows = response.json()["results"]
293+
assert {row["email"] for row in rows} == {"bob@example.com"}
294+
295+
178296
@pytest.mark.django_db
179297
def test_email_address_create_lowercases_email(api_client, regular_user):
180298
response = api_client.post(

app/admin_api/views/socialaccount.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from admin_api.filtersets.socialaccount import EmailAddressAdminFilterSet, SocialAccountAdminFilterSet
12
from admin_api.serializers.socialaccount import (
23
EmailAddressAdminSerializer,
34
SocialAccountAdminSerializer,
@@ -39,7 +40,7 @@ class SocialAccountAdminViewSet(
3940
pagination_class = AdminPagination
4041
serializer_class = SocialAccountAdminSerializer
4142
queryset = SocialAccount.objects.all().select_related("user").order_by("-date_joined", "-id")
42-
filterset_fields = ["user"]
43+
filterset_class = SocialAccountAdminFilterSet
4344

4445
def perform_destroy(self, instance: SocialAccount) -> None:
4546
delete_social_accounts_and_cleanup_user_emails(SocialAccount.objects.filter(pk=instance.pk))
@@ -52,4 +53,4 @@ class EmailAddressAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet):
5253
permission_classes = [IsSuperUser]
5354
pagination_class = AdminPagination
5455
queryset = EmailAddress.objects.all().select_related("user").order_by("-id")
55-
filterset_fields = ["user"]
56+
filterset_class = EmailAddressAdminFilterSet
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from drf_spectacular.contrib.django_filters import DjangoFilterExtension as _BaseDjangoFilterExtension
2+
3+
4+
class DjangoFilterExtension(_BaseDjangoFilterExtension):
5+
"""기본 확장은 DB 비용 우려로 callable `choices` 를 무시한다.
6+
7+
Filter 클래스에 `expose_callable_choices_in_schema = True` 를 붙이면 스키마 생성 시
8+
한 번만 호출해 enum 으로 노출. 런타임 dynamism 은 유지하면서 OpenAPI 문서에도
9+
선택지를 드러내고 싶을 때 사용.
10+
"""
11+
12+
priority = 1
13+
14+
def _get_explicit_filter_choices(self, filter_field): # type: ignore[no-untyped-def]
15+
choices = filter_field.extra.get("choices")
16+
if callable(choices) and getattr(filter_field, "expose_callable_choices_in_schema", False):
17+
try:
18+
resolved = list(choices())
19+
except Exception:
20+
return []
21+
return [c for c, _ in resolved]
22+
return super()._get_explicit_filter_choices(filter_field)

app/core/openapi/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from core.openapi.filter_extension import DjangoFilterExtension # noqa: F401 drf-spectacular 확장 등록
12
from drf_spectacular.openapi import AutoSchema, OpenApiExample, OpenApiResponse
23
from drf_spectacular.utils import OpenApiParameter
34
from rest_framework import status

0 commit comments

Comments
 (0)