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

@@ -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