feat: add hotel and vehicle option site modules

Add full support for hotel group and vehicle option site management features:
- Define SQLAlchemy models and alembic migration for the new database tables
- Add default sample content entries in content.py
- Extend admin and public API routes to support the new modules
- Update seed script to populate default hotel and vehicle data
- Update all relevant documentation and test cases
This commit is contained in:
duanshuwen
2026-07-03 20:26:44 +08:00
parent 72d388a047
commit 201e835eab
13 changed files with 426 additions and 34 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, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, 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
@@ -180,6 +180,33 @@ def make_cta_banner(**overrides):
)
def make_hotel_group(**overrides):
return HotelGroup(
id=overrides.get("id", "hotel-group-test"),
title=overrides.get("title", "经典酒店"),
description=overrides.get("description", "经典城市酒店与山野度假住宿组合"),
image=overrides.get("image", "/assets/hotel.jpg"),
sortOrder=overrides.get("sortOrder", 1),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_vehicle_option(**overrides):
return VehicleOption(
id=overrides.get("id", "vehicle-option-test"),
title=overrides.get("title", "5座舒适用车"),
description=overrides.get("description", "2-8人小团按人数和行李匹配车型"),
image=overrides.get("image", "/assets/vehicle.jpg"),
sortOrder=overrides.get("sortOrder", 2),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_map_image(**overrides):
return MapImage(
id=overrides.get("id", "map-test"),
@@ -442,6 +469,8 @@ def test_site_config_patch_ignores_fields_not_allowed_for_module_and_audits():
("destinations", {"name": "新目的地", "slug": "", "region": None, "isHot": True}, {"name": "新目的地", "slug": "e696b0e79baee79a84e59cb0", "isHot": True}),
("themes", {"label": "新主题", "image": None}, {"label": "新主题", "image": ""}),
("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}),
],
)
def test_site_config_create_modules_defaults_fields_and_audits(module, payload, expected):
@@ -496,7 +525,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:
@@ -514,7 +543,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:
@@ -540,7 +569,7 @@ def test_admin_site_config_includes_map_array_with_dedicated_contract():
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:
@@ -579,7 +608,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:
@@ -603,6 +632,45 @@ 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]])
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["hotelGroups"] == [
{
"id": "hotel-group-test",
"title": "经典酒店",
"description": "经典城市酒店与山野度假住宿组合",
"image": "/assets/hotel.jpg",
"sortOrder": 1,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
assert body["vehicleOptions"] == [
{
"id": "vehicle-option-test",
"title": "5座舒适用车",
"description": "2-8人小团按人数和行李匹配车型",
"image": "/assets/vehicle.jpg",
"sortOrder": 2,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
def test_site_config_create_route_section_generates_dynamic_id_and_audits():
fake_db = FakeDb(scalar_results=[[]])
app = authenticated_app(fake_db)
@@ -957,7 +1025,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)
@@ -989,7 +1057,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)
@@ -1004,6 +1072,19 @@ 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]])
result = site_config(fake_db, active_only=True)
assert result["hotelGroups"][0]["title"] == "经典酒店"
assert result["hotelGroups"][0]["description"] == "经典城市酒店与山野度假住宿组合"
assert result["vehicleOptions"][0]["title"] == "5座舒适用车"
assert result["vehicleOptions"][0]["image"] == "/assets/vehicle.jpg"
def test_site_config_patch_updates_destination_contract_fields():
destination = make_destination()
fake_db = FakeDb(get_result=destination)