From bb0157b374da61612fdecea0cc544e2e0cb76c4b Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Fri, 8 May 2026 08:29:56 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20DomainGroup=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Sitemap=EA=B3=BC=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0,=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cms/apps.py | 3 +- .../0013_domain_group_and_sitemap_link.py | 276 ++++++++++++++++++ app/cms/models.py | 60 +++- app/core/const/regex.py | 4 + app/core/settings.py | 1 + 5 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 app/cms/migrations/0013_domain_group_and_sitemap_link.py diff --git a/app/cms/apps.py b/app/cms/apps.py index 0dc102b..337e2a0 100644 --- a/app/cms/apps.py +++ b/app/cms/apps.py @@ -9,9 +9,10 @@ class CmsConfig(AppConfig): def ready(self): importlib.import_module("cms.translation") - from cms.models import Page, Section, Sitemap + from cms.models import DomainGroup, Page, Section, Sitemap from simple_history import register register(Page) register(Sitemap) register(Section) + register(DomainGroup) diff --git a/app/cms/migrations/0013_domain_group_and_sitemap_link.py b/app/cms/migrations/0013_domain_group_and_sitemap_link.py new file mode 100644 index 0000000..e1f29dc --- /dev/null +++ b/app/cms/migrations/0013_domain_group_and_sitemap_link.py @@ -0,0 +1,276 @@ +"""DomainGroup 추가 + Sitemap.domain_group 연결 + DomainGroup.domains 그룹 간 중복 방지 trigger. + +진행 순서: +1. (schema) DomainGroup / HistoricalDomainGroup 생성, Sitemap.domain_group 일시 nullable로 추가 +2. (data) "2025년 PyConKR 홈페이지" 그룹 생성 후 모든 기존 Sitemap을 해당 그룹에 연결 +3. (schema) Sitemap.domain_group을 NOT NULL로 변경, route_code unique constraint를 도메인 그룹 단위로 교체 +4. (sql) DomainGroup.domains 그룹 간 중복을 막는 trigger + advisory lock 설치 — race-safe DB-level 강제 +""" + +import uuid + +import django.contrib.postgres.fields +import django.contrib.postgres.indexes +import django.core.validators +import django.db.models.deletion +import simple_history.models +from core.const.regex import HOSTNAME_PATTERN +from django.conf import settings +from django.db import migrations, models + +_HOSTNAME_MESSAGE = "올바른 호스트 형식이 아닙니다 (스킴/포트/경로/쿼리는 포함할 수 없습니다)." + +DEFAULT_DOMAIN_GROUP_NAME = "2025년 PyConKR 홈페이지" +DEFAULT_DOMAIN_GROUP_DOMAINS = ["2025.pycon.kr"] + +# advisory lock: READ COMMITTED 격리수준에서 동시 INSERT/UPDATE가 서로의 미커밋 row를 못 보는 문제를 +# 해결하기 위해 모든 DomainGroup writer를 직렬화한다. +_CREATE_OVERLAP_TRIGGER = """ +CREATE OR REPLACE FUNCTION cms_domain_group_check_overlap() RETURNS trigger AS $$ +BEGIN + IF NEW.deleted_at IS NULL THEN + PERFORM pg_advisory_xact_lock(hashtext('cms_domaingroup_overlap')); + IF EXISTS ( + SELECT 1 FROM cms_domaingroup + WHERE id <> NEW.id + AND deleted_at IS NULL + AND domains && NEW.domains + ) THEN + RAISE EXCEPTION 'cms_domaingroup_domains_no_overlap' + USING ERRCODE = 'unique_violation'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER cms_domaingroup_overlap_check +BEFORE INSERT OR UPDATE OF domains, deleted_at ON cms_domaingroup +FOR EACH ROW EXECUTE FUNCTION cms_domain_group_check_overlap(); +""" + +_DROP_OVERLAP_TRIGGER = """ +DROP TRIGGER IF EXISTS cms_domaingroup_overlap_check ON cms_domaingroup; +DROP FUNCTION IF EXISTS cms_domain_group_check_overlap(); +""" + + +def seed_default_domain_group(apps, schema_editor): + DomainGroup = apps.get_model("cms", "DomainGroup") + Sitemap = apps.get_model("cms", "Sitemap") + + group, _ = DomainGroup.objects.get_or_create( + name=DEFAULT_DOMAIN_GROUP_NAME, + defaults={"domains": DEFAULT_DOMAIN_GROUP_DOMAINS}, + ) + Sitemap.objects.filter(domain_group__isnull=True).update(domain_group=group) + + +def unseed_default_domain_group(apps, schema_editor): + DomainGroup = apps.get_model("cms", "DomainGroup") + Sitemap = apps.get_model("cms", "Sitemap") + + Sitemap.objects.filter(domain_group__name=DEFAULT_DOMAIN_GROUP_NAME).update(domain_group=None) + DomainGroup.objects.filter(name=DEFAULT_DOMAIN_GROUP_NAME).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0012_alter_historicalsection_body_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # ── 1단계: 스키마 추가 (Sitemap.domain_group은 일시 nullable) ────────────── + migrations.CreateModel( + name="DomainGroup", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("name", models.CharField(help_text="예: '2025년 PyConKR 홈페이지'", max_length=128)), + ( + "domains", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + max_length=253, + validators=[ + django.core.validators.RegexValidator(regex=HOSTNAME_PATTERN, message=_HOSTNAME_MESSAGE) + ], + ), + help_text="이 그룹에 속한 frontend 도메인 호스트 목록 (스킴/포트/경로 제외).", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"abstract": False}, + ), + migrations.AddIndex( + model_name="domaingroup", + index=django.contrib.postgres.indexes.GinIndex(fields=["domains"], name="cms_domaing_domains_407bdc_gin"), + ), + migrations.AddConstraint( + model_name="domaingroup", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("name",), + name="uq__domain_group__name", + ), + ), + migrations.CreateModel( + name="HistoricalDomainGroup", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("name", models.CharField(help_text="예: '2025년 PyConKR 홈페이지'", max_length=128)), + ( + "domains", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + max_length=253, + validators=[ + django.core.validators.RegexValidator(regex=HOSTNAME_PATTERN, message=_HOSTNAME_MESSAGE) + ], + ), + help_text="이 그룹에 속한 frontend 도메인 호스트 목록 (스킴/포트/경로 제외).", + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical domain group", + "verbose_name_plural": "historical domain groups", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddField( + model_name="sitemap", + name="domain_group", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="sitemaps", + to="cms.domaingroup", + help_text="이 Sitemap이 노출될 frontend 도메인 그룹", + ), + ), + migrations.AddField( + model_name="historicalsitemap", + name="domain_group", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="cms.domaingroup", + help_text="이 Sitemap이 노출될 frontend 도메인 그룹", + ), + ), + # ── 2단계: 데이터 마이그레이션 ─────────────────────────────────────── + migrations.RunPython(seed_default_domain_group, reverse_code=unseed_default_domain_group), + # ── 3단계: NOT NULL로 변경 + unique constraint 교체 ────────────────── + migrations.AlterField( + model_name="sitemap", + name="domain_group", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="sitemaps", + to="cms.domaingroup", + help_text="이 Sitemap이 노출될 frontend 도메인 그룹", + ), + ), + migrations.RemoveConstraint(model_name="sitemap", name="uq__sitemap__parent_route_code"), + migrations.AddConstraint( + model_name="sitemap", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("domain_group", "parent_sitemap", "route_code"), + name="uq__sitemap__domain_parent_route_code", + ), + ), + # ── 4단계: race-safe DB-level 중복 방지 trigger ──────────────────── + migrations.RunSQL(sql=_CREATE_OVERLAP_TRIGGER, reverse_sql=_DROP_OVERLAP_TRIGGER), + ] diff --git a/app/cms/models.py b/app/cms/models.py index 22294f9..9cd321f 100644 --- a/app/cms/models.py +++ b/app/cms/models.py @@ -6,13 +6,47 @@ import functools import re import typing +import uuid +from core.const.regex import HOSTNAME_PATTERN from core.models import BaseAbstractModel, BaseAbstractModelQuerySet, MarkdownField +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import GinIndex from django.core.exceptions import ValidationError -from django.core.validators import MinValueValidator +from django.core.validators import MinValueValidator, RegexValidator from django.db import models +class DomainGroup(BaseAbstractModel): + name = models.CharField(max_length=128, help_text="예: '2025년 PyConKR 홈페이지'") + domains = ArrayField( + models.CharField( + max_length=253, + validators=[ + RegexValidator( + regex=HOSTNAME_PATTERN, + message="올바른 호스트 형식이 아닙니다 (스킴/포트/경로/쿼리는 포함할 수 없습니다).", + ) + ], + ), + blank=False, + help_text="이 그룹에 속한 frontend 도메인 호스트 목록 (스킴/포트/경로 제외).", + ) + + class Meta: + indexes = [GinIndex(fields=["domains"])] + constraints = [ + models.UniqueConstraint( + fields=["name"], + name="uq__domain_group__name", + condition=models.Q(deleted_at__isnull=True), + ), + ] + + def __str__(self): + return f"{self.name} ({', '.join(self.domains) or '없음'})" + + class Page(BaseAbstractModel): css = models.TextField(null=True, blank=True, default=None) title = models.CharField(max_length=256) @@ -57,10 +91,17 @@ 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]: + def filter_by_domain(self, domain: str | None) -> typing.Self: + if not domain: + return self.none() + return self.filter(domain_group__domains__contains=[domain]) + + def get_all_routes(self, domain_group_id: uuid.UUID) -> 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") + for id, parent_id, route_code in self.filter(domain_group_id=domain_group_id).values_list( + "id", "parent_sitemap_id", "route_code" + ) } roots: list[SitemapGraph] = [] @@ -80,6 +121,12 @@ class Sitemap(BaseAbstractModel): parent_sitemap = models.ForeignKey( "self", null=True, blank=True, default=None, on_delete=models.SET_NULL, related_name="children" ) + domain_group = models.ForeignKey( + DomainGroup, + on_delete=models.PROTECT, + related_name="sitemaps", + help_text="이 Sitemap이 노출될 frontend 도메인 그룹", + ) route_code = models.CharField(max_length=256, blank=True) name = models.CharField(max_length=256) @@ -103,8 +150,8 @@ class Meta: ordering = ["order"] constraints = [ models.UniqueConstraint( - fields=["parent_sitemap", "route_code"], - name="uq__sitemap__parent_route_code", + fields=["domain_group", "parent_sitemap", "route_code"], + name="uq__sitemap__domain_parent_route_code", condition=models.Q(deleted_at__isnull=True), ), ] @@ -136,7 +183,8 @@ def clean(self) -> None: parent_sitemap = parent_sitemap.parent_sitemap # route를 계산할 시 이미 존재하는 route가 있을 경우 ValidationError 발생 - if self.route in Sitemap.objects.get_all_routes(): + # (도메인 그룹이 다르면 같은 route_code 허용 — 그룹 내에서만 검증) + if self.domain_group_id and self.route in Sitemap.objects.get_all_routes(self.domain_group_id): raise ValidationError(f"`{self.route}`라우트는 이미 존재하는 route입니다.") diff --git a/app/core/const/regex.py b/app/core/const/regex.py index 5dcf505..ba41be0 100644 --- a/app/core/const/regex.py +++ b/app/core/const/regex.py @@ -2,3 +2,7 @@ UUID_V4_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" UUID_V4_REGEX = re.compile(f"^{UUID_V4_PATTERN}$", re.IGNORECASE) + +# 호스트 형식 — RFC 1035 기반 +HOSTNAME_PATTERN = r"^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*)(\.[a-z0-9](-?[a-z0-9])*)*$" +HOSTNAME_REGEX = re.compile(HOSTNAME_PATTERN) diff --git a/app/core/settings.py b/app/core/settings.py index f4ae3ef..ca1e8a6 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -95,6 +95,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.postgres", # CORS "corsheaders", # django-rest-framework From 6c27035b942a0fcdc69c584e88749b74f32c7c3b Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Fri, 8 May 2026 08:30:23 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=EC=9D=84=20CMS=EC=97=90=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=82=AC=EC=9D=B4=ED=8A=B8=EB=A7=B5=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=83=9D=EC=84=B1=20=EC=8B=9C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B7=B8=EB=A3=B9=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cms/test/conftest.py | 18 +-- app/cms/test/domain_group_validation_test.py | 33 +++++ app/cms/test/site_route_calculation_test.py | 67 +++++++-- app/cms/test/sitemap_api_test.py | 136 ++++++++++++++++++- app/cms/views.py | 44 +++++- 5 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 app/cms/test/domain_group_validation_test.py diff --git a/app/cms/test/conftest.py b/app/cms/test/conftest.py index 9f56a32..d41b27a 100644 --- a/app/cms/test/conftest.py +++ b/app/cms/test/conftest.py @@ -1,5 +1,5 @@ import pytest -from cms.models import Page, Sitemap +from cms.models import DomainGroup, Page, Sitemap from rest_framework.test import APIClient @@ -10,14 +10,14 @@ def api_client(): @pytest.fixture def create_page(): - page = Page.objects.create( - title="제목", - subtitle="부제목", - ) - return page + return Page.objects.create(title="제목", subtitle="부제목") @pytest.fixture -def create_sitemap(create_page): - sitemap = Sitemap.objects.create(page=create_page) - return sitemap +def create_domain_group(): + return DomainGroup.objects.create(name="테스트 그룹", domains=["test.pycon.kr"]) + + +@pytest.fixture +def create_sitemap(create_page, create_domain_group): + return Sitemap.objects.create(page=create_page, domain_group=create_domain_group) diff --git a/app/cms/test/domain_group_validation_test.py b/app/cms/test/domain_group_validation_test.py new file mode 100644 index 0000000..abbd309 --- /dev/null +++ b/app/cms/test/domain_group_validation_test.py @@ -0,0 +1,33 @@ +import pytest +from core.const.regex import HOSTNAME_REGEX + + +@pytest.mark.parametrize( + "domain", + [ + "pycon.kr", + "2025.pycon.kr", + "a", + "sub.domain.example.com", + ], +) +def test_hostname_re_accepts_valid(domain): + assert HOSTNAME_REGEX.match(domain) + + +@pytest.mark.parametrize( + "domain", + [ + "https://pycon.kr", # 스킴 + "pycon.kr:8080", # 포트 + "pycon.kr/path", # 경로 + "pycon.kr?q=1", # 쿼리 + "pycon..kr", # 연속 점 + "-pycon.kr", # 하이픈으로 시작 + "pycon.kr-", # 하이픈으로 끝 + "PYCON.KR", # 대문자 (정규화 안 된 입력은 거부) + " pycon.kr", # 공백 (정규화 안 된 입력은 거부) + ], +) +def test_hostname_re_rejects_invalid(domain): + assert not HOSTNAME_REGEX.match(domain) diff --git a/app/cms/test/site_route_calculation_test.py b/app/cms/test/site_route_calculation_test.py index 5b16a5a..a934348 100644 --- a/app/cms/test/site_route_calculation_test.py +++ b/app/cms/test/site_route_calculation_test.py @@ -1,15 +1,21 @@ import pytest -from cms.models import Page, Sitemap +from cms.models import DomainGroup, Page, Sitemap from django.core.exceptions import ValidationError +@pytest.fixture +def domain_group(db): + return DomainGroup.objects.create(name="테스트 그룹", domains=["test.pycon.kr"]) + + @pytest.mark.django_db -def test_route_calculation(): +def test_route_calculation(domain_group): # 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"), + domain_group=domain_group, ) # Create a child sitemap @@ -18,6 +24,7 @@ def test_route_calculation(): name="Child Sitemap", parent_sitemap=root_sitemap, page=Page.objects.create(title="Child Page", subtitle="Child Subtitle"), + domain_group=domain_group, ) # Create a grandchild sitemap @@ -26,6 +33,7 @@ def test_route_calculation(): name="Grandchild Sitemap", parent_sitemap=child_sitemap, page=Page.objects.create(title="Grandchild Page", subtitle="Grandchild Subtitle"), + domain_group=domain_group, ) # Check the routes @@ -35,7 +43,7 @@ def test_route_calculation(): @pytest.mark.django_db -def test_get_all_routes(): +def test_get_all_routes(domain_group): # Given: nested한 사이트맵 구조 생성 data = { "root_1": { @@ -56,13 +64,14 @@ def create_sitemaps(data: dict[str, dict], parent: Sitemap = None) -> None: name=name, page=Page.objects.create(title=name, subtitle=f"{name} Subtitle"), parent_sitemap=parent, + domain_group=domain_group, ) create_sitemaps(children, sitemap) create_sitemaps(data) - # When: Sitemap.objects.get_all_routes() 메서드를 호출할 시 - all_routes = Sitemap.objects.get_all_routes() + # When: Sitemap.objects.get_all_routes(domain_group_id) 메서드를 호출할 시 + all_routes = Sitemap.objects.get_all_routes(domain_group.id) # Then: 예상한 모든 route가 나와야 한다. assert all_routes == { @@ -80,6 +89,39 @@ def create_sitemaps(data: dict[str, dict], parent: Sitemap = None) -> None: } +@pytest.mark.django_db +def test_get_all_routes_is_scoped_per_domain_group(): + # Given: 두 도메인 그룹에 동일한 route_code를 가진 사이트맵을 만들어도 충돌하지 않아야 한다 + group_a = DomainGroup.objects.create(name="A", domains=["a.pycon.kr"]) + group_b = DomainGroup.objects.create(name="B", domains=["b.pycon.kr"]) + + Sitemap.objects.create( + route_code="about", + name="A about", + page=Page.objects.create(title="A", subtitle="A"), + domain_group=group_a, + ) + sitemap_b = Sitemap( + route_code="about", + name="B about", + page=Page.objects.create(title="B", subtitle="B"), + domain_group=group_b, + ) + + # 다른 그룹이라 clean()이 통과해야 한다 + sitemap_b.clean() + + # 같은 그룹이라면 통과하면 안 된다 + sitemap_a_dup = Sitemap( + route_code="about", + name="A about dup", + page=Page.objects.create(title="A", subtitle="A"), + domain_group=group_a, + ) + with pytest.raises(ValidationError): + sitemap_a_dup.clean() + + @pytest.mark.parametrize( argnames=["route_code", "should_raise"], argvalues=[ @@ -93,12 +135,13 @@ def create_sitemaps(data: dict[str, dict], parent: Sitemap = None) -> None: ], ) @pytest.mark.django_db -def test_route_code_validation(route_code: str, should_raise: bool): +def test_route_code_validation(route_code: str, should_raise: bool, domain_group): # Given: Sitemap 객체 생성 sitemap = Sitemap( route_code=route_code, name="Test Sitemap", page=Page.objects.create(title="Test Page", subtitle="Test Subtitle"), + domain_group=domain_group, ) # When: Validation을 수행 @@ -111,12 +154,13 @@ def test_route_code_validation(route_code: str, should_raise: bool): @pytest.mark.django_db -def test_clean_should_check_for_self_reference(): +def test_clean_should_check_for_self_reference(domain_group): # Given: Sitemap 객체 생성 sitemap = Sitemap.objects.create( route_code="self", name="Self Sitemap", page=Page.objects.create(title="Self Page", subtitle="Self Subtitle"), + domain_group=domain_group, ) # When: Self-reference를 만들기 위해 parent_sitemap을 자기 자신으로 설정 @@ -129,12 +173,13 @@ def test_clean_should_check_for_self_reference(): @pytest.mark.django_db -def test_clean_should_check_for_circular_reference(): +def test_clean_should_check_for_circular_reference(domain_group): # 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"), + domain_group=domain_group, ) child_sitemap = Sitemap.objects.create( @@ -142,6 +187,7 @@ def test_clean_should_check_for_circular_reference(): name="Child Sitemap", parent_sitemap=root_sitemap, page=Page.objects.create(title="Child Page", subtitle="Child Subtitle"), + domain_group=domain_group, ) grandchild_sitemap = Sitemap.objects.create( @@ -149,6 +195,7 @@ def test_clean_should_check_for_circular_reference(): name="Grandchild Sitemap", parent_sitemap=child_sitemap, page=Page.objects.create(title="Grandchild Page", subtitle="Grandchild Subtitle"), + domain_group=domain_group, ) # When: Circular reference를 만들기 위해 child_sitemap을 root_sitemap의 parent로 설정 @@ -161,12 +208,13 @@ def test_clean_should_check_for_circular_reference(): @pytest.mark.django_db -def test_clean_should_check_for_existing_route(): +def test_clean_should_check_for_existing_route(domain_group): # Given: 이미 존재하는 route를 가진 Sitemap 객체 생성 Sitemap.objects.create( route_code="existing", name="Existing Sitemap", page=Page.objects.create(title="Existing Page", subtitle="Existing Subtitle"), + domain_group=domain_group, ) # When: 새로운 Sitemap 객체를 생성하고, 기존의 route와 같은 route_code를 설정 @@ -174,6 +222,7 @@ def test_clean_should_check_for_existing_route(): route_code="existing", name="New Sitemap", page=Page.objects.create(title="New Page", subtitle="New Subtitle"), + domain_group=domain_group, ) # Then: ValidationError가 발생해야 한다. diff --git a/app/cms/test/sitemap_api_test.py b/app/cms/test/sitemap_api_test.py index 0d6d0a8..49595d1 100644 --- a/app/cms/test/sitemap_api_test.py +++ b/app/cms/test/sitemap_api_test.py @@ -1,12 +1,144 @@ import http import pytest +from cms.models import DomainGroup, Page, Sitemap from django.urls import reverse +def _create_sitemap(route_code: str, *, group: DomainGroup) -> Sitemap: + return Sitemap.objects.create( + route_code=route_code, + name=route_code, + page=Page.objects.create(title=route_code, subtitle=route_code), + domain_group=group, + ) + + @pytest.mark.django_db def test_list_view(api_client, create_sitemap): + url = reverse("v1:cms-sitemap-list") + response = api_client.get(url, {"frontend-domain": "test.pycon.kr"}) + assert response.status_code == http.HTTPStatus.OK + + +@pytest.mark.django_db +def test_list_view_returns_only_matching_domain(api_client): + group_main = DomainGroup.objects.create(name="main", domains=["pycon.kr"]) + group_legacy = DomainGroup.objects.create(name="legacy", domains=["2025.pycon.kr"]) + + _create_sitemap("main_about", group=group_main) + _create_sitemap("legacy_about", group=group_legacy) + + url = reverse("v1:cms-sitemap-list") + response = api_client.get(url, {"frontend-domain": "pycon.kr"}) + + assert response.status_code == http.HTTPStatus.OK + route_codes = {item["route_code"] for item in response.data} + assert route_codes == {"main_about"} + + +@pytest.mark.django_db +def test_list_view_returns_404_when_no_domain_context(api_client, create_sitemap): url = reverse("v1:cms-sitemap-list") response = api_client.get(url) - if response.status_code != http.HTTPStatus.OK: - raise Exception("cms Sitemap list API raised error") + assert response.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_list_view_returns_empty_when_domain_does_not_match(api_client): + group = DomainGroup.objects.create(name="g", domains=["pycon.kr"]) + _create_sitemap("about", group=group) + + url = reverse("v1:cms-sitemap-list") + response = api_client.get(url, {"frontend-domain": "other.kr"}) + assert response.data == [] + + +@pytest.mark.django_db +def test_priority_query_param_wins_over_header(api_client): + main = DomainGroup.objects.create(name="main", domains=["pycon.kr"]) + other = DomainGroup.objects.create(name="other", domains=["other.kr"]) + _create_sitemap("main_route", group=main) + _create_sitemap("other_route", group=other) + + url = reverse("v1:cms-sitemap-list") + # query=other.kr이지만 header=pycon.kr — query가 이겨서 other 그룹만 나와야 함 + response = api_client.get( + url, + {"frontend-domain": "other.kr"}, + HTTP_X_FRONTEND_DOMAIN="pycon.kr", + HTTP_ORIGIN="https://pycon.kr", + HTTP_REFERER="https://pycon.kr/foo", + ) + route_codes = {item["route_code"] for item in response.data} + assert route_codes == {"other_route"} + + +@pytest.mark.django_db +def test_priority_header_wins_over_origin(api_client): + main = DomainGroup.objects.create(name="main", domains=["pycon.kr"]) + other = DomainGroup.objects.create(name="other", domains=["other.kr"]) + _create_sitemap("main_route", group=main) + _create_sitemap("other_route", group=other) + + url = reverse("v1:cms-sitemap-list") + response = api_client.get( + url, + HTTP_X_FRONTEND_DOMAIN="other.kr", + HTTP_ORIGIN="https://pycon.kr", + HTTP_REFERER="https://pycon.kr/foo", + ) + route_codes = {item["route_code"] for item in response.data} + assert route_codes == {"other_route"} + + +@pytest.mark.django_db +def test_priority_origin_wins_over_referer(api_client): + main = DomainGroup.objects.create(name="main", domains=["pycon.kr"]) + other = DomainGroup.objects.create(name="other", domains=["other.kr"]) + _create_sitemap("main_route", group=main) + _create_sitemap("other_route", group=other) + + url = reverse("v1:cms-sitemap-list") + response = api_client.get( + url, + HTTP_ORIGIN="https://pycon.kr", + HTTP_REFERER="https://other.kr/foo", + ) + route_codes = {item["route_code"] for item in response.data} + assert route_codes == {"main_route"} + + +@pytest.mark.django_db +def test_referer_used_when_no_other_signals(api_client): + main = DomainGroup.objects.create(name="main", domains=["pycon.kr"]) + _create_sitemap("main_route", group=main) + + url = reverse("v1:cms-sitemap-list") + response = api_client.get(url, HTTP_REFERER="https://pycon.kr/some/path") + route_codes = {item["route_code"] for item in response.data} + assert route_codes == {"main_route"} + + +@pytest.mark.django_db +def test_normalization_strips_scheme_port_and_lowercases(api_client): + main = DomainGroup.objects.create(name="main", domains=["pycon.kr"]) + _create_sitemap("main_route", group=main) + + url = reverse("v1:cms-sitemap-list") + # 대문자 + 포트 + 스킴 + 경로가 모두 정규화되어 'pycon.kr'로 매칭되어야 함 + response = api_client.get(url, HTTP_ORIGIN="HTTPS://PYCON.KR:8080") + route_codes = {item["route_code"] for item in response.data} + assert route_codes == {"main_route"} + + +@pytest.mark.django_db +def test_serializer_does_not_expose_domain_group(api_client): + main = DomainGroup.objects.create(name="main", domains=["pycon.kr"]) + _create_sitemap("main_route", group=main) + + url = reverse("v1:cms-sitemap-list") + response = api_client.get(url, {"frontend-domain": "pycon.kr"}) + + assert response.data + assert "domain_group" not in response.data[0] diff --git a/app/cms/views.py b/app/cms/views.py index 1a42330..c780597 100644 --- a/app/cms/views.py +++ b/app/cms/views.py @@ -1,16 +1,52 @@ +from urllib.parse import urlparse + from cms.models import Page, Section, Sitemap from cms.serializers import PageSerializer, SitemapSerializer from core.const.tag import OpenAPITag from django.db import models from django.utils.decorators import method_decorator -from drf_spectacular.utils import extend_schema -from rest_framework import mixins, viewsets +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import exceptions, mixins, viewsets -@method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.CMS])) +@method_decorator( + name="list", + decorator=extend_schema( + tags=[OpenAPITag.CMS], + parameters=[ + OpenApiParameter( + name="frontend-domain", + type=str, + location=OpenApiParameter.QUERY, + required=False, + description=( + "Sitemap이 노출될 frontend 도메인.\n" + "이 값이 없으면 X-Frontend-Domain 헤더 → Origin → Referer 순으로 도메인을 결정합니다.\n" + "도메인을 결정할 수 없으면 404를 반환합니다.\n" + "도메인은 결정되었으나 매칭되는 그룹이 없으면 빈 결과를 반환합니다." + ), + ), + ], + ), +) class SitemapViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = SitemapSerializer - queryset = Sitemap.objects.filter_active().filter_by_today() + + def get_queryset(self): + raw = ( + ( + self.request.query_params.get("frontend-domain") + or self.request.headers.get("X-Frontend-Domain") + or self.request.headers.get("Origin") + or self.request.headers.get("Referer") + or "" + ) + .strip() + .lower() + ) + if not (host := urlparse(raw).hostname if "://" in raw else raw.split("/", 1)[0].split(":", 1)[0]): + raise exceptions.NotFound("frontend 도메인을 결정할 수 없습니다.") + return Sitemap.objects.filter_active().filter_by_today().filter_by_domain(host) @method_decorator(name="retrieve", decorator=extend_schema(tags=[OpenAPITag.CMS])) From 7a2db626dedd9187850add9b65e5eb1d891ebb1a Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Fri, 8 May 2026 08:30:38 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20API=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/cms.py | 54 +++++- app/admin_api/test/cms_test.py | 312 +++++++++++++++++++++++++++++++ app/admin_api/urls.py | 3 +- app/admin_api/views/cms.py | 37 +++- 4 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 app/admin_api/test/cms_test.py diff --git a/app/admin_api/serializers/cms.py b/app/admin_api/serializers/cms.py index 5bc87c8..8e079e0 100644 --- a/app/admin_api/serializers/cms.py +++ b/app/admin_api/serializers/cms.py @@ -1,16 +1,68 @@ import re -from cms.models import Page, Section, Sitemap +from cms.models import DomainGroup, Page, Section, Sitemap +from core.const.regex import HOSTNAME_REGEX from core.const.serializer import COMMON_ADMIN_FIELDS from core.serializer.base_abstract_serializer import BaseAbstractSerializer from core.serializer.json_schema_serializer import JsonSchemaSerializer +from django.db import IntegrityError, transaction from rest_framework import serializers +class DomainGroupAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + # DRF가 ArrayField를 자동 매핑하면 inner CharField의 validator를 raw input(정규화 전)에 적용해 정상 입력도 거부됨. + # 이를 막기 위해 inner validator 없는 ListField로 재정의하고, 정규화 + format 검증 + 그룹 간 중복 검증을 validate_domains에서 명시적으로 수행. + domains = serializers.ListField(child=serializers.CharField(), allow_empty=False) + + class Meta: + model = DomainGroup + fields = COMMON_ADMIN_FIELDS + ("name", "domains") + + def validate_domains(self, value: list[str]) -> list[str]: + if not (normalized := list({c for v in value if (c := v.strip().lower())})): + raise serializers.ValidationError("도메인 목록이 비어있을 수 없습니다.") + + if invalid := [d for d in normalized if not HOSTNAME_REGEX.match(d)]: + raise serializers.ValidationError( + [ + f"`{d}` 도메인이 올바른 호스트 형식이 아닙니다 (스킴/포트/경로/쿼리는 포함할 수 없습니다)." + for d in invalid + ] + ) + + overlap_qs = DomainGroup.objects.filter_active().filter(domains__overlap=normalized) + if self.instance and self.instance.pk: + overlap_qs = overlap_qs.exclude(pk=self.instance.pk) + + if conflict := overlap_qs.first(): + shared = sorted(set(normalized) & set(conflict.domains)) + err_msg = f"`{', '.join(shared)}` 도메인이 이미 `{conflict.name}` 그룹에 등록되어 있습니다." + raise serializers.ValidationError(err_msg) + + return normalized + + @transaction.atomic + def save(self, **kwargs): + try: + instance = super().save(**kwargs) + except IntegrityError as e: + # DB-level overlap trigger가 race condition을 잡아낸 경우 (app-level 검사가 통과한 동시 요청). + if "cms_domaingroup_domains_no_overlap" in str(e): + raise serializers.ValidationError({"domains": "도메인이 이미 다른 그룹에 등록되어 있습니다."}) from e + raise + + if not instance.sitemaps.filter_active().exists(): + page = Page.objects.create(title=instance.name, subtitle=instance.name) + Section.objects.create(page=page, order=0, body="") + Sitemap.objects.create(domain_group=instance, name=instance.name, route_code="", page=page) + return instance + + class SitemapAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): class Meta: model = Sitemap fields = COMMON_ADMIN_FIELDS + ( + "domain_group", "parent_sitemap", "route_code", "order", diff --git a/app/admin_api/test/cms_test.py b/app/admin_api/test/cms_test.py new file mode 100644 index 0000000..5aba361 --- /dev/null +++ b/app/admin_api/test/cms_test.py @@ -0,0 +1,312 @@ +import http + +import pytest +from cms.models import DomainGroup, Page, Section, Sitemap +from django.db import IntegrityError +from django.urls import reverse +from rest_framework.test import APIClient + + +@pytest.fixture +def domain_group(superuser): + return DomainGroup.objects.create( + name="2025년 PyConKR 홈페이지", + domains=["2025.pycon.kr"], + created_by=superuser, + updated_by=superuser, + ) + + +# ---- Auth ------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_unauthenticated_request_to_domain_group_is_rejected(): + response = APIClient().get(reverse("v1:admin-domain-group-list")) + assert response.status_code in (http.HTTPStatus.FORBIDDEN, http.HTTPStatus.UNAUTHORIZED) + + +# ---- DomainGroup CRUD ------------------------------------------------------- + + +@pytest.mark.django_db +def test_domain_group_list(api_client, domain_group): + response = api_client.get(reverse("v1:admin-domain-group-list")) + assert response.status_code == http.HTTPStatus.OK + rows = response.json() + assert any(row["name"] == domain_group.name for row in rows) + + +@pytest.mark.django_db +def test_domain_group_create(api_client): + response = api_client.post( + reverse("v1:admin-domain-group-list"), + data={"name": "2026년 PyConKR 홈페이지", "domains": ["2026.pycon.kr", "pycon.kr"]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.CREATED, response.json() + assert DomainGroup.objects.filter(name="2026년 PyConKR 홈페이지").exists() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "domains", + [ + ["https://pycon.kr"], # 스킴 + ["pycon.kr:8080"], # 포트 + ["pycon.kr/path"], # 경로 + ["pycon.kr?q=1"], # 쿼리 + ["pycon..kr"], # 연속 점 + [], # 빈 배열 + ], +) +def test_domain_group_create_rejects_invalid_domains(api_client, domains): + response = api_client.post( + reverse("v1:admin-domain-group-list"), + data={"name": "bad", "domains": domains}, + format="json", + ) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "input_domains,expected", + [ + (["PYCON.KR"], ["pycon.kr"]), + ([" pycon.kr "], ["pycon.kr"]), + (["pycon.kr", "PYCON.KR"], ["pycon.kr"]), + (["pycon.kr", "pycon.kr"], ["pycon.kr"]), + ], +) +def test_domain_group_create_normalizes_domains(api_client, input_domains, expected): + response = api_client.post( + reverse("v1:admin-domain-group-list"), + data={"name": "n", "domains": input_domains}, + format="json", + ) + assert response.status_code == http.HTTPStatus.CREATED, response.json() + assert response.json()["domains"] == expected + + +# ---- DB-level overlap trigger (race-safe) ----------------------------------- + + +@pytest.mark.django_db(transaction=True) +def test_db_trigger_rejects_overlapping_domain_on_insert(): + DomainGroup.objects.create(name="A", domains=["x.pycon.kr"]) + with pytest.raises(IntegrityError): + DomainGroup.objects.create(name="B", domains=["x.pycon.kr", "y.pycon.kr"]) + + +@pytest.mark.django_db(transaction=True) +def test_db_trigger_rejects_overlapping_domain_on_update(): + DomainGroup.objects.create(name="A", domains=["x.pycon.kr"]) + other = DomainGroup.objects.create(name="B", domains=["y.pycon.kr"]) + with pytest.raises(IntegrityError): + other.domains = ["x.pycon.kr"] + other.save() + + +@pytest.mark.django_db(transaction=True) +def test_db_trigger_ignores_soft_deleted_groups(): + a = DomainGroup.objects.create(name="A", domains=["x.pycon.kr"]) + a.delete() + DomainGroup.objects.create(name="B", domains=["x.pycon.kr"]) # 같은 도메인 재사용 허용 + + +# ---- DomainGroup constraints ------------------------------------------------ + + +@pytest.mark.django_db +def test_domain_group_create_rejects_overlapping_domain(api_client, domain_group): + response = api_client.post( + reverse("v1:admin-domain-group-list"), + data={"name": "다른 그룹", "domains": ["2025.pycon.kr", "new.pycon.kr"]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + +@pytest.mark.django_db +def test_domain_group_update_can_keep_own_domains(api_client, domain_group): + response = api_client.patch( + reverse("v1:admin-domain-group-detail", kwargs={"pk": domain_group.id}), + data={"domains": ["2025.pycon.kr", "another.pycon.kr"]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + domain_group.refresh_from_db() + assert set(domain_group.domains) == {"2025.pycon.kr", "another.pycon.kr"} + + +@pytest.mark.django_db +def test_domain_group_create_rejects_duplicate_name(api_client, domain_group): + response = api_client.post( + reverse("v1:admin-domain-group-list"), + data={"name": domain_group.name, "domains": ["new.pycon.kr"]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + +# ---- DomainGroup auto-creates default Sitemap ------------------------------- + + +@pytest.mark.django_db +def test_creating_domain_group_auto_creates_default_sitemap_page_section(api_client): + response = api_client.post( + reverse("v1:admin-domain-group-list"), + data={"name": "신규 그룹", "domains": ["new.pycon.kr"]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.CREATED, response.json() + + group = DomainGroup.objects.get(name="신규 그룹") + sitemaps = list(group.sitemaps.filter_active()) + assert len(sitemaps) == 1 + assert sitemaps[0].name == "신규 그룹" + assert sitemaps[0].route_code == "" + + page = sitemaps[0].page + assert page is not None + assert page.title == "신규 그룹" + assert Section.objects.filter_active().filter(page=page).count() == 1 + + +@pytest.mark.django_db +def test_updating_empty_group_auto_creates_default_sitemap(api_client, domain_group): + assert domain_group.sitemaps.filter_active().count() == 0 + + response = api_client.patch( + reverse("v1:admin-domain-group-detail", kwargs={"pk": domain_group.id}), + data={"name": "수정된 이름"}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + assert domain_group.sitemaps.filter_active().count() == 1 + + +@pytest.mark.django_db +def test_updating_non_empty_group_does_not_create_extra_sitemap(api_client, superuser, domain_group): + page = Page.objects.create(title="t", subtitle="s", created_by=superuser, updated_by=superuser) + Sitemap.objects.create( + name="existing", + page=page, + domain_group=domain_group, + created_by=superuser, + updated_by=superuser, + ) + assert domain_group.sitemaps.filter_active().count() == 1 + + response = api_client.patch( + reverse("v1:admin-domain-group-detail", kwargs={"pk": domain_group.id}), + data={"name": "수정된 이름"}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK, response.json() + assert domain_group.sitemaps.filter_active().count() == 1 + + +# ---- DomainGroup destroy ---------------------------------------------------- + + +@pytest.mark.django_db +def test_destroy_domain_group_with_lone_root_succeeds_leaving_page(api_client, superuser): + # lone root만 함께 삭제. Page/Section은 보존 (dangling이 되더라도 의도적 삭제 회피로 안전성 우선). + group = DomainGroup.objects.create(name="A", domains=["a.pycon.kr"], created_by=superuser, updated_by=superuser) + page = Page.objects.create(title="A", subtitle="A", created_by=superuser, updated_by=superuser) + section = Section.objects.create(page=page, order=0, body="", created_by=superuser, updated_by=superuser) + sitemap = Sitemap.objects.create( + name="root", domain_group=group, route_code="", page=page, created_by=superuser, updated_by=superuser + ) + + response = api_client.delete(reverse("v1:admin-domain-group-detail", kwargs={"pk": group.id})) + assert response.status_code == http.HTTPStatus.NO_CONTENT + + group.refresh_from_db() + sitemap.refresh_from_db() + page.refresh_from_db() + section.refresh_from_db() + assert group.deleted_at is not None + assert sitemap.deleted_at is not None + assert page.deleted_at is None + assert section.deleted_at is None + + +@pytest.mark.django_db +def test_destroy_domain_group_with_multiple_sitemaps_rejected(api_client, superuser): + group = DomainGroup.objects.create(name="A", domains=["a.pycon.kr"], created_by=superuser, updated_by=superuser) + Sitemap.objects.create(name="r1", domain_group=group, route_code="", created_by=superuser, updated_by=superuser) + Sitemap.objects.create( + name="r2", domain_group=group, route_code="other", created_by=superuser, updated_by=superuser + ) + + response = api_client.delete(reverse("v1:admin-domain-group-detail", kwargs={"pk": group.id})) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + group.refresh_from_db() + assert group.deleted_at is None + + +@pytest.mark.django_db +def test_destroy_domain_group_with_sitemap_having_children_rejected(api_client, superuser): + group = DomainGroup.objects.create(name="A", domains=["a.pycon.kr"], created_by=superuser, updated_by=superuser) + parent = Sitemap.objects.create( + name="parent", domain_group=group, route_code="", created_by=superuser, updated_by=superuser + ) + Sitemap.objects.create( + name="child", + domain_group=group, + route_code="child", + parent_sitemap=parent, + created_by=superuser, + updated_by=superuser, + ) + + response = api_client.delete(reverse("v1:admin-domain-group-detail", kwargs={"pk": group.id})) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + group.refresh_from_db() + assert group.deleted_at is None + + +# ---- Sitemap admin serializer exposes domain_group -------------------------- + + +@pytest.mark.django_db +def test_sitemap_admin_serializer_exposes_domain_group(api_client, superuser, domain_group): + page = Page.objects.create(title="t", subtitle="s", created_by=superuser, updated_by=superuser) + sitemap = Sitemap.objects.create( + name="x", + page=page, + domain_group=domain_group, + created_by=superuser, + updated_by=superuser, + ) + + response = api_client.get(reverse("v1:admin-sitemap-list")) + assert response.status_code == http.HTTPStatus.OK + + rows = response.json() + row = next(r for r in rows if r["id"] == str(sitemap.id)) + assert row["domain_group"] == str(domain_group.id) + + +@pytest.mark.django_db +def test_sitemap_admin_filter_by_domain_group(api_client, superuser): + group_a = DomainGroup.objects.create(name="A", domains=["a.pycon.kr"], created_by=superuser, updated_by=superuser) + group_b = DomainGroup.objects.create(name="B", domains=["b.pycon.kr"], created_by=superuser, updated_by=superuser) + page = Page.objects.create(title="t", subtitle="s", created_by=superuser, updated_by=superuser) + + sitemap_a = Sitemap.objects.create( + name="A-sitemap", page=page, domain_group=group_a, created_by=superuser, updated_by=superuser + ) + Sitemap.objects.create( + name="B-sitemap", page=page, domain_group=group_b, created_by=superuser, updated_by=superuser + ) + + response = api_client.get(reverse("v1:admin-sitemap-list"), {"domain_group": str(group_a.id)}) + assert response.status_code == http.HTTPStatus.OK + rows = response.json() + assert {r["id"] for r in rows} == {str(sitemap_a.id)} diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py index 8ba8995..376fd81 100644 --- a/app/admin_api/urls.py +++ b/app/admin_api/urls.py @@ -1,4 +1,4 @@ -from admin_api.views.cms import PageAdminViewSet, SitemapAdminViewSet +from admin_api.views.cms import DomainGroupAdminViewSet, PageAdminViewSet, SitemapAdminViewSet from admin_api.views.event.event import EventAdminViewSet from admin_api.views.event.presentation import ( PresentationAdminViewSet, @@ -29,6 +29,7 @@ admin_user_router.register("organization", OrganizationAdminViewSet, basename="admin-organization") admin_cms_router = routers.SimpleRouter() +admin_cms_router.register("domain-group", DomainGroupAdminViewSet, basename="admin-domain-group") admin_cms_router.register("sitemap", SitemapAdminViewSet, basename="admin-sitemap") admin_cms_router.register("page", PageAdminViewSet, basename="admin-page") diff --git a/app/admin_api/views/cms.py b/app/admin_api/views/cms.py index 8c6937b..c46fe94 100644 --- a/app/admin_api/views/cms.py +++ b/app/admin_api/views/cms.py @@ -2,8 +2,13 @@ import typing -from admin_api.serializers.cms import PageAdminSerializer, SectionAdminSerializer, SitemapAdminSerializer -from cms.models import Page, Section, Sitemap +from admin_api.serializers.cms import ( + DomainGroupAdminSerializer, + PageAdminSerializer, + SectionAdminSerializer, + SitemapAdminSerializer, +) +from cms.models import DomainGroup, Page, Section, Sitemap from core.const.tag import OpenAPITag from core.permissions import IsSuperUser from core.viewset.json_schema_viewset import JsonSchemaViewSet @@ -14,11 +19,34 @@ ValidationErrorResponseSerializer, ValidationErrorSerializer, ) -from rest_framework import decorators, request, response, status, viewsets +from rest_framework import decorators, exceptions, request, response, status, viewsets ADMIN_METHODS = ["list", "retrieve", "create", "update", "partial_update", "destroy"] +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_CMS]) for m in ADMIN_METHODS}) +class DomainGroupAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = DomainGroupAdminSerializer + permission_classes = [IsSuperUser] + queryset = DomainGroup.objects.filter_active().select_related_with_user() + + def perform_destroy(self, instance: DomainGroup) -> None: + active = list(instance.sitemaps.filter_active()) + is_lone_root = ( + len(active) == 1 and active[0].parent_sitemap_id is None and not active[0].children.filter_active().exists() + ) + if active and not is_lone_root: + raise exceptions.ValidationError( + "DomainGroup 하위에 Sitemap이 있어 삭제할 수 없습니다. 먼저 Sitemap을 정리해주세요." + ) + + with transaction.atomic(): + if is_lone_root: + active[0].delete() + instance.delete() + + class SectionData(typing.TypedDict): id: typing.NotRequired[str] page_id: typing.NotRequired[str] @@ -32,7 +60,8 @@ class SitemapAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): http_method_names = ["get", "post", "patch", "delete"] serializer_class = SitemapAdminSerializer permission_classes = [IsSuperUser] - queryset = Sitemap.objects.filter_active().select_related("created_by", "updated_by", "deleted_by") + queryset = Sitemap.objects.filter_active().select_related_with_user("domain_group") + filterset_fields = ["domain_group"] @extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_CMS]) for m in ADMIN_METHODS})