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

@@ -0,0 +1,64 @@
"""Add destination page configurable modules.
Revision ID: 0007_destination_page_modules
Revises: 0006_hotel_group_offer_fields
Create Date: 2026-07-07
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = "0007_destination_page_modules"
down_revision = "0006_hotel_group_offer_fields"
branch_labels = None
depends_on = None
def _create_destination_hero_table() -> None:
op.create_table(
"DestinationHero",
sa.Column("id", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("kicker", sa.String(), nullable=True),
sa.Column("image", sa.String(), nullable=True),
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("createdAt", sa.DateTime(), nullable=False),
sa.Column("updatedAt", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def _create_destination_region_table() -> None:
op.create_table(
"DestinationRegion",
sa.Column("id", sa.String(), nullable=False),
sa.Column("label", sa.String(), nullable=False),
sa.Column("keyword", sa.String(), nullable=True),
sa.Column("spots", sa.Text(), nullable=True),
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("createdAt", sa.DateTime(), nullable=False),
sa.Column("updatedAt", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def upgrade() -> None:
bind = op.get_bind()
existing_tables = set(inspect(bind).get_table_names())
if "DestinationHero" not in existing_tables:
_create_destination_hero_table()
if "DestinationRegion" not in existing_tables:
_create_destination_region_table()
def downgrade() -> None:
bind = op.get_bind()
existing_tables = set(inspect(bind).get_table_names())
if "DestinationRegion" in existing_tables:
op.drop_table("DestinationRegion")
if "DestinationHero" in existing_tables:
op.drop_table("DestinationHero")

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),

View File

@@ -8,7 +8,7 @@ from app import serializers
from app.auth import hash_password, require_admin
from app.database import get_db
from app.main import create_app
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, HotelGroup, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard, VehicleOption
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard, VehicleOption
from app.routers import admin as admin_router
from app.routers.admin import normalize_detail_sections, normalize_images
from app.routers.shared import site_config
@@ -222,6 +222,32 @@ def make_map_image(**overrides):
)
def make_destination_hero(**overrides):
return DestinationHero(
id=overrides.get("id", "destination-hero-test"),
title=overrides.get("title", "目的地页主视觉"),
kicker=overrides.get("kicker", "贵州小包团目的地"),
image=overrides.get("image", "/assets/destination-hero.jpg"),
sortOrder=overrides.get("sortOrder", 0),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_destination_region(**overrides):
return DestinationRegion(
id=overrides.get("id", "destination-region-test"),
label=overrides.get("label", "黔南"),
keyword=overrides.get("keyword", "黔南荔波"),
spots=overrides.get("spots", "荔波小七孔、茂兰"),
sortOrder=overrides.get("sortOrder", 0),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_campaign(**overrides):
return Campaign(
id=overrides.get("id", "campaign-test"),
@@ -473,6 +499,8 @@ def test_site_config_patch_ignores_fields_not_allowed_for_module_and_audits():
("heroSlides", {"title": "新轮播", "image": None, "targetType": None}, {"title": "新轮播", "image": None}),
("destinations", {"name": "新目的地", "slug": "", "region": None, "isHot": True}, {"name": "新目的地", "slug": "e696b0e79baee79a84e59cb0", "isHot": True}),
("themes", {"label": "新主题", "image": None}, {"label": "新主题", "image": ""}),
("destinationHero", {"title": "目的地页主视觉", "kicker": "贵州小包团目的地", "image": None}, {"title": "目的地页主视觉", "kicker": "贵州小包团目的地", "image": None}),
("destinationRegions", {"label": "黔南", "keyword": "黔南荔波", "spots": "荔波小七孔、茂兰"}, {"label": "黔南", "keyword": "黔南荔波", "spots": "荔波小七孔、茂兰"}),
("ctaBanners", {"alt": "新运营入口", "image": None, "targetType": None}, {"alt": "新运营入口", "image": "", "targetType": ""}),
("hotelGroups", {"title": "经典酒店", "description": "酒店文案", "image": None}, {"title": "经典酒店", "description": "酒店文案", "image": None}),
("vehicleOptions", {"title": "5座舒适用车", "description": "用车文案", "image": None}, {"title": "5座舒适用车", "description": "用车文案", "image": None}),
@@ -530,7 +558,7 @@ def test_site_config_create_requires_module_primary_field():
def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test")
fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], [], [], [], [], []])
fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], [], [], [], [], [], [], []])
app = authenticated_app(fake_db)
try:
@@ -548,7 +576,7 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
def test_admin_site_config_includes_map_array_with_dedicated_contract():
map_image = make_map_image()
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], [], [], [], []])
fake_db = FakeDb(scalar_results=[[], [], [], [], [map_image], [], [], [], [], [], []])
app = authenticated_app(fake_db)
try:
@@ -572,9 +600,57 @@ def test_admin_site_config_includes_map_array_with_dedicated_contract():
assert "targetType" not in body["map"][0]
def test_admin_site_config_includes_destination_page_modules():
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [], [], []])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["destinationHero"] == []
assert body["destinationRegions"] == []
def test_public_site_config_includes_destination_page_modules():
destination_hero = make_destination_hero()
destination_region = make_destination_region()
fake_db = FakeDb(scalar_results=[[], [], [destination_hero], [destination_region], [], [], [], [], [], [], []])
result = site_config(fake_db, active_only=True)
assert result["destinationHero"] == [
{
"id": "destination-hero-test",
"title": "目的地页主视觉",
"kicker": "贵州小包团目的地",
"image": "/assets/destination-hero.jpg",
"sortOrder": 0,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
assert result["destinationRegions"] == [
{
"id": "destination-region-test",
"label": "黔南",
"keyword": "黔南荔波",
"spots": "荔波小七孔、茂兰",
"sortOrder": 0,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
def test_admin_site_config_includes_campaigns_for_special_offers():
campaign = make_campaign(status="published")
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], [], []])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [campaign], [], [], []])
app = authenticated_app(fake_db)
try:
@@ -613,7 +689,7 @@ def test_admin_site_config_includes_route_sections_for_featured_routes():
make_route_section_product(productId="product-b", sortOrder=0),
],
)
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section], [], []])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [section], [], []])
app = authenticated_app(fake_db)
try:
@@ -640,7 +716,7 @@ def test_admin_site_config_includes_route_sections_for_featured_routes():
def test_admin_site_config_includes_hotel_and_vehicle_modules():
hotel_group = make_hotel_group()
vehicle_option = make_vehicle_option()
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [hotel_group], [vehicle_option]])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [], [hotel_group], [vehicle_option]])
app = authenticated_app(fake_db)
try:
@@ -1035,7 +1111,7 @@ def test_site_config_patch_route_section_rejects_product_used_by_another_section
def test_public_site_config_campaigns_include_price_and_tags():
campaign = make_campaign(status="published", tags=["自然奇景", "小众秘境"])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], [], []])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [campaign], [], [], []])
result = site_config(fake_db, active_only=True)
@@ -1067,7 +1143,7 @@ def test_public_site_config_route_sections_filter_unpublished_products():
make_route_section_product(product=draft, productId=draft.id, sortOrder=1),
]
)
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section], [], []])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [section], [], []])
result = site_config(fake_db, active_only=True)
@@ -1085,7 +1161,7 @@ def test_public_site_config_route_sections_filter_unpublished_products():
def test_public_site_config_includes_hotel_and_vehicle_modules():
hotel_group = make_hotel_group()
vehicle_option = make_vehicle_option()
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [hotel_group], [vehicle_option]])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [], [hotel_group], [vehicle_option]])
result = site_config(fake_db, active_only=True)