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:
@@ -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]}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from ..models import Campaign, CtaBanner, Destination, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption
|
||||
from ..models import Campaign, CtaBanner, DemandFeatureCard, DemandForm, DemandHero, Destination, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption
|
||||
from ..demand_recommendations import demand_recommendation_dict, demand_recommendation_query
|
||||
from ..route_sections import route_section_dict, route_section_query
|
||||
from ..serializers import destination_dict, hotel_group_dict, model_dict
|
||||
|
||||
@@ -15,6 +16,9 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
|
||||
cta_stmt = select(CtaBanner).order_by(CtaBanner.sortOrder.asc())
|
||||
hotel_stmt = select(HotelGroup).order_by(HotelGroup.sortOrder.asc())
|
||||
vehicle_stmt = select(VehicleOption).order_by(VehicleOption.sortOrder.asc())
|
||||
demand_hero_stmt = select(DemandHero).order_by(DemandHero.sortOrder.asc())
|
||||
demand_feature_card_stmt = select(DemandFeatureCard).order_by(DemandFeatureCard.sortOrder.asc())
|
||||
demand_form_stmt = select(DemandForm).order_by(DemandForm.createdAt.asc())
|
||||
if active_only:
|
||||
hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True))
|
||||
destination_stmt = destination_stmt.where(Destination.isActive.is_(True))
|
||||
@@ -25,6 +29,9 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
|
||||
cta_stmt = cta_stmt.where(CtaBanner.isActive.is_(True))
|
||||
hotel_stmt = hotel_stmt.where(HotelGroup.isActive.is_(True), HotelGroup.status == "published")
|
||||
vehicle_stmt = vehicle_stmt.where(VehicleOption.isActive.is_(True))
|
||||
demand_hero_stmt = demand_hero_stmt.where(DemandHero.isActive.is_(True))
|
||||
demand_feature_card_stmt = demand_feature_card_stmt.where(DemandFeatureCard.isActive.is_(True))
|
||||
demand_form_stmt = demand_form_stmt.where(DemandForm.isActive.is_(True))
|
||||
|
||||
hero_slides = db.scalars(hero_stmt).all()
|
||||
destinations = db.scalars(destination_stmt).all()
|
||||
@@ -51,4 +58,11 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
|
||||
result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections]
|
||||
result["hotelGroups"] = [hotel_group_dict(item) for item in db.scalars(hotel_stmt).all()]
|
||||
result["vehicleOptions"] = [model_dict(item) for item in db.scalars(vehicle_stmt).all()]
|
||||
result["demandHero"] = [model_dict(item) for item in db.scalars(demand_hero_stmt).all()]
|
||||
result["demandFeatureCards"] = [model_dict(item) for item in db.scalars(demand_feature_card_stmt).all()]
|
||||
result["demandForm"] = [model_dict(item) for item in db.scalars(demand_form_stmt).all()]
|
||||
demand_recommendations = db.scalars(demand_recommendation_query()).all()
|
||||
if active_only:
|
||||
demand_recommendations = [item for item in demand_recommendations if item.isActive]
|
||||
result["demandRecommendations"] = [demand_recommendation_dict(item, public=active_only) for item in demand_recommendations]
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user