Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ local-collectstatic:
local-shell:
@ENV_PATH=envfile/.env.local uv run python app/manage.py shell

# Run django shell plus
local-shell-plus:
@ENV_PATH=envfile/.env.local uv run python app/manage.py shell_plus

# Run django makemigrations
local-makemigrations:
@ENV_PATH=envfile/.env.local uv run python app/manage.py makemigrations
Expand All @@ -68,6 +72,10 @@ local-migrate:
local-createsuperuser:
@ENV_PATH=envfile/.env.local uv run python app/manage.py createsuperuser

# Run pytest
local-test:
@ENV_PATH=envfile/.env.local cd app && uv run pytest -v

# Devtools
hooks-install: local-setup
uv run pre-commit install
Expand Down
14 changes: 12 additions & 2 deletions app/cms/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,18 @@ class Meta:

@admin.register(Sitemap)
class SitemapAdmin(RelatedReadonlyFieldsMixin, admin.ModelAdmin):
fields = ["id", "parent_sitemap", "page", "name", "order", "display_start_at", "display_end_at"]
readonly_fields = ["id"]
fields = [
"id",
"parent_sitemap",
"route_code",
"route",
"page",
"name",
"order",
"display_start_at",
"display_end_at",
]
readonly_fields = ["id", "route"]
related_readonly_config = {
"page": ["id", "is_active", "css", "title", "subtitle"],
"parent_sitemap": ["id", "name", "order", "display_start_at", "display_end_at"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 5.2 on 2025-05-18 22:40

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("cms", "0004_alter_section_options_alter_sitemap_options_and_more"),
]

operations = [
migrations.AddField(
model_name="historicalsitemap",
name="route_code",
field=models.CharField(default="", max_length=256, blank=True),
preserve_default=False,
),
migrations.AddField(
model_name="sitemap",
name="route_code",
field=models.CharField(default="", max_length=256, blank=True),
preserve_default=False,
),
]
25 changes: 25 additions & 0 deletions app/cms/migrations/0006_alter_sitemap_parent_sitemap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 5.2 on 2025-05-18 22:53

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("cms", "0005_historicalsitemap_route_code_sitemap_route_code"),
]

operations = [
migrations.AlterField(
model_name="sitemap",
name="parent_sitemap",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="children",
to="cms.sitemap",
),
),
]
71 changes: 69 additions & 2 deletions app/cms/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from __future__ import annotations

import dataclasses
import datetime
import re
import typing

from core.models import BaseAbstractModel, BaseAbstractModelQuerySet
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models

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


@dataclasses.dataclass
class SitemapGraph:
id: str
parent_id: str | None
route_code: str

parent: SitemapGraph | None = None
children: list[SitemapGraph] = dataclasses.field(default_factory=list)

@property
def route(self) -> str:
if self.parent:
return f"{self.parent.route}/{self.route_code}"
return self.route_code


class SitemapQuerySet(BaseAbstractModelQuerySet):
def filter_by_today(self) -> typing.Self:
now = datetime.datetime.now()
Expand All @@ -23,12 +44,31 @@ def filter_by_today(self) -> typing.Self:
models.Q(display_end_at__isnull=True) | models.Q(display_end_at__gte=now),
)

def get_all_routes(self) -> set[str]:
flattened_graph: dict[str, SitemapGraph] = {
id: SitemapGraph(id=id, parent_id=parent_id, route_code=route_code)
for id, parent_id, route_code in self.all().values_list("id", "parent_sitemap_id", "route_code")
}
roots: list[SitemapGraph] = []

for node in flattened_graph.values():
if node.parent_id is None:
roots.append(node)
continue

parent_node = flattened_graph[node.parent_id]
node.parent = parent_node
parent_node.children.append(node)

return {node.route for node in flattened_graph.values()}


class Sitemap(BaseAbstractModel):
parent_sitemap = models.ForeignKey(
"self", null=True, default=None, on_delete=models.SET_NULL, related_name="children"
"self", null=True, blank=True, default=None, on_delete=models.SET_NULL, related_name="children"
)

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

def __str__(self):
return str(self.name)
return f"{self.route} ({self.name})"

@property
def route(self) -> str:
"""주의: 이 속성은 N+1 쿼리를 발생시킵니다. 절때 API 응답에서 사용하지 마세요."""
if self.parent_sitemap:
return f"{self.parent_sitemap.route}/{self.route_code}"
return self.route_code

def clean(self) -> None:
# route_code는 URL-Safe하도록 알파벳, 숫자, 언더바(_)로만 구성되어야 함
if not re.match(r"^[a-zA-Z0-9_-]*$", self.route_code):
raise ValidationError("route_code는 알파벳, 숫자, 언더바(_)로만 구성되어야 합니다.")

# Parent Sitemap과 Page가 같을 경우 ValidationError 발생
if self.parent_sitemap_id and self.parent_sitemap_id == self.id:
raise ValidationError("자기 자신을 부모로 설정할 수 없습니다.")

# 순환 참조를 방지하기 위해 Parent Sitemap이 자식 Sitemap을 가리키는 경우 ValidationError 발생
parent_sitemap = self.parent_sitemap
while parent_sitemap:
if parent_sitemap == self:
raise ValidationError("Parent Sitemap이 자식 Sitemap을 가리킬 수 없습니다.")
parent_sitemap = parent_sitemap.parent_sitemap

# route를 계산할 시 이미 존재하는 route가 있을 경우 ValidationError 발생
if self.route in Sitemap.objects.get_all_routes():
raise ValidationError(f"`{self.route}`라우트는 이미 존재하는 route입니다.")


class Section(BaseAbstractModel):
Expand Down
2 changes: 1 addition & 1 deletion app/cms/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
class SitemapSerializer(serializers.ModelSerializer):
class Meta:
model = Sitemap
fields = ("id", "parent_sitemap", "name", "order", "page")
fields = ("id", "parent_sitemap", "route_code", "name", "order", "page")


class SectionSerializer(serializers.ModelSerializer):
Expand Down
7 changes: 0 additions & 7 deletions app/cms/test/page_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,6 @@
from django.urls import reverse


@pytest.mark.django_db
def test_list_view(api_client, create_page):
url = reverse("v1:cms-page-list")
response = api_client.get(url)
assert response.status_code == http.HTTPStatus.OK


@pytest.mark.django_db
def test_retrieve_view(api_client, create_page):
url = reverse("v1:cms-page-detail", kwargs={"pk": create_page.id})
Expand Down
Loading