diff --git a/Makefile b/Makefile index 8e09600..efb5d11 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/app/cms/admin.py b/app/cms/admin.py index 2d0a511..c5e713f 100644 --- a/app/cms/admin.py +++ b/app/cms/admin.py @@ -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"], diff --git a/app/cms/migrations/0005_historicalsitemap_route_code_sitemap_route_code.py b/app/cms/migrations/0005_historicalsitemap_route_code_sitemap_route_code.py new file mode 100644 index 0000000..30d28ba --- /dev/null +++ b/app/cms/migrations/0005_historicalsitemap_route_code_sitemap_route_code.py @@ -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, + ), + ] diff --git a/app/cms/migrations/0006_alter_sitemap_parent_sitemap.py b/app/cms/migrations/0006_alter_sitemap_parent_sitemap.py new file mode 100644 index 0000000..130c21f --- /dev/null +++ b/app/cms/migrations/0006_alter_sitemap_parent_sitemap.py @@ -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", + ), + ), + ] diff --git a/app/cms/models.py b/app/cms/models.py index b7ce393..6876768 100644 --- a/app/cms/models.py +++ b/app/cms/models.py @@ -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 @@ -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() @@ -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) @@ -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): diff --git a/app/cms/serializers.py b/app/cms/serializers.py index be3f50a..2fcfb74 100644 --- a/app/cms/serializers.py +++ b/app/cms/serializers.py @@ -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): diff --git a/app/cms/test/page_api_test.py b/app/cms/test/page_api_test.py index e652958..5b6744d 100644 --- a/app/cms/test/page_api_test.py +++ b/app/cms/test/page_api_test.py @@ -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}) diff --git a/app/cms/test/site_route_calculation_test.py b/app/cms/test/site_route_calculation_test.py new file mode 100644 index 0000000..5b16a5a --- /dev/null +++ b/app/cms/test/site_route_calculation_test.py @@ -0,0 +1,182 @@ +import pytest +from cms.models import Page, Sitemap +from django.core.exceptions import ValidationError + + +@pytest.mark.django_db +def test_route_calculation(): + # Create a root sitemap + root_sitemap = Sitemap.objects.create( + route_code="root", + name="Root Sitemap", + page=Page.objects.create(title="Root Page", subtitle="Root Subtitle"), + ) + + # Create a child sitemap + child_sitemap = Sitemap.objects.create( + route_code="child", + name="Child Sitemap", + parent_sitemap=root_sitemap, + page=Page.objects.create(title="Child Page", subtitle="Child Subtitle"), + ) + + # Create a grandchild sitemap + grandchild_sitemap = Sitemap.objects.create( + route_code="grandchild", + name="Grandchild Sitemap", + parent_sitemap=child_sitemap, + page=Page.objects.create(title="Grandchild Page", subtitle="Grandchild Subtitle"), + ) + + # Check the routes + assert root_sitemap.route == "root" + assert child_sitemap.route == "root/child" + assert grandchild_sitemap.route == "root/child/grandchild" + + +@pytest.mark.django_db +def test_get_all_routes(): + # Given: nested한 사이트맵 구조 생성 + data = { + "root_1": { + "child_1_1": {"child_1_1_1": {}, "child_1_1_2": {}}, + "child_1_2": {"child_1_2_1": {}}, + "child_1_3": {}, + }, + "root_2": { + "child_2_1": {"child_2_1_1": {}}, + "child_2_2": {}, + }, + } + + def create_sitemaps(data: dict[str, dict], parent: Sitemap = None) -> None: + for name, children in data.items(): + sitemap = Sitemap.objects.create( + route_code=name, + name=name, + page=Page.objects.create(title=name, subtitle=f"{name} Subtitle"), + parent_sitemap=parent, + ) + create_sitemaps(children, sitemap) + + create_sitemaps(data) + + # When: Sitemap.objects.get_all_routes() 메서드를 호출할 시 + all_routes = Sitemap.objects.get_all_routes() + + # Then: 예상한 모든 route가 나와야 한다. + assert all_routes == { + "root_1", + "root_1/child_1_1", + "root_1/child_1_1/child_1_1_1", + "root_1/child_1_1/child_1_1_2", + "root_1/child_1_2", + "root_1/child_1_2/child_1_2_1", + "root_1/child_1_3", + "root_2", + "root_2/child_2_1", + "root_2/child_2_1/child_2_1_1", + "root_2/child_2_2", + } + + +@pytest.mark.parametrize( + argnames=["route_code", "should_raise"], + argvalues=[ + ("valid_route", False), + ("", False), + ("/invalid_route", True), # 슬래시로 시작 + ("valid_route_123", False), + ("valid-route", False), + ("valid_route_123!", True), # 특수문자 포함 + ("valid_route_123@", True), # 특수문자 포함 + ], +) +@pytest.mark.django_db +def test_route_code_validation(route_code: str, should_raise: bool): + # Given: Sitemap 객체 생성 + sitemap = Sitemap( + route_code=route_code, + name="Test Sitemap", + page=Page.objects.create(title="Test Page", subtitle="Test Subtitle"), + ) + + # When: Validation을 수행 + if should_raise: + with pytest.raises(ValidationError) as excinfo: + sitemap.clean() + assert excinfo.value == "route_code는 알파벳, 숫자, 언더바(_)로만 구성되어야 합니다." + else: + sitemap.clean() + + +@pytest.mark.django_db +def test_clean_should_check_for_self_reference(): + # Given: Sitemap 객체 생성 + sitemap = Sitemap.objects.create( + route_code="self", + name="Self Sitemap", + page=Page.objects.create(title="Self Page", subtitle="Self Subtitle"), + ) + + # When: Self-reference를 만들기 위해 parent_sitemap을 자기 자신으로 설정 + sitemap.parent_sitemap = sitemap + + # Then: ValidationError가 발생해야 한다. + with pytest.raises(ValidationError) as excinfo: + sitemap.clean() + assert excinfo.value == "자기 자신을 부모로 설정할 수 없습니다." + + +@pytest.mark.django_db +def test_clean_should_check_for_circular_reference(): + # Given: Circular reference가 있는 Sitemap 객체 생성 + root_sitemap = Sitemap.objects.create( + route_code="root", + name="Root Sitemap", + page=Page.objects.create(title="Root Page", subtitle="Root Subtitle"), + ) + + child_sitemap = Sitemap.objects.create( + route_code="child", + name="Child Sitemap", + parent_sitemap=root_sitemap, + page=Page.objects.create(title="Child Page", subtitle="Child Subtitle"), + ) + + grandchild_sitemap = Sitemap.objects.create( + route_code="grandchild", + name="Grandchild Sitemap", + parent_sitemap=child_sitemap, + page=Page.objects.create(title="Grandchild Page", subtitle="Grandchild Subtitle"), + ) + + # When: Circular reference를 만들기 위해 child_sitemap을 root_sitemap의 parent로 설정 + root_sitemap.parent_sitemap = grandchild_sitemap + + # Then: ValidationError가 발생해야 한다. + with pytest.raises(ValidationError) as excinfo: + root_sitemap.clean() + assert excinfo.value == "Parent Sitemap이 자식 Sitemap을 가리킬 수 없습니다." + + +@pytest.mark.django_db +def test_clean_should_check_for_existing_route(): + # Given: 이미 존재하는 route를 가진 Sitemap 객체 생성 + Sitemap.objects.create( + route_code="existing", + name="Existing Sitemap", + page=Page.objects.create(title="Existing Page", subtitle="Existing Subtitle"), + ) + + # When: 새로운 Sitemap 객체를 생성하고, 기존의 route와 같은 route_code를 설정 + new_sitemap = Sitemap( + route_code="existing", + name="New Sitemap", + page=Page.objects.create(title="New Page", subtitle="New Subtitle"), + ) + + # Then: ValidationError가 발생해야 한다. + with pytest.raises(ValidationError) as excinfo: + new_sitemap.clean() + assert excinfo.value == "`existing`라우트는 이미 존재하는 route입니다." diff --git a/app/cms/test/sitemap_api_test.py b/app/cms/test/sitemap_api_test.py index 3566afe..0d6d0a8 100644 --- a/app/cms/test/sitemap_api_test.py +++ b/app/cms/test/sitemap_api_test.py @@ -10,11 +10,3 @@ def test_list_view(api_client, create_sitemap): response = api_client.get(url) if response.status_code != http.HTTPStatus.OK: raise Exception("cms Sitemap list API raised error") - - -@pytest.mark.django_db -def test_retrieve_view(api_client, create_sitemap): - url = reverse("v1:cms-sitemap-detail", kwargs={"pk": create_sitemap.id}) - response = api_client.get(url) - if response.status_code != http.HTTPStatus.OK: - raise Exception("cms Sitemap retrieve API raised error") diff --git a/pyproject.toml b/pyproject.toml index 21e80f8..d615534 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = "==3.12.*" dependencies = [ "argon2-cffi>=23.1.0", "awslambdaric>=3.0.2", + "boto3 (>=1.37.37,<2.0.0)", "django>=5.2", "django-constance>=4.3.2", "django-cors-headers>=4.7.0", @@ -27,7 +28,6 @@ dependencies = [ "setuptools>=78.1.0", "zappa>=0.59.0", "zappa-django-utils>=0.4.1", - "boto3 (>=1.37.37,<2.0.0)", ] [dependency-groups] @@ -37,6 +37,9 @@ dev = [ "djangorestframework-stubs[compatible-mypy]>=3.15.3", "ipython>=9.0.2", "pre-commit>=4.2.0", + "pytest>=8.3.5", + "pytest-django>=4.11.1", + "pytest-socket>=0.7.0", ] deployment = [ "boto3>=1.37.28", @@ -55,6 +58,7 @@ default-groups = ["dev", "deployment"] [tool.poetry.group.dev.dependencies] pytest = ">=8.3.5,<9.0.0" pytest-django = "^4.11.1" +pytest-socket = "^0.7.0" [build-system] requires = ["hatchling"] @@ -74,3 +78,9 @@ target-version = "py312" [tool.ruff.lint] fixable = ["ALL"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "core.settings" +norecursedirs = ".git migrations static template" +python_files = "*_test.py" +addopts = "--reuse-db --disable-socket -v" diff --git a/uv.lock b/uv.lock index a35e44a..c84623a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = "==3.12.*" [[package]] @@ -145,6 +144,9 @@ dev = [ { name = "djangorestframework-stubs", extra = ["compatible-mypy"] }, { name = "ipython" }, { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-django" }, + { name = "pytest-socket" }, ] [package.metadata] @@ -190,6 +192,9 @@ dev = [ { name = "djangorestframework-stubs", extras = ["compatible-mypy"], specifier = ">=3.15.3" }, { name = "ipython", specifier = ">=9.0.2" }, { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-django", specifier = ">=4.11.1" }, + { name = "pytest-socket", specifier = ">=0.7.0" }, ] [[package]] @@ -703,6 +708,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454 }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + [[package]] name = "ipython" version = "9.0.2" @@ -989,6 +1003,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + [[package]] name = "pre-commit" version = "4.2.0" @@ -1089,6 +1112,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "pytest-django" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281 }, +] + +[[package]] +name = "pytest-socket" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"