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

@@ -18,6 +18,11 @@ from ..models import (
AuditLog,
Campaign,
CtaBanner,
DemandFeatureCard,
DemandForm,
DemandHero,
DemandRecommendation,
DemandRecommendationProduct,
Destination,
DestinationHero,
DestinationRegion,
@@ -37,6 +42,7 @@ from ..models import (
)
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
from ..seed import create_media, reset_guizhou_content
from ..demand_recommendations import demand_recommendation_dict, demand_recommendation_query, replace_demand_recommendation_products
from ..route_sections import replace_route_section_products, route_section_dict, route_section_query
from ..serializers import admin_product_dict, destination_dict, encode_value, hotel_group_dict, lead_dict, model_dict
from .shared import site_config
@@ -88,6 +94,63 @@ SITE_CONFIG_MODULES = {
"empty_to_none": {"keyword", "spots"},
"create_defaults": {"keyword": None, "spots": None},
},
"demandHero": {
"model": DemandHero,
"entity": "demand_hero",
"primary": "title",
"fields": {"title", "kicker", "description", "steps", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"kicker", "description"},
"create_defaults": {"kicker": None, "description": None, "steps": []},
},
"demandFeatureCards": {
"model": DemandFeatureCard,
"entity": "demand_feature_card",
"primary": "title",
"fields": {"title", "description", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"description"},
"create_defaults": {"description": None},
},
"demandForm": {
"model": DemandForm,
"entity": "demand_form",
"primary": "submitLabel",
"fields": {
"destinationLabel",
"destinationPlaceholder",
"phoneLabel",
"phonePlaceholder",
"noteLabel",
"notePlaceholder",
"submitLabel",
"chips",
"isActive",
},
"none_to_empty": set(),
"empty_to_none": {"destinationPlaceholder", "phonePlaceholder", "notePlaceholder"},
"create_defaults": {
"destinationLabel": "目的地/玩法",
"destinationPlaceholder": "例如:贵州、黄果树、西江苗寨",
"phoneLabel": "联系方式",
"phonePlaceholder": "手机号 / 微信号",
"noteLabel": "补充说明",
"notePlaceholder": "出行日期、人数、酒店偏好、预算范围",
"submitLabel": "提交出行需求",
"chips": ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"],
},
"ordered": False,
"singleton": True,
},
"demandRecommendations": {
"model": DemandRecommendation,
"entity": "demand_recommendation",
"primary": "title",
"fields": {"title", "subtitle", "productIds", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"subtitle"},
"create_defaults": {"subtitle": None},
},
"map": {
"model": MapImage,
"entity": "map_image",
@@ -353,9 +416,17 @@ def normalize_campaign_tags(value) -> list[str]:
return tags
def normalize_string_list(value) -> list[str]:
if value is None:
return []
return [item.strip() for item in value if isinstance(item, str) and item.strip()]
def site_field_value(config: dict, field: str, value):
if field == "tags":
return normalize_campaign_tags(value)
if field in {"steps", "chips"}:
return normalize_string_list(value)
value = clean_site_value(value)
if value == "" and field in config.get("empty_to_none", set()):
return None
@@ -414,6 +485,8 @@ def site_item_dict(module: str, item) -> dict:
return map_image_admin_dict(item)
if module == "routeSections":
return route_section_dict(item)
if module == "demandRecommendations":
return demand_recommendation_dict(item)
if module == "hotelGroups":
return hotel_group_dict(item)
return destination_dict(item) if module == "destinations" else model_dict(item)
@@ -424,6 +497,8 @@ def module_items(db: Session, config: dict) -> list:
stmt = select(model)
if model is RouteSection:
stmt = stmt.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product))
if model is DemandRecommendation:
stmt = stmt.options(selectinload(DemandRecommendation.products).selectinload(DemandRecommendationProduct.product))
if config.get("ordered", True):
stmt = stmt.order_by(model.sortOrder.asc())
else:
@@ -708,6 +783,19 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
model_dict(item)
for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all()
],
"demandHero": [
model_dict(item)
for item in db.scalars(select(DemandHero).order_by(DemandHero.sortOrder.asc())).all()
],
"demandFeatureCards": [
model_dict(item)
for item in db.scalars(select(DemandFeatureCard).order_by(DemandFeatureCard.sortOrder.asc())).all()
],
"demandForm": [
model_dict(item)
for item in db.scalars(select(DemandForm).order_by(DemandForm.createdAt.asc())).all()
],
"demandRecommendations": [demand_recommendation_dict(item) for item in db.scalars(demand_recommendation_query()).all()],
}
@@ -725,10 +813,15 @@ def create_site_config(
if config.get("fixed"):
site_config_error(405, "固定模块不支持新增", "MODULE_CONFIG_CREATE_UNSUPPORTED", {"module": module})
if config.get("singleton") and module_items(db, config):
site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module})
if module == "map":
site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module})
site_config_error(409, "单例配置已存在", "MODULE_CONFIG_SINGLETON_EXISTS", {"module": module})
item = config["model"](**site_create_payload(module, config, body, db))
db.add(item)
db.flush()
if module == "demandRecommendations" and "productIds" in body.model_fields_set:
replace_demand_recommendation_products(db, item, body.productIds or [])
db.flush()
after = site_item_dict(module, item)
audit(db, get_actor_id(request), "create", config["entity"], item.id, after)
db.commit()
@@ -783,6 +876,8 @@ def update_site_config(
apply_site_patch(module, config, item, body)
if module == "routeSections" and "productIds" in body.model_fields_set:
replace_route_section_products(db, item, body.productIds or [])
if module == "demandRecommendations" and "productIds" in body.model_fields_set:
replace_demand_recommendation_products(db, item, body.productIds or [])
db.flush()
after = site_item_dict(module, item)
audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before)
@@ -822,11 +917,22 @@ def delete_site_config(
@router.get("/leads")
def list_leads(
status_value: LeadStatus | None = Query(default=None, alias="status"),
sourcePage: str | None = None,
keyword: str | None = None,
createdFrom: str | None = None,
createdTo: str | None = None,
take: int = Query(default=100, ge=1, le=200),
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
query = LeadQuery(status=status_value, take=take)
query = LeadQuery(
status=status_value,
sourcePage=sourcePage,
keyword=keyword,
createdFrom=createdFrom,
createdTo=createdTo,
take=take,
)
stmt = (
select(Lead)
.options(selectinload(Lead.sourceProduct), selectinload(Lead.assignedUser))
@@ -835,6 +941,26 @@ def list_leads(
)
if query.status:
stmt = stmt.where(Lead.status == query.status)
if query.sourcePage:
stmt = stmt.where(Lead.sourcePage == query.sourcePage.strip())
if query.createdFrom:
stmt = stmt.where(Lead.createdAt >= query.createdFrom)
if query.createdTo:
stmt = stmt.where(Lead.createdAt < query.createdTo)
if query.keyword and query.keyword.strip():
pattern = f"%{query.keyword.strip()}%"
stmt = (
stmt.outerjoin(Lead.sourceProduct)
.where(
or_(
Lead.phone.ilike(pattern),
Lead.destination.ilike(pattern),
Lead.note.ilike(pattern),
Product.title.ilike(pattern),
)
)
.distinct()
)
leads = db.scalars(stmt).all()
return {"items": [lead_dict(lead) for lead in leads]}