Skip to content

Commit f8bf950

Browse files
authored
Merge pull request #41 from pythonkr/feature/add-applied-domain-to-cms
feat: CMS Sitemap을 frontend 도메인 그룹으로 필터링
2 parents db761a2 + 7a2db62 commit f8bf950

14 files changed

Lines changed: 1011 additions & 37 deletions

File tree

app/admin_api/serializers/cms.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,68 @@
11
import re
22

3-
from cms.models import Page, Section, Sitemap
3+
from cms.models import DomainGroup, Page, Section, Sitemap
4+
from core.const.regex import HOSTNAME_REGEX
45
from core.const.serializer import COMMON_ADMIN_FIELDS
56
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
67
from core.serializer.json_schema_serializer import JsonSchemaSerializer
8+
from django.db import IntegrityError, transaction
79
from rest_framework import serializers
810

911

12+
class DomainGroupAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
13+
# DRF가 ArrayField를 자동 매핑하면 inner CharField의 validator를 raw input(정규화 전)에 적용해 정상 입력도 거부됨.
14+
# 이를 막기 위해 inner validator 없는 ListField로 재정의하고, 정규화 + format 검증 + 그룹 간 중복 검증을 validate_domains에서 명시적으로 수행.
15+
domains = serializers.ListField(child=serializers.CharField(), allow_empty=False)
16+
17+
class Meta:
18+
model = DomainGroup
19+
fields = COMMON_ADMIN_FIELDS + ("name", "domains")
20+
21+
def validate_domains(self, value: list[str]) -> list[str]:
22+
if not (normalized := list({c for v in value if (c := v.strip().lower())})):
23+
raise serializers.ValidationError("도메인 목록이 비어있을 수 없습니다.")
24+
25+
if invalid := [d for d in normalized if not HOSTNAME_REGEX.match(d)]:
26+
raise serializers.ValidationError(
27+
[
28+
f"`{d}` 도메인이 올바른 호스트 형식이 아닙니다 (스킴/포트/경로/쿼리는 포함할 수 없습니다)."
29+
for d in invalid
30+
]
31+
)
32+
33+
overlap_qs = DomainGroup.objects.filter_active().filter(domains__overlap=normalized)
34+
if self.instance and self.instance.pk:
35+
overlap_qs = overlap_qs.exclude(pk=self.instance.pk)
36+
37+
if conflict := overlap_qs.first():
38+
shared = sorted(set(normalized) & set(conflict.domains))
39+
err_msg = f"`{', '.join(shared)}` 도메인이 이미 `{conflict.name}` 그룹에 등록되어 있습니다."
40+
raise serializers.ValidationError(err_msg)
41+
42+
return normalized
43+
44+
@transaction.atomic
45+
def save(self, **kwargs):
46+
try:
47+
instance = super().save(**kwargs)
48+
except IntegrityError as e:
49+
# DB-level overlap trigger가 race condition을 잡아낸 경우 (app-level 검사가 통과한 동시 요청).
50+
if "cms_domaingroup_domains_no_overlap" in str(e):
51+
raise serializers.ValidationError({"domains": "도메인이 이미 다른 그룹에 등록되어 있습니다."}) from e
52+
raise
53+
54+
if not instance.sitemaps.filter_active().exists():
55+
page = Page.objects.create(title=instance.name, subtitle=instance.name)
56+
Section.objects.create(page=page, order=0, body="")
57+
Sitemap.objects.create(domain_group=instance, name=instance.name, route_code="", page=page)
58+
return instance
59+
60+
1061
class SitemapAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
1162
class Meta:
1263
model = Sitemap
1364
fields = COMMON_ADMIN_FIELDS + (
65+
"domain_group",
1466
"parent_sitemap",
1567
"route_code",
1668
"order",

app/admin_api/test/cms_test.py

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import http
2+
3+
import pytest
4+
from cms.models import DomainGroup, Page, Section, Sitemap
5+
from django.db import IntegrityError
6+
from django.urls import reverse
7+
from rest_framework.test import APIClient
8+
9+
10+
@pytest.fixture
11+
def domain_group(superuser):
12+
return DomainGroup.objects.create(
13+
name="2025년 PyConKR 홈페이지",
14+
domains=["2025.pycon.kr"],
15+
created_by=superuser,
16+
updated_by=superuser,
17+
)
18+
19+
20+
# ---- Auth -------------------------------------------------------------------
21+
22+
23+
@pytest.mark.django_db
24+
def test_unauthenticated_request_to_domain_group_is_rejected():
25+
response = APIClient().get(reverse("v1:admin-domain-group-list"))
26+
assert response.status_code in (http.HTTPStatus.FORBIDDEN, http.HTTPStatus.UNAUTHORIZED)
27+
28+
29+
# ---- DomainGroup CRUD -------------------------------------------------------
30+
31+
32+
@pytest.mark.django_db
33+
def test_domain_group_list(api_client, domain_group):
34+
response = api_client.get(reverse("v1:admin-domain-group-list"))
35+
assert response.status_code == http.HTTPStatus.OK
36+
rows = response.json()
37+
assert any(row["name"] == domain_group.name for row in rows)
38+
39+
40+
@pytest.mark.django_db
41+
def test_domain_group_create(api_client):
42+
response = api_client.post(
43+
reverse("v1:admin-domain-group-list"),
44+
data={"name": "2026년 PyConKR 홈페이지", "domains": ["2026.pycon.kr", "pycon.kr"]},
45+
format="json",
46+
)
47+
assert response.status_code == http.HTTPStatus.CREATED, response.json()
48+
assert DomainGroup.objects.filter(name="2026년 PyConKR 홈페이지").exists()
49+
50+
51+
@pytest.mark.django_db
52+
@pytest.mark.parametrize(
53+
"domains",
54+
[
55+
["https://pycon.kr"], # 스킴
56+
["pycon.kr:8080"], # 포트
57+
["pycon.kr/path"], # 경로
58+
["pycon.kr?q=1"], # 쿼리
59+
["pycon..kr"], # 연속 점
60+
[], # 빈 배열
61+
],
62+
)
63+
def test_domain_group_create_rejects_invalid_domains(api_client, domains):
64+
response = api_client.post(
65+
reverse("v1:admin-domain-group-list"),
66+
data={"name": "bad", "domains": domains},
67+
format="json",
68+
)
69+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
70+
71+
72+
@pytest.mark.django_db
73+
@pytest.mark.parametrize(
74+
"input_domains,expected",
75+
[
76+
(["PYCON.KR"], ["pycon.kr"]),
77+
([" pycon.kr "], ["pycon.kr"]),
78+
(["pycon.kr", "PYCON.KR"], ["pycon.kr"]),
79+
(["pycon.kr", "pycon.kr"], ["pycon.kr"]),
80+
],
81+
)
82+
def test_domain_group_create_normalizes_domains(api_client, input_domains, expected):
83+
response = api_client.post(
84+
reverse("v1:admin-domain-group-list"),
85+
data={"name": "n", "domains": input_domains},
86+
format="json",
87+
)
88+
assert response.status_code == http.HTTPStatus.CREATED, response.json()
89+
assert response.json()["domains"] == expected
90+
91+
92+
# ---- DB-level overlap trigger (race-safe) -----------------------------------
93+
94+
95+
@pytest.mark.django_db(transaction=True)
96+
def test_db_trigger_rejects_overlapping_domain_on_insert():
97+
DomainGroup.objects.create(name="A", domains=["x.pycon.kr"])
98+
with pytest.raises(IntegrityError):
99+
DomainGroup.objects.create(name="B", domains=["x.pycon.kr", "y.pycon.kr"])
100+
101+
102+
@pytest.mark.django_db(transaction=True)
103+
def test_db_trigger_rejects_overlapping_domain_on_update():
104+
DomainGroup.objects.create(name="A", domains=["x.pycon.kr"])
105+
other = DomainGroup.objects.create(name="B", domains=["y.pycon.kr"])
106+
with pytest.raises(IntegrityError):
107+
other.domains = ["x.pycon.kr"]
108+
other.save()
109+
110+
111+
@pytest.mark.django_db(transaction=True)
112+
def test_db_trigger_ignores_soft_deleted_groups():
113+
a = DomainGroup.objects.create(name="A", domains=["x.pycon.kr"])
114+
a.delete()
115+
DomainGroup.objects.create(name="B", domains=["x.pycon.kr"]) # 같은 도메인 재사용 허용
116+
117+
118+
# ---- DomainGroup constraints ------------------------------------------------
119+
120+
121+
@pytest.mark.django_db
122+
def test_domain_group_create_rejects_overlapping_domain(api_client, domain_group):
123+
response = api_client.post(
124+
reverse("v1:admin-domain-group-list"),
125+
data={"name": "다른 그룹", "domains": ["2025.pycon.kr", "new.pycon.kr"]},
126+
format="json",
127+
)
128+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
129+
130+
131+
@pytest.mark.django_db
132+
def test_domain_group_update_can_keep_own_domains(api_client, domain_group):
133+
response = api_client.patch(
134+
reverse("v1:admin-domain-group-detail", kwargs={"pk": domain_group.id}),
135+
data={"domains": ["2025.pycon.kr", "another.pycon.kr"]},
136+
format="json",
137+
)
138+
assert response.status_code == http.HTTPStatus.OK, response.json()
139+
domain_group.refresh_from_db()
140+
assert set(domain_group.domains) == {"2025.pycon.kr", "another.pycon.kr"}
141+
142+
143+
@pytest.mark.django_db
144+
def test_domain_group_create_rejects_duplicate_name(api_client, domain_group):
145+
response = api_client.post(
146+
reverse("v1:admin-domain-group-list"),
147+
data={"name": domain_group.name, "domains": ["new.pycon.kr"]},
148+
format="json",
149+
)
150+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
151+
152+
153+
# ---- DomainGroup auto-creates default Sitemap -------------------------------
154+
155+
156+
@pytest.mark.django_db
157+
def test_creating_domain_group_auto_creates_default_sitemap_page_section(api_client):
158+
response = api_client.post(
159+
reverse("v1:admin-domain-group-list"),
160+
data={"name": "신규 그룹", "domains": ["new.pycon.kr"]},
161+
format="json",
162+
)
163+
assert response.status_code == http.HTTPStatus.CREATED, response.json()
164+
165+
group = DomainGroup.objects.get(name="신규 그룹")
166+
sitemaps = list(group.sitemaps.filter_active())
167+
assert len(sitemaps) == 1
168+
assert sitemaps[0].name == "신규 그룹"
169+
assert sitemaps[0].route_code == ""
170+
171+
page = sitemaps[0].page
172+
assert page is not None
173+
assert page.title == "신규 그룹"
174+
assert Section.objects.filter_active().filter(page=page).count() == 1
175+
176+
177+
@pytest.mark.django_db
178+
def test_updating_empty_group_auto_creates_default_sitemap(api_client, domain_group):
179+
assert domain_group.sitemaps.filter_active().count() == 0
180+
181+
response = api_client.patch(
182+
reverse("v1:admin-domain-group-detail", kwargs={"pk": domain_group.id}),
183+
data={"name": "수정된 이름"},
184+
format="json",
185+
)
186+
assert response.status_code == http.HTTPStatus.OK, response.json()
187+
assert domain_group.sitemaps.filter_active().count() == 1
188+
189+
190+
@pytest.mark.django_db
191+
def test_updating_non_empty_group_does_not_create_extra_sitemap(api_client, superuser, domain_group):
192+
page = Page.objects.create(title="t", subtitle="s", created_by=superuser, updated_by=superuser)
193+
Sitemap.objects.create(
194+
name="existing",
195+
page=page,
196+
domain_group=domain_group,
197+
created_by=superuser,
198+
updated_by=superuser,
199+
)
200+
assert domain_group.sitemaps.filter_active().count() == 1
201+
202+
response = api_client.patch(
203+
reverse("v1:admin-domain-group-detail", kwargs={"pk": domain_group.id}),
204+
data={"name": "수정된 이름"},
205+
format="json",
206+
)
207+
assert response.status_code == http.HTTPStatus.OK, response.json()
208+
assert domain_group.sitemaps.filter_active().count() == 1
209+
210+
211+
# ---- DomainGroup destroy ----------------------------------------------------
212+
213+
214+
@pytest.mark.django_db
215+
def test_destroy_domain_group_with_lone_root_succeeds_leaving_page(api_client, superuser):
216+
# lone root만 함께 삭제. Page/Section은 보존 (dangling이 되더라도 의도적 삭제 회피로 안전성 우선).
217+
group = DomainGroup.objects.create(name="A", domains=["a.pycon.kr"], created_by=superuser, updated_by=superuser)
218+
page = Page.objects.create(title="A", subtitle="A", created_by=superuser, updated_by=superuser)
219+
section = Section.objects.create(page=page, order=0, body="", created_by=superuser, updated_by=superuser)
220+
sitemap = Sitemap.objects.create(
221+
name="root", domain_group=group, route_code="", page=page, created_by=superuser, updated_by=superuser
222+
)
223+
224+
response = api_client.delete(reverse("v1:admin-domain-group-detail", kwargs={"pk": group.id}))
225+
assert response.status_code == http.HTTPStatus.NO_CONTENT
226+
227+
group.refresh_from_db()
228+
sitemap.refresh_from_db()
229+
page.refresh_from_db()
230+
section.refresh_from_db()
231+
assert group.deleted_at is not None
232+
assert sitemap.deleted_at is not None
233+
assert page.deleted_at is None
234+
assert section.deleted_at is None
235+
236+
237+
@pytest.mark.django_db
238+
def test_destroy_domain_group_with_multiple_sitemaps_rejected(api_client, superuser):
239+
group = DomainGroup.objects.create(name="A", domains=["a.pycon.kr"], created_by=superuser, updated_by=superuser)
240+
Sitemap.objects.create(name="r1", domain_group=group, route_code="", created_by=superuser, updated_by=superuser)
241+
Sitemap.objects.create(
242+
name="r2", domain_group=group, route_code="other", created_by=superuser, updated_by=superuser
243+
)
244+
245+
response = api_client.delete(reverse("v1:admin-domain-group-detail", kwargs={"pk": group.id}))
246+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
247+
248+
group.refresh_from_db()
249+
assert group.deleted_at is None
250+
251+
252+
@pytest.mark.django_db
253+
def test_destroy_domain_group_with_sitemap_having_children_rejected(api_client, superuser):
254+
group = DomainGroup.objects.create(name="A", domains=["a.pycon.kr"], created_by=superuser, updated_by=superuser)
255+
parent = Sitemap.objects.create(
256+
name="parent", domain_group=group, route_code="", created_by=superuser, updated_by=superuser
257+
)
258+
Sitemap.objects.create(
259+
name="child",
260+
domain_group=group,
261+
route_code="child",
262+
parent_sitemap=parent,
263+
created_by=superuser,
264+
updated_by=superuser,
265+
)
266+
267+
response = api_client.delete(reverse("v1:admin-domain-group-detail", kwargs={"pk": group.id}))
268+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
269+
270+
group.refresh_from_db()
271+
assert group.deleted_at is None
272+
273+
274+
# ---- Sitemap admin serializer exposes domain_group --------------------------
275+
276+
277+
@pytest.mark.django_db
278+
def test_sitemap_admin_serializer_exposes_domain_group(api_client, superuser, domain_group):
279+
page = Page.objects.create(title="t", subtitle="s", created_by=superuser, updated_by=superuser)
280+
sitemap = Sitemap.objects.create(
281+
name="x",
282+
page=page,
283+
domain_group=domain_group,
284+
created_by=superuser,
285+
updated_by=superuser,
286+
)
287+
288+
response = api_client.get(reverse("v1:admin-sitemap-list"))
289+
assert response.status_code == http.HTTPStatus.OK
290+
291+
rows = response.json()
292+
row = next(r for r in rows if r["id"] == str(sitemap.id))
293+
assert row["domain_group"] == str(domain_group.id)
294+
295+
296+
@pytest.mark.django_db
297+
def test_sitemap_admin_filter_by_domain_group(api_client, superuser):
298+
group_a = DomainGroup.objects.create(name="A", domains=["a.pycon.kr"], created_by=superuser, updated_by=superuser)
299+
group_b = DomainGroup.objects.create(name="B", domains=["b.pycon.kr"], created_by=superuser, updated_by=superuser)
300+
page = Page.objects.create(title="t", subtitle="s", created_by=superuser, updated_by=superuser)
301+
302+
sitemap_a = Sitemap.objects.create(
303+
name="A-sitemap", page=page, domain_group=group_a, created_by=superuser, updated_by=superuser
304+
)
305+
Sitemap.objects.create(
306+
name="B-sitemap", page=page, domain_group=group_b, created_by=superuser, updated_by=superuser
307+
)
308+
309+
response = api_client.get(reverse("v1:admin-sitemap-list"), {"domain_group": str(group_a.id)})
310+
assert response.status_code == http.HTTPStatus.OK
311+
rows = response.json()
312+
assert {r["id"] for r in rows} == {str(sitemap_a.id)}

app/admin_api/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from admin_api.views.cms import PageAdminViewSet, SitemapAdminViewSet
1+
from admin_api.views.cms import DomainGroupAdminViewSet, PageAdminViewSet, SitemapAdminViewSet
22
from admin_api.views.event.event import EventAdminViewSet
33
from admin_api.views.event.presentation import (
44
PresentationAdminViewSet,
@@ -29,6 +29,7 @@
2929
admin_user_router.register("organization", OrganizationAdminViewSet, basename="admin-organization")
3030

3131
admin_cms_router = routers.SimpleRouter()
32+
admin_cms_router.register("domain-group", DomainGroupAdminViewSet, basename="admin-domain-group")
3233
admin_cms_router.register("sitemap", SitemapAdminViewSet, basename="admin-sitemap")
3334
admin_cms_router.register("page", PageAdminViewSet, basename="admin-page")
3435

0 commit comments

Comments
 (0)