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