1+ from __future__ import annotations
2+
3+ import dataclasses
14import datetime
5+ import re
26import typing
37
48from core .models import BaseAbstractModel , BaseAbstractModelQuerySet
9+ from django .core .exceptions import ValidationError
510from django .core .validators import MinValueValidator
611from 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+
1839class 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
2766class 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
48115class Section (BaseAbstractModel ):
0 commit comments