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