feat: add demand page modules and admin endpoints

Add full backend support for a demand request page, including:
- New database models for demand hero, feature cards, form configuration, and product recommendations
- Alembic migration for the new tables
- Default seed data for demand page components
- Public site config endpoint exposing demand data
- Admin CRUD operations and configuration for demand modules
- Extended lead query filters for source page, keyword, and date range
- Updated Pydantic schemas for lead query parameters
- Comprehensive test coverage for all new endpoints
This commit is contained in:
duanshuwen
2026-07-07 22:03:57 +08:00
parent d11c361aa2
commit fe85197e68
9 changed files with 749 additions and 15 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, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard, VehicleOption
from app.models import AdminUser, Campaign, CtaBanner, DemandFeatureCard, DemandForm, DemandHero, DemandRecommendation, DemandRecommendationProduct, 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
@@ -52,19 +52,24 @@ class FakeDb:
self.scalar_results = list(scalar_results or [])
self.scalar_values = list(scalar_values or [])
self.execute_results = list(execute_results or [])
self.scalar_statements = []
self.execute_statements = []
self.get_result = get_result
self.added = []
self.deleted = []
self.committed = False
def scalars(self, _stmt):
return FakeScalarResult(self.scalar_results.pop(0))
def scalars(self, stmt):
self.scalar_statements.append(stmt)
return FakeScalarResult(self.scalar_results.pop(0) if self.scalar_results else [])
def scalar(self, _stmt):
return self.scalar_values.pop(0)
def scalar(self, stmt):
self.scalar_statements.append(stmt)
return self.scalar_values.pop(0) if self.scalar_values else None
def execute(self, _stmt):
return FakeExecuteResult(self.execute_results.pop(0))
def execute(self, stmt):
self.execute_statements.append(stmt)
return FakeExecuteResult(self.execute_results.pop(0) if self.execute_results else [])
def get(self, _model, _item_id):
return self.get_result
@@ -291,6 +296,74 @@ def make_route_section_product(**overrides):
return link
def make_demand_hero(**overrides):
return DemandHero(
id=overrides.get("id", "demand-hero-test"),
title=overrides.get("title", "告诉我们日期、人数和想法"),
kicker=overrides.get("kicker", "3步定制"),
description=overrides.get("description", "管家会按同行人、预算和体力强度,重新组合酒店、用车和景点节奏。"),
steps=overrides.get("steps", ["提交需求", "管家沟通", "确认方案"]),
sortOrder=overrides.get("sortOrder", 0),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_demand_feature_card(**overrides):
return DemandFeatureCard(
id=overrides.get("id", "demand-feature-card-test"),
title=overrides.get("title", "动线"),
description=overrides.get("description", "按天数顺路排"),
sortOrder=overrides.get("sortOrder", 0),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_demand_form(**overrides):
return DemandForm(
id=overrides.get("id", "demand-form-test"),
destinationLabel=overrides.get("destinationLabel", "目的地/玩法"),
destinationPlaceholder=overrides.get("destinationPlaceholder", "例如:贵州、黄果树、西江苗寨"),
phoneLabel=overrides.get("phoneLabel", "联系方式"),
phonePlaceholder=overrides.get("phonePlaceholder", "手机号 / 微信号"),
noteLabel=overrides.get("noteLabel", "补充说明"),
notePlaceholder=overrides.get("notePlaceholder", "出行日期、人数、酒店偏好、预算范围"),
submitLabel=overrides.get("submitLabel", "提交出行需求"),
chips=overrides.get("chips", ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"]),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_demand_recommendation(**overrides):
item = DemandRecommendation(
id=overrides.get("id", "demand-recommendation-test"),
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),
)
item.products = overrides.get("products", [])
return item
def make_demand_recommendation_product(**overrides):
product = overrides.get("product", make_product(id=overrides.get("productId", "product-test")))
link = DemandRecommendationProduct(
recommendationId=overrides.get("recommendationId", "demand-recommendation-test"),
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",
@@ -504,6 +577,26 @@ def test_site_config_patch_ignores_fields_not_allowed_for_module_and_audits():
("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}),
(
"demandHero",
{"title": "需求页主视觉", "kicker": "3步定制", "description": "说明", "steps": ["提交", "沟通", "确认"]},
{"title": "需求页主视觉", "kicker": "3步定制", "description": "说明", "steps": ["提交", "沟通", "确认"]},
),
(
"demandFeatureCards",
{"title": "动线", "description": "按天数顺路排"},
{"title": "动线", "description": "按天数顺路排"},
),
(
"demandForm",
{"submitLabel": "提交出行需求", "destinationLabel": "目的地/玩法", "chips": [" 贵州 ", "", "黄果树"]},
{"submitLabel": "提交出行需求", "destinationLabel": "目的地/玩法", "chips": ["贵州", "黄果树"]},
),
(
"demandRecommendations",
{"title": "热门推荐", "subtitle": "也可以先挑一条线路沟通"},
{"title": "热门推荐", "subtitle": "也可以先挑一条线路沟通", "productIds": []},
),
],
)
def test_site_config_create_modules_defaults_fields_and_audits(module, payload, expected):
@@ -520,7 +613,10 @@ def test_site_config_create_modules_defaults_fields_and_audits(module, payload,
for key, value in expected.items():
assert body[key] == value
assert body["isActive"] is True
assert body["sortOrder"] == 5
if module == "demandForm":
assert "sortOrder" not in body
else:
assert body["sortOrder"] == 5
assert fake_db.committed
assert fake_db.added[-1].action == "create"
@@ -615,6 +711,23 @@ def test_admin_site_config_includes_destination_page_modules():
assert body["destinationRegions"] == []
def test_admin_site_config_includes_demand_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["demandHero"] == []
assert body["demandFeatureCards"] == []
assert body["demandForm"] == []
assert body["demandRecommendations"] == []
def test_public_site_config_includes_destination_page_modules():
destination_hero = make_destination_hero()
destination_region = make_destination_region()
@@ -648,6 +761,53 @@ def test_public_site_config_includes_destination_page_modules():
]
def test_public_site_config_includes_demand_modules_and_filters_unpublished_recommendation_products():
published = make_product(id="product-published", status="published")
draft = make_product(id="product-draft", status="draft")
recommendation = make_demand_recommendation(
products=[
make_demand_recommendation_product(product=published, productId=published.id, sortOrder=1),
make_demand_recommendation_product(product=draft, productId=draft.id, sortOrder=0),
]
)
fake_db = FakeDb(
scalar_results=[
[],
[],
[],
[],
[],
[],
[],
[],
[],
[],
[],
[make_demand_hero()],
[make_demand_feature_card()],
[make_demand_form()],
[recommendation],
]
)
result = site_config(fake_db, active_only=True)
assert result["demandHero"][0]["title"] == "告诉我们日期、人数和想法"
assert result["demandHero"][0]["steps"] == ["提交需求", "管家沟通", "确认方案"]
assert result["demandFeatureCards"][0]["title"] == "动线"
assert result["demandForm"][0]["submitLabel"] == "提交出行需求"
assert result["demandForm"][0]["chips"] == ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"]
assert result["demandRecommendations"] == [
{
"id": "demand-recommendation-test",
"title": "热门推荐",
"subtitle": "也可以先挑一条线路沟通",
"productIds": ["product-published"],
"isActive": True,
}
]
def test_admin_site_config_includes_campaigns_for_special_offers():
campaign = make_campaign(status="published")
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [campaign], [], [], []])
@@ -880,6 +1040,21 @@ def test_site_config_create_map_image_rejects_duplicate_singleton():
assert not fake_db.committed
def test_site_config_create_demand_form_rejects_duplicate_singleton():
fake_db = FakeDb(scalar_results=[[make_demand_form()]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post("/api/admin/site-config/demandForm", json={"submitLabel": "提交"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 409
assert response.json()["code"] == "MODULE_CONFIG_SINGLETON_EXISTS"
assert not fake_db.added
assert not fake_db.committed
def test_site_config_create_campaign_defaults_draft_and_audits():
fake_db = FakeDb()
app = authenticated_app(fake_db)
@@ -1087,6 +1262,42 @@ def test_site_config_patch_route_section_updates_copy_and_product_order():
assert fake_db.committed
def test_site_config_patch_demand_recommendation_updates_product_order_without_route_conflict():
recommendation = make_demand_recommendation()
product_a = make_product(id="product-a")
product_b = make_product(id="product-b")
fake_db = FakeDb(
get_result=recommendation,
scalar_results=[[product_b, product_a]],
execute_results=[[]],
)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/demandRecommendations/demand-recommendation-test",
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, DemandRecommendationProduct)]
assert [(link.productId, link.sortOrder) for link in links] == [("product-b", 0), ("product-a", 1)]
assert fake_db.added[-1].entity == "demand_recommendation"
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")
@@ -1478,6 +1689,37 @@ def test_admin_media_upload_rejects_non_image_file():
assert not fake_db.committed
def test_admin_leads_endpoint_accepts_source_keyword_and_created_range_filters():
fake_db = FakeDb(scalar_results=[[]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get(
"/api/admin/leads",
params={
"status": "new",
"sourcePage": "demand_page",
"keyword": "荔波",
"createdFrom": "2026-01-01",
"createdTo": "2026-01-31",
"take": 50,
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"items": []}
stmt = str(fake_db.scalar_statements[0])
assert '"Lead".status' in stmt
assert '"Lead"."sourcePage"' in stmt
assert '"Lead"."createdAt"' in stmt
assert '"Product".title' in stmt
assert '"Lead".phone' in stmt
assert '"Lead".destination' in stmt
assert '"Lead".note' in stmt
def test_admin_lead_status_update_returns_updated_status_and_audits():
lead = Lead(
id="lead-test",