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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user