Skip to content

Commit 9c00478

Browse files
committed
feat: Sitemap 등록 시 순환 참조 및 중복 등록 방지를 위한 검증 추가
1 parent 2eb7d4c commit 9c00478

2 files changed

Lines changed: 174 additions & 1 deletion

File tree

app/cms/models.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
14
import datetime
25
import typing
36

47
from core.models import BaseAbstractModel, BaseAbstractModelQuerySet
8+
from django.core.exceptions import ValidationError
59
from django.core.validators import MinValueValidator
610
from django.db import models
711

@@ -15,6 +19,22 @@ def __str__(self):
1519
return str(self.title)
1620

1721

22+
@dataclasses.dataclass
23+
class SitemapGraph:
24+
id: str
25+
parent_id: str | None
26+
route_code: str
27+
28+
parent: SitemapGraph | None = None
29+
children: list[SitemapGraph] = dataclasses.field(default_factory=list)
30+
31+
@property
32+
def route(self) -> str:
33+
if self.parent:
34+
return f"{self.parent.route}/{self.route_code}"
35+
return self.route_code
36+
37+
1838
class SitemapQuerySet(BaseAbstractModelQuerySet):
1939
def filter_by_today(self) -> typing.Self:
2040
now = datetime.datetime.now()
@@ -23,10 +43,28 @@ def filter_by_today(self) -> typing.Self:
2343
models.Q(display_end_at__isnull=True) | models.Q(display_end_at__gte=now),
2444
)
2545

46+
def get_all_routes(self) -> set[str]:
47+
flattened_graph: dict[str, SitemapGraph] = {
48+
id: SitemapGraph(id=id, parent_id=parent_id, route_code=route_code)
49+
for id, parent_id, route_code in self.all().values_list("id", "parent_sitemap_id", "route_code")
50+
}
51+
roots: list[SitemapGraph] = []
52+
53+
for node in flattened_graph.values():
54+
if node.parent_id is None:
55+
roots.append(node)
56+
continue
57+
58+
parent_node = flattened_graph[node.parent_id]
59+
node.parent = parent_node
60+
parent_node.children.append(node)
61+
62+
return {node.route for node in flattened_graph.values()}
63+
2664

2765
class Sitemap(BaseAbstractModel):
2866
parent_sitemap = models.ForeignKey(
29-
"self", null=True, default=None, on_delete=models.SET_NULL, related_name="children"
67+
"self", null=True, blank=True, default=None, on_delete=models.SET_NULL, related_name="children"
3068
)
3169

3270
route_code = models.CharField(max_length=256)
@@ -52,6 +90,22 @@ def route(self) -> str:
5290
return f"{self.parent_sitemap.route}/{self.route_code}"
5391
return self.route_code
5492

93+
def clean(self) -> None:
94+
# Parent Sitemap과 Page가 같을 경우 ValidationError 발생
95+
if self.parent_sitemap_id and self.parent_sitemap_id == self.id:
96+
raise ValidationError("자기 자신을 부모로 설정할 수 없습니다.")
97+
98+
# 순환 참조를 방지하기 위해 Parent Sitemap이 자식 Sitemap을 가리키는 경우 ValidationError 발생
99+
parent_sitemap = self.parent_sitemap
100+
while parent_sitemap:
101+
if parent_sitemap == self:
102+
raise ValidationError("Parent Sitemap이 자식 Sitemap을 가리킬 수 없습니다.")
103+
parent_sitemap = parent_sitemap.parent_sitemap
104+
105+
# route를 계산할 시 이미 존재하는 route가 있을 경우 ValidationError 발생
106+
if self.route in Sitemap.objects.get_all_routes():
107+
raise ValidationError(f"`{self.route}`라우트는 이미 존재하는 route입니다.")
108+
55109

56110
class Section(BaseAbstractModel):
57111
page = models.ForeignKey(Page, on_delete=models.CASCADE, related_name="sections")

app/cms/test/site_route_calculation_test.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
from cms.models import Page, Sitemap
3+
from django.core.exceptions import ValidationError
34

45

56
@pytest.mark.django_db
@@ -31,3 +32,121 @@ def test_route_calculation():
3132
assert root_sitemap.route == "root"
3233
assert child_sitemap.route == "root/child"
3334
assert grandchild_sitemap.route == "root/child/grandchild"
35+
36+
37+
@pytest.mark.django_db
38+
def test_get_all_routes():
39+
# Given: nested한 사이트맵 구조 생성
40+
data = {
41+
"root_1": {
42+
"child_1_1": {"child_1_1_1": {}, "child_1_1_2": {}},
43+
"child_1_2": {"child_1_2_1": {}},
44+
"child_1_3": {},
45+
},
46+
"root_2": {
47+
"child_2_1": {"child_2_1_1": {}},
48+
"child_2_2": {},
49+
},
50+
}
51+
52+
def create_sitemaps(data: dict[str, dict], parent: Sitemap = None) -> None:
53+
for name, children in data.items():
54+
sitemap = Sitemap.objects.create(
55+
route_code=name,
56+
name=name,
57+
page=Page.objects.create(title=name, subtitle=f"{name} Subtitle"),
58+
parent_sitemap=parent,
59+
)
60+
create_sitemaps(children, sitemap)
61+
62+
create_sitemaps(data)
63+
64+
# When: Sitemap.objects.get_all_routes() 메서드를 호출할 시
65+
all_routes = Sitemap.objects.get_all_routes()
66+
67+
# Then: 예상한 모든 route가 나와야 한다.
68+
assert all_routes == {
69+
"root_1",
70+
"root_1/child_1_1",
71+
"root_1/child_1_1/child_1_1_1",
72+
"root_1/child_1_1/child_1_1_2",
73+
"root_1/child_1_2",
74+
"root_1/child_1_2/child_1_2_1",
75+
"root_1/child_1_3",
76+
"root_2",
77+
"root_2/child_2_1",
78+
"root_2/child_2_1/child_2_1_1",
79+
"root_2/child_2_2",
80+
}
81+
82+
83+
@pytest.mark.django_db
84+
def test_clean_should_check_for_self_reference():
85+
# Given: Sitemap 객체 생성
86+
sitemap = Sitemap.objects.create(
87+
route_code="self",
88+
name="Self Sitemap",
89+
page=Page.objects.create(title="Self Page", subtitle="Self Subtitle"),
90+
)
91+
92+
# When: Self-reference를 만들기 위해 parent_sitemap을 자기 자신으로 설정
93+
sitemap.parent_sitemap = sitemap
94+
95+
# Then: ValidationError가 발생해야 한다.
96+
with pytest.raises(ValidationError) as excinfo:
97+
sitemap.clean()
98+
assert str(excinfo.value) == "자기 자신을 부모로 설정할 수 없습니다."
99+
100+
101+
@pytest.mark.django_db
102+
def test_clean_should_check_for_circular_reference():
103+
# Given: Circular reference가 있는 Sitemap 객체 생성
104+
root_sitemap = Sitemap.objects.create(
105+
route_code="root",
106+
name="Root Sitemap",
107+
page=Page.objects.create(title="Root Page", subtitle="Root Subtitle"),
108+
)
109+
110+
child_sitemap = Sitemap.objects.create(
111+
route_code="child",
112+
name="Child Sitemap",
113+
parent_sitemap=root_sitemap,
114+
page=Page.objects.create(title="Child Page", subtitle="Child Subtitle"),
115+
)
116+
117+
grandchild_sitemap = Sitemap.objects.create(
118+
route_code="grandchild",
119+
name="Grandchild Sitemap",
120+
parent_sitemap=child_sitemap,
121+
page=Page.objects.create(title="Grandchild Page", subtitle="Grandchild Subtitle"),
122+
)
123+
124+
# When: Circular reference를 만들기 위해 child_sitemap을 root_sitemap의 parent로 설정
125+
root_sitemap.parent_sitemap = grandchild_sitemap
126+
127+
# Then: ValidationError가 발생해야 한다.
128+
with pytest.raises(ValidationError) as excinfo:
129+
root_sitemap.clean()
130+
assert str(excinfo.value) == "Parent Sitemap이 자식 Sitemap을 가리킬 수 없습니다."
131+
132+
133+
@pytest.mark.django_db
134+
def test_clean_should_check_for_existing_route():
135+
# Given: 이미 존재하는 route를 가진 Sitemap 객체 생성
136+
Sitemap.objects.create(
137+
route_code="existing",
138+
name="Existing Sitemap",
139+
page=Page.objects.create(title="Existing Page", subtitle="Existing Subtitle"),
140+
)
141+
142+
# When: 새로운 Sitemap 객체를 생성하고, 기존의 route와 같은 route_code를 설정
143+
new_sitemap = Sitemap(
144+
route_code="existing",
145+
name="New Sitemap",
146+
page=Page.objects.create(title="New Page", subtitle="New Subtitle"),
147+
)
148+
149+
# Then: ValidationError가 발생해야 한다.
150+
with pytest.raises(ValidationError) as excinfo:
151+
new_sitemap.clean()
152+
assert str(excinfo.value) == "`existing`라우트는 이미 존재하는 route입니다."

0 commit comments

Comments
 (0)