From d11c361aa2b0d8a2909a7db78dbce402206bc22a Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Tue, 7 Jul 2026 21:24:30 +0800 Subject: [PATCH] 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 --- .../versions/0007_destination_page_modules.py | 64 +++++++++++++ app/content.py | 18 ++++ app/models.py | 26 +++++ app/routers/admin.py | 28 ++++++ app/routers/shared.py | 12 ++- app/schemas.py | 2 + app/seed.py | 31 +++++- tests/test_api_contracts.py | 94 +++++++++++++++++-- 8 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 alembic/versions/0007_destination_page_modules.py diff --git a/alembic/versions/0007_destination_page_modules.py b/alembic/versions/0007_destination_page_modules.py new file mode 100644 index 0000000..b34295f --- /dev/null +++ b/alembic/versions/0007_destination_page_modules.py @@ -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") diff --git a/app/content.py b/app/content.py index cd3b502..7638e07 100644 --- a/app/content.py +++ b/app/content.py @@ -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 = { "贵阳": ["贵阳", "青岩", "花溪", "高坡", "天河潭"], "黄果树": ["黄果树", "安顺", "坝陵河", "瀑布"], diff --git a/app/models.py b/app/models.py index 2c55cc3..fc7e520 100644 --- a/app/models.py +++ b/app/models.py @@ -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" diff --git a/app/routers/admin.py b/app/routers/admin.py index 3c351a5..cd44065 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -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() diff --git a/app/routers/shared.py b/app/routers/shared.py index 7b9f2bd..8020c92 100644 --- a/app/routers/shared.py +++ b/app/routers/shared.py @@ -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 \ No newline at end of file + return result diff --git a/app/schemas.py b/app/schemas.py index 1dca3ed..1656e79 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 diff --git a/app/seed.py b/app/seed.py index af6aa69..9efc4b2 100644 --- a/app/seed.py +++ b/app/seed.py @@ -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), diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index df453f4..4a6fe76 100644 --- a/tests/test_api_contracts.py +++ b/tests/test_api_contracts.py @@ -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)