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

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