feat: add destination page configurable modules

This commit adds full support for two new destination page modules: a hero banner section and categorized destination regions.

Changes include:
- New SQLAlchemy models and alembic migration for DestinationHero and DestinationRegion tables
- Updated Pydantic SiteConfigPatchIn schema with new optional fields
- Integrated admin CRUD support for the new modules
- Added default seed data for Guizhou destination regions and hero content
- Exposed active modules via public site config API
- Added comprehensive test coverage for admin and public endpoints
This commit is contained in:
duanshuwen
2026-07-07 21:24:30 +08:00
parent 83906f481d
commit d11c361aa2
8 changed files with 262 additions and 13 deletions

View File

@@ -42,6 +42,24 @@ DESTINATIONS = [
("黔东南", "/assets/guizhou/xijiang-miao-village.jpg"),
]
DESTINATION_HERO = [
{
"title": "山水、苗寨、古城与野咖同程安排",
"kicker": "贵州小包团目的地",
"image": "/assets/guizhou/huangguoshu-waterfall.jpg",
},
]
DESTINATION_REGIONS = [
{"label": "贵阳/安顺", "keyword": "贵阳安顺", "spots": "甲秀楼、黄果树"},
{"label": "黔东南", "keyword": "黔东南", "spots": "西江、镇远、肇兴"},
{"label": "黔南", "keyword": "黔南荔波", "spots": "荔波小七孔、茂兰"},
{"label": "铜仁", "keyword": "铜仁梵净山", "spots": "梵净山、云舍"},
{"label": "黔西南", "keyword": "黔西南万峰林", "spots": "万峰林、马岭河"},
{"label": "遵义/黔北", "keyword": "遵义赤水", "spots": "娄山关、赤水丹霞"},
{"label": "毕节/六盘水", "keyword": "毕节六盘水", "spots": "织金洞、乌蒙草原"},
]
ALIASES = {
"贵阳": ["贵阳", "青岩", "花溪", "高坡", "天河潭"],
"黄果树": ["黄果树", "安顺", "坝陵河", "瀑布"],

View File

@@ -98,6 +98,32 @@ class MapImage(Base):
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class DestinationHero(Base):
__tablename__ = "DestinationHero"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
title: Mapped[str] = mapped_column(String, nullable=False)
kicker: Mapped[str | None] = mapped_column(String)
image: Mapped[str | None] = mapped_column(String)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class DestinationRegion(Base):
__tablename__ = "DestinationRegion"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
label: Mapped[str] = mapped_column(String, nullable=False)
keyword: Mapped[str | None] = mapped_column(String)
spots: Mapped[str | None] = mapped_column(Text)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class ThemeCard(Base):
__tablename__ = "ThemeCard"

View File

@@ -19,6 +19,8 @@ from ..models import (
Campaign,
CtaBanner,
Destination,
DestinationHero,
DestinationRegion,
HeroSlide,
HotelGroup,
Lead,
@@ -68,6 +70,24 @@ SITE_CONFIG_MODULES = {
"none_to_empty": set(),
"create_defaults": {"isHot": False},
},
"destinationHero": {
"model": DestinationHero,
"entity": "destination_hero",
"primary": "title",
"fields": {"title", "kicker", "image", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"kicker", "image"},
"create_defaults": {"kicker": None, "image": None},
},
"destinationRegions": {
"model": DestinationRegion,
"entity": "destination_region",
"primary": "label",
"fields": {"label", "keyword", "spots", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"keyword", "spots"},
"create_defaults": {"keyword": None, "spots": None},
},
"map": {
"model": MapImage,
"entity": "map_image",
@@ -655,6 +675,14 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
for item in db.scalars(select(HeroSlide).order_by(HeroSlide.sortOrder.asc())).all()
],
"destinations": [destination_dict(item) for item in destinations],
"destinationHero": [
model_dict(item)
for item in db.scalars(select(DestinationHero).order_by(DestinationHero.sortOrder.asc())).all()
],
"destinationRegions": [
model_dict(item)
for item in db.scalars(select(DestinationRegion).order_by(DestinationRegion.sortOrder.asc())).all()
],
"map": [
map_image_admin_dict(item)
for item in db.scalars(select(MapImage).order_by(MapImage.createdAt.asc())).all()

View File

@@ -1,6 +1,6 @@
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from ..models import Campaign, CtaBanner, Destination, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption
from ..models import Campaign, CtaBanner, Destination, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption
from ..route_sections import route_section_dict, route_section_query
from ..serializers import destination_dict, hotel_group_dict, model_dict
@@ -8,6 +8,8 @@ from ..serializers import destination_dict, hotel_group_dict, model_dict
def site_config(db: Session, active_only: bool, include_public_extras: bool = True) -> dict:
hero_stmt = select(HeroSlide).order_by(HeroSlide.sortOrder.asc())
destination_stmt = select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc())
destination_hero_stmt = select(DestinationHero).order_by(DestinationHero.sortOrder.asc())
destination_region_stmt = select(DestinationRegion).order_by(DestinationRegion.sortOrder.asc())
map_stmt = select(MapImage).order_by(MapImage.createdAt.asc())
theme_stmt = select(ThemeCard).order_by(ThemeCard.sortOrder.asc())
cta_stmt = select(CtaBanner).order_by(CtaBanner.sortOrder.asc())
@@ -16,6 +18,8 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
if active_only:
hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True))
destination_stmt = destination_stmt.where(Destination.isActive.is_(True))
destination_hero_stmt = destination_hero_stmt.where(DestinationHero.isActive.is_(True))
destination_region_stmt = destination_region_stmt.where(DestinationRegion.isActive.is_(True))
map_stmt = map_stmt.where(MapImage.isActive.is_(True))
theme_stmt = theme_stmt.where(ThemeCard.isActive.is_(True))
cta_stmt = cta_stmt.where(CtaBanner.isActive.is_(True))
@@ -24,12 +28,16 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
hero_slides = db.scalars(hero_stmt).all()
destinations = db.scalars(destination_stmt).all()
destination_hero = db.scalars(destination_hero_stmt).all()
destination_regions = db.scalars(destination_region_stmt).all()
map_images = db.scalars(map_stmt).all()
themes = db.scalars(theme_stmt).all()
cta_banners = db.scalars(cta_stmt).all()
result = {
"heroSlides": [model_dict(item) for item in hero_slides],
"destinations": [destination_dict(item) for item in destinations],
"destinationHero": [model_dict(item) for item in destination_hero],
"destinationRegions": [model_dict(item) for item in destination_regions],
"map": [model_dict(item) for item in map_images],
"themes": [model_dict(item) for item in themes],
"ctaBanners": [model_dict(item) for item in cta_banners],
@@ -43,4 +51,4 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections]
result["hotelGroups"] = [hotel_group_dict(item) for item in db.scalars(hotel_stmt).all()]
result["vehicleOptions"] = [model_dict(item) for item in db.scalars(vehicle_stmt).all()]
return result
return result

View File

@@ -126,6 +126,8 @@ class SiteConfigPatchIn(BaseModel):
slug: str | None = None
region: str | None = None
label: str | None = None
keyword: str | None = None
spots: str | None = None
alt: str | None = None
image: str | None = None
description: str | None = None

View File

@@ -8,7 +8,7 @@ from urllib.parse import quote
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from .auth import hash_password
from .content import ALIASES, CAMPAIGNS, CTAS, DESTINATIONS, HERO_SLIDES, HOTEL_GROUPS, THEMES, VEHICLE_OPTIONS
from .content import ALIASES, CAMPAIGNS, CTAS, DESTINATION_HERO, DESTINATION_REGIONS, DESTINATIONS, HERO_SLIDES, HOTEL_GROUPS, THEMES, VEHICLE_OPTIONS
from .database import Base, SessionLocal, engine
from .models import (
AdminUser,
@@ -17,6 +17,8 @@ from .models import (
CtaBanner,
Destination,
DestinationAlias,
DestinationHero,
DestinationRegion,
HeroSlide,
HotelGroup,
MediaAsset,
@@ -102,7 +104,7 @@ def load_products() -> list[dict]:
def reset_guizhou_content(db: Session) -> dict:
products = load_products()
for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, VehicleOption, HotelGroup, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]:
for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, VehicleOption, HotelGroup, CtaBanner, ThemeCard, DestinationRegion, DestinationHero, HeroSlide, DestinationAlias, Destination, MediaAsset]:
db.execute(delete(model))
db.flush()
@@ -130,6 +132,27 @@ def reset_guizhou_content(db: Session) -> dict:
for alias in ALIASES.get(name, []):
db.add(DestinationAlias(destinationId=destination.id, alias=alias))
for index, item in enumerate(DESTINATION_HERO):
create_media(db, item.get("image"), "destination-hero", item["title"])
db.add(
DestinationHero(
title=item["title"],
kicker=item.get("kicker"),
image=item.get("image"),
sortOrder=index,
)
)
for index, item in enumerate(DESTINATION_REGIONS):
db.add(
DestinationRegion(
label=item["label"],
keyword=item.get("keyword"),
spots=item.get("spots"),
sortOrder=index,
)
)
for index, (label, image) in enumerate(THEMES):
create_media(db, image, "theme", label)
db.add(ThemeCard(label=label, image=image, targetType="search", targetValue=label, sortOrder=index))
@@ -229,6 +252,8 @@ def reset_guizhou_content(db: Session) -> dict:
snapshot={
"heroSlides": len(HERO_SLIDES),
"destinations": len(DESTINATIONS),
"destinationHero": len(DESTINATION_HERO),
"destinationRegions": len(DESTINATION_REGIONS),
"themeCards": len(THEMES),
"products": len(products),
"routeSections": len(ROUTE_SECTION_DEFAULTS),
@@ -241,6 +266,8 @@ def reset_guizhou_content(db: Session) -> dict:
return {
"heroSlides": len(HERO_SLIDES),
"destinations": len(DESTINATIONS),
"destinationHero": len(DESTINATION_HERO),
"destinationRegions": len(DESTINATION_REGIONS),
"themes": len(THEMES),
"ctaBanners": len(CTAS),
"products": len(products),