feat(route-sections): add dynamic route sections

Replace hardcoded homepage featured route groups with a fully managed dynamic system:
- add database models RouteSection and RouteSectionProduct for storing route groups and their associated products
- create Alembic migration 0004_route_sections for the new tables
- extend SiteConfigPatchIn schema with subtitle and productIds fields
- refactor shared site_config utility to load dynamic route sections instead of fixed groups
- implement admin CRUD API with validation for duplicate/conflicting product associations
- update public and admin API documentation to reflect the new system
- add default route section seed data and comprehensive test coverage
This commit is contained in:
duanshuwen
2026-07-02 23:05:36 +08:00
parent 521e501992
commit 72d388a047
10 changed files with 610 additions and 43 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, ThemeCard
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard
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
@@ -208,6 +208,31 @@ def make_campaign(**overrides):
)
def make_route_section(**overrides):
section = RouteSection(
id=overrides.get("id", "routes"),
title=overrides.get("title", "经典人文打卡线路"),
subtitle=overrides.get("subtitle", "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联"),
sortOrder=overrides.get("sortOrder", 0),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
section.products = overrides.get("products", [])
return section
def make_route_section_product(**overrides):
product = overrides.get("product", make_product(id=overrides.get("productId", "product-test")))
link = RouteSectionProduct(
sectionId=overrides.get("sectionId", "routes"),
productId=overrides.get("productId", product.id),
sortOrder=overrides.get("sortOrder", 0),
)
link.product = product
return link
def make_admin_user():
return AdminUser(
id="admin-test",
@@ -471,7 +496,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:
@@ -489,7 +514,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:
@@ -515,7 +540,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:
@@ -547,6 +572,90 @@ def test_admin_site_config_includes_campaigns_for_special_offers():
assert "targetType" not in body["campaigns"][0]
def test_admin_site_config_includes_route_sections_for_featured_routes():
section = make_route_section(
products=[
make_route_section_product(productId="product-a", sortOrder=1),
make_route_section_product(productId="product-b", sortOrder=0),
],
)
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
route_sections = response.json()["routeSections"]
assert route_sections == [
{
"id": "routes",
"title": "经典人文打卡线路",
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
"productIds": ["product-b", "product-a"],
"isActive": True,
"sortOrder": 0,
"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)
try:
response = TestClient(app).post(
"/api/admin/site-config/routeSections",
json={"title": " 新人文线路 ", "subtitle": " 新副文案 "},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["id"].startswith("routesection-")
assert body["title"] == "新人文线路"
assert body["subtitle"] == "新副文案"
assert body["productIds"] == []
assert body["isActive"] is True
assert body["sortOrder"] == 0
created = fake_db.added[0]
assert isinstance(created, RouteSection)
assert created.id.startswith("routesection-")
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_route_sections_module_is_not_fixed():
assert admin_router.SITE_CONFIG_MODULES["routeSections"].get("fixed") is not True
def test_site_config_create_route_section_allows_more_than_three_groups():
items = [
make_route_section(id="route-section-a", title="经典", sortOrder=0),
make_route_section(id="route-section-b", title="户外", sortOrder=1),
make_route_section(id="route-section-c", title="混搭", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post("/api/admin/site-config/routeSections", json={"title": "第四组"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["title"] == "第四组"
assert body["sortOrder"] == 3
assert body["productIds"] == []
assert fake_db.committed
def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicated_contract():
fake_db = FakeDb(scalar_values=[0])
app = authenticated_app(fake_db)
@@ -788,9 +897,67 @@ def test_site_config_patch_campaign_updates_contract_fields_only():
assert fake_db.added[-1].entity == "campaign"
def test_site_config_patch_route_section_updates_copy_and_product_order():
section = make_route_section()
product_a = make_product(id="product-a")
product_b = make_product(id="product-b")
fake_db = FakeDb(
get_result=section,
scalar_results=[[product_b, product_a], []],
execute_results=[[]],
)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/routeSections/routes",
json={
"title": " 新经典线路 ",
"subtitle": " 新副文案 ",
"isActive": False,
"productIds": ["product-b", "product-a"],
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["title"] == "新经典线路"
assert body["subtitle"] == "新副文案"
assert body["isActive"] is False
assert body["productIds"] == ["product-b", "product-a"]
links = [item for item in fake_db.added if isinstance(item, RouteSectionProduct)]
assert [(link.productId, link.sortOrder) for link in links] == [("product-b", 0), ("product-a", 1)]
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_site_config_patch_route_section_rejects_product_used_by_another_section():
section = make_route_section(id="routes")
conflict = make_route_section_product(sectionId="route-section-other", productId="product-a")
fake_db = FakeDb(
get_result=section,
scalar_results=[[make_product(id="product-a")], [conflict]],
)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/routeSections/routes",
json={"productIds": ["product-a"]},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 409
assert response.json()["code"] == "ROUTE_SECTION_PRODUCT_CONFLICT"
assert not fake_db.committed
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)
@@ -813,6 +980,30 @@ def test_public_site_config_campaigns_include_price_and_tags():
]
def test_public_site_config_route_sections_filter_unpublished_products():
published = make_product(id="product-published", status="published")
draft = make_product(id="product-draft", status="draft")
section = make_route_section(
products=[
make_route_section_product(product=published, productId=published.id, sortOrder=0),
make_route_section_product(product=draft, productId=draft.id, sortOrder=1),
]
)
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]])
result = site_config(fake_db, active_only=True)
assert result["routeSections"] == [
{
"id": "routes",
"title": "经典人文打卡线路",
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
"productIds": ["product-published"],
"isActive": True,
}
]
def test_site_config_patch_updates_destination_contract_fields():
destination = make_destination()
fake_db = FakeDb(get_result=destination)
@@ -925,6 +1116,32 @@ def test_site_config_reorder_rejects_duplicate_missing_and_unknown_ids(item_ids)
assert not fake_db.committed
@pytest.mark.parametrize(
"item_ids",
[
["route-section-a", "route-section-a", "route-section-b"],
["route-section-a", "route-section-b"],
["route-section-a", "route-section-b", "route-section-x"],
],
)
def test_site_config_reorder_route_sections_rejects_duplicate_missing_and_unknown_ids(item_ids):
items = [
make_route_section(id="route-section-a", title="A", sortOrder=0),
make_route_section(id="route-section-b", title="B", sortOrder=1),
make_route_section(id="route-section-c", title="C", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch("/api/admin/site-config/routeSections/reorder", json={"itemIds": item_ids})
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json()["code"] == "MODULE_CONFIG_REORDER_INVALID"
assert not fake_db.committed
def test_site_config_reorder_map_is_not_supported():
fake_db = FakeDb()
app = authenticated_app(fake_db)
@@ -953,6 +1170,49 @@ def test_site_config_reorder_campaigns_is_not_supported():
assert not fake_db.committed
def test_site_config_reorder_route_sections_reassigns_sort_order():
items = [
make_route_section(id="route-section-a", title="经典", sortOrder=0),
make_route_section(id="route-section-b", title="户外", sortOrder=1),
make_route_section(id="route-section-c", title="混搭", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/routeSections/reorder",
json={"itemIds": ["route-section-b", "route-section-a", "route-section-c"]},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert {item.id: item.sortOrder for item in items} == {"route-section-b": 0, "route-section-a": 1, "route-section-c": 2}
assert [item["id"] for item in response.json()["items"]] == ["route-section-b", "route-section-a", "route-section-c"]
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_site_config_delete_route_section_returns_json_reorders_and_does_not_delete_products():
section = make_route_section(id="route-section-delete", title="待删除", sortOrder=1)
remaining_a = make_route_section(id="route-section-a", title="保留 A", sortOrder=0)
remaining_b = make_route_section(id="route-section-b", title="保留 B", sortOrder=2)
fake_db = FakeDb(get_result=section, scalar_results=[[remaining_a, remaining_b]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).delete("/api/admin/site-config/routeSections/route-section-delete")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"id": "route-section-delete"}
assert fake_db.deleted == [section]
assert {item.id: item.sortOrder for item in [remaining_a, remaining_b]} == {"route-section-a": 0, "route-section-b": 1}
assert fake_db.added[-1].action == "delete"
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_site_config_delete_map_image_returns_json_and_audits_without_reorder():
map_image = make_map_image()
fake_db = FakeDb(get_result=map_image)