Skip to content

Commit e7bb7f0

Browse files
authored
Merge pull request #14 from pythonkr/feature/add-route-code-to-sitemap
feat: 프론트엔드 경로 표현을 위한 `route_code` 컬럼 추가
2 parents 1206f03 + 5b1a29f commit e7bb7f0

11 files changed

+395
-22
lines changed

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ local-collectstatic:
5656
local-shell:
5757
@ENV_PATH=envfile/.env.local uv run python app/manage.py shell
5858

59+
# Run django shell plus
60+
local-shell-plus:
61+
@ENV_PATH=envfile/.env.local uv run python app/manage.py shell_plus
62+
5963
# Run django makemigrations
6064
local-makemigrations:
6165
@ENV_PATH=envfile/.env.local uv run python app/manage.py makemigrations
@@ -68,6 +72,10 @@ local-migrate:
6872
local-createsuperuser:
6973
@ENV_PATH=envfile/.env.local uv run python app/manage.py createsuperuser
7074

75+
# Run pytest
76+
local-test:
77+
@ENV_PATH=envfile/.env.local cd app && uv run pytest -v
78+
7179
# Devtools
7280
hooks-install: local-setup
7381
uv run pre-commit install

app/cms/admin.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,18 @@ class Meta:
152152

153153
@admin.register(Sitemap)
154154
class SitemapAdmin(RelatedReadonlyFieldsMixin, admin.ModelAdmin):
155-
fields = ["id", "parent_sitemap", "page", "name", "order", "display_start_at", "display_end_at"]
156-
readonly_fields = ["id"]
155+
fields = [
156+
"id",
157+
"parent_sitemap",
158+
"route_code",
159+
"route",
160+
"page",
161+
"name",
162+
"order",
163+
"display_start_at",
164+
"display_end_at",
165+
]
166+
readonly_fields = ["id", "route"]
157167
related_readonly_config = {
158168
"page": ["id", "is_active", "css", "title", "subtitle"],
159169
"parent_sitemap": ["id", "name", "order", "display_start_at", "display_end_at"],
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.2 on 2025-05-18 22:40
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("cms", "0004_alter_section_options_alter_sitemap_options_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="historicalsitemap",
14+
name="route_code",
15+
field=models.CharField(default="", max_length=256, blank=True),
16+
preserve_default=False,
17+
),
18+
migrations.AddField(
19+
model_name="sitemap",
20+
name="route_code",
21+
field=models.CharField(default="", max_length=256, blank=True),
22+
preserve_default=False,
23+
),
24+
]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 5.2 on 2025-05-18 22:53
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("cms", "0005_historicalsitemap_route_code_sitemap_route_code"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="sitemap",
15+
name="parent_sitemap",
16+
field=models.ForeignKey(
17+
blank=True,
18+
default=None,
19+
null=True,
20+
on_delete=django.db.models.deletion.SET_NULL,
21+
related_name="children",
22+
to="cms.sitemap",
23+
),
24+
),
25+
]

app/cms/models.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
14
import datetime
5+
import re
26
import typing
37

48
from core.models import BaseAbstractModel, BaseAbstractModelQuerySet
9+
from django.core.exceptions import ValidationError
510
from django.core.validators import MinValueValidator
611
from django.db import models
712

@@ -15,6 +20,22 @@ def __str__(self):
1520
return str(self.title)
1621

1722

23+
@dataclasses.dataclass
24+
class SitemapGraph:
25+
id: str
26+
parent_id: str | None
27+
route_code: str
28+
29+
parent: SitemapGraph | None = None
30+
children: list[SitemapGraph] = dataclasses.field(default_factory=list)
31+
32+
@property
33+
def route(self) -> str:
34+
if self.parent:
35+
return f"{self.parent.route}/{self.route_code}"
36+
return self.route_code
37+
38+
1839
class SitemapQuerySet(BaseAbstractModelQuerySet):
1940
def filter_by_today(self) -> typing.Self:
2041
now = datetime.datetime.now()
@@ -23,12 +44,31 @@ def filter_by_today(self) -> typing.Self:
2344
models.Q(display_end_at__isnull=True) | models.Q(display_end_at__gte=now),
2445
)
2546

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

2766
class Sitemap(BaseAbstractModel):
2867
parent_sitemap = models.ForeignKey(
29-
"self", null=True, default=None, on_delete=models.SET_NULL, related_name="children"
68+
"self", null=True, blank=True, default=None, on_delete=models.SET_NULL, related_name="children"
3069
)
3170

71+
route_code = models.CharField(max_length=256, blank=True)
3272
name = models.CharField(max_length=256)
3373
order = models.IntegerField(default=0, validators=[MinValueValidator(0)])
3474
page = models.ForeignKey(Page, on_delete=models.PROTECT)
@@ -42,7 +82,34 @@ class Meta:
4282
ordering = ["order"]
4383

4484
def __str__(self):
45-
return str(self.name)
85+
return f"{self.route} ({self.name})"
86+
87+
@property
88+
def route(self) -> str:
89+
"""주의: 이 속성은 N+1 쿼리를 발생시킵니다. 절때 API 응답에서 사용하지 마세요."""
90+
if self.parent_sitemap:
91+
return f"{self.parent_sitemap.route}/{self.route_code}"
92+
return self.route_code
93+
94+
def clean(self) -> None:
95+
# route_code는 URL-Safe하도록 알파벳, 숫자, 언더바(_)로만 구성되어야 함
96+
if not re.match(r"^[a-zA-Z0-9_-]*$", self.route_code):
97+
raise ValidationError("route_code는 알파벳, 숫자, 언더바(_)로만 구성되어야 합니다.")
98+
99+
# Parent Sitemap과 Page가 같을 경우 ValidationError 발생
100+
if self.parent_sitemap_id and self.parent_sitemap_id == self.id:
101+
raise ValidationError("자기 자신을 부모로 설정할 수 없습니다.")
102+
103+
# 순환 참조를 방지하기 위해 Parent Sitemap이 자식 Sitemap을 가리키는 경우 ValidationError 발생
104+
parent_sitemap = self.parent_sitemap
105+
while parent_sitemap:
106+
if parent_sitemap == self:
107+
raise ValidationError("Parent Sitemap이 자식 Sitemap을 가리킬 수 없습니다.")
108+
parent_sitemap = parent_sitemap.parent_sitemap
109+
110+
# route를 계산할 시 이미 존재하는 route가 있을 경우 ValidationError 발생
111+
if self.route in Sitemap.objects.get_all_routes():
112+
raise ValidationError(f"`{self.route}`라우트는 이미 존재하는 route입니다.")
46113

47114

48115
class Section(BaseAbstractModel):

app/cms/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
class SitemapSerializer(serializers.ModelSerializer):
66
class Meta:
77
model = Sitemap
8-
fields = ("id", "parent_sitemap", "name", "order", "page")
8+
fields = ("id", "parent_sitemap", "route_code", "name", "order", "page")
99

1010

1111
class SectionSerializer(serializers.ModelSerializer):

app/cms/test/page_api_test.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,6 @@
44
from django.urls import reverse
55

66

7-
@pytest.mark.django_db
8-
def test_list_view(api_client, create_page):
9-
url = reverse("v1:cms-page-list")
10-
response = api_client.get(url)
11-
assert response.status_code == http.HTTPStatus.OK
12-
13-
147
@pytest.mark.django_db
158
def test_retrieve_view(api_client, create_page):
169
url = reverse("v1:cms-page-detail", kwargs={"pk": create_page.id})

0 commit comments

Comments
 (0)