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

@@ -120,6 +120,41 @@ VEHICLE_OPTIONS = [
},
]
DEMAND_HERO = [
{
"kicker": "3步定制",
"title": "告诉我们日期、人数和想法",
"description": "管家会按同行人、预算和体力强度,重新组合酒店、用车和景点节奏。",
"steps": ["提交需求", "管家沟通", "确认方案"],
}
]
DEMAND_FEATURE_CARDS = [
{"title": "动线", "description": "按天数顺路排"},
{"title": "住宿", "description": "城市与山野组合"},
{"title": "用车", "description": "人数行李匹配"},
]
DEMAND_FORM = {
"destinationLabel": "目的地/玩法",
"destinationPlaceholder": "例如:贵州、黄果树、西江苗寨",
"phoneLabel": "联系方式",
"phonePlaceholder": "手机号 / 微信号",
"noteLabel": "补充说明",
"notePlaceholder": "出行日期、人数、酒店偏好、预算范围",
"submitLabel": "提交出行需求",
"chips": ["贵州", "黄果树", "荔波小七孔", "西江苗寨", "梵净山", "万峰林"],
}
DEMAND_RECOMMENDATIONS = [
{
"title": "热门推荐",
"subtitle": "也可以先挑一条线路沟通",
"start": 16,
"end": 22,
}
]
CAMPAIGNS = [
{"slug": "classic-deal", "title": "经典打卡特惠", "start": 0, "end": 8, "coverImage": HERO_SLIDES[0]["image"]},
{"slug": "outdoor-deal", "title": "山野野咖特惠", "start": 8, "end": 16, "coverImage": HERO_SLIDES[1]["image"]},

View File

@@ -0,0 +1,66 @@
from fastapi import HTTPException
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, selectinload
from .models import DemandRecommendation, DemandRecommendationProduct, Product
from .serializers import model_dict
def demand_recommendation_error(status_code: int, message: str, code: str, details: dict | None = None) -> None:
raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}})
def demand_recommendation_dict(item: DemandRecommendation, *, public: bool = False) -> dict:
links = sorted(item.products or [], key=lambda link: (link.sortOrder, link.productId))
if public:
links = [link for link in links if link.product and link.product.status == "published"]
data = {
"id": item.id,
"title": item.title,
"subtitle": item.subtitle,
"productIds": [link.productId for link in links],
"isActive": item.isActive,
}
if not public:
base = model_dict(item)
data.update(
{
"sortOrder": item.sortOrder,
"createdAt": base["createdAt"],
"updatedAt": base["updatedAt"],
}
)
return data
def demand_recommendation_query():
return (
select(DemandRecommendation)
.options(selectinload(DemandRecommendation.products).selectinload(DemandRecommendationProduct.product))
.order_by(DemandRecommendation.sortOrder.asc())
)
def validate_demand_recommendation_product_ids(db: Session, product_ids: list[str]) -> None:
if len(set(product_ids)) != len(product_ids):
demand_recommendation_error(422, "推荐线路商品不能重复", "DEMAND_RECOMMENDATION_PRODUCT_DUPLICATE")
if not product_ids:
return
products = db.scalars(select(Product).where(Product.id.in_(product_ids))).all()
found_ids = {product.id for product in products}
missing_ids = [product_id for product_id in product_ids if product_id not in found_ids]
if missing_ids:
demand_recommendation_error(422, "推荐线路商品不存在", "DEMAND_RECOMMENDATION_PRODUCT_NOT_FOUND", {"productIds": missing_ids})
def replace_demand_recommendation_products(db: Session, recommendation: DemandRecommendation, product_ids: list[str]) -> None:
validate_demand_recommendation_product_ids(db, product_ids)
db.execute(delete(DemandRecommendationProduct).where(DemandRecommendationProduct.recommendationId == recommendation.id))
db.flush()
recommendation.products = []
for index, product_id in enumerate(product_ids):
link = DemandRecommendationProduct(recommendationId=recommendation.id, productId=product_id, sortOrder=index)
recommendation.products.append(link)
db.add(link)

View File

@@ -207,6 +207,7 @@ class Product(Base):
images: Mapped[list["ProductImage"]] = relationship(back_populates="product", cascade="all, delete-orphan")
campaignLinks: Mapped[list["CampaignProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan")
routeSectionLinks: Mapped[list["RouteSectionProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan")
demandRecommendationLinks: Mapped[list["DemandRecommendationProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan")
leads: Mapped[list["Lead"]] = relationship(back_populates="sourceProduct")
orders: Mapped[list["Order"]] = relationship(back_populates="product")
@@ -279,6 +280,74 @@ class RouteSectionProduct(Base):
product: Mapped[Product] = relationship(back_populates="routeSectionLinks")
class DemandHero(Base):
__tablename__ = "DemandHero"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
title: Mapped[str] = mapped_column(String, nullable=False)
kicker: Mapped[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(Text)
steps: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, nullable=False)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class DemandFeatureCard(Base):
__tablename__ = "DemandFeatureCard"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
title: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class DemandForm(Base):
__tablename__ = "DemandForm"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
destinationLabel: Mapped[str] = mapped_column(String, default="目的地/玩法", nullable=False)
destinationPlaceholder: Mapped[str | None] = mapped_column(String)
phoneLabel: Mapped[str] = mapped_column(String, default="联系方式", nullable=False)
phonePlaceholder: Mapped[str | None] = mapped_column(String)
noteLabel: Mapped[str] = mapped_column(String, default="补充说明", nullable=False)
notePlaceholder: Mapped[str | None] = mapped_column(String)
submitLabel: Mapped[str] = mapped_column(String, default="提交出行需求", nullable=False)
chips: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, nullable=False)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class DemandRecommendation(Base):
__tablename__ = "DemandRecommendation"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
title: Mapped[str] = mapped_column(String, nullable=False)
subtitle: Mapped[str | None] = mapped_column(Text)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
products: Mapped[list["DemandRecommendationProduct"]] = relationship(back_populates="recommendation", cascade="all, delete-orphan")
class DemandRecommendationProduct(Base):
__tablename__ = "DemandRecommendationProduct"
recommendationId: Mapped[str] = mapped_column(String, ForeignKey("DemandRecommendation.id", ondelete="CASCADE"), primary_key=True)
productId: Mapped[str] = mapped_column(String, ForeignKey("Product.id", ondelete="CASCADE"), primary_key=True)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
recommendation: Mapped[DemandRecommendation] = relationship(back_populates="products")
product: Mapped[Product] = relationship(back_populates="demandRecommendationLinks")
class Lead(Base):
__tablename__ = "Lead"

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]}

View File

@@ -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

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from datetime import datetime, timedelta
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
from pydantic import BaseModel, EmailStr, Field, ValidationInfo, field_validator
ProductStatus = Literal["draft", "published", "archived"]
@@ -115,8 +115,23 @@ class AdminProductQuery(BaseModel):
class LeadQuery(BaseModel):
status: LeadStatus | None = None
sourcePage: str | None = None
keyword: str | None = None
createdFrom: datetime | None = None
createdTo: datetime | None = None
take: int = Field(default=100, ge=1, le=200)
@field_validator("createdFrom", "createdTo", mode="before")
@classmethod
def parse_date_only_range(cls, value, info: ValidationInfo):
if isinstance(value, str) and len(value) == 10:
try:
parsed = datetime.strptime(value, "%Y-%m-%d")
return parsed + timedelta(days=1) if info.field_name == "createdTo" else parsed
except ValueError:
return value
return value
class SiteConfigPatchIn(BaseModel):
title: str | None = None
@@ -135,6 +150,15 @@ class SiteConfigPatchIn(BaseModel):
priceAmount: int | None = Field(default=None, ge=0)
priceUnit: str | None = None
tags: list[str] | None = None
steps: list[str] | None = None
destinationLabel: str | None = None
destinationPlaceholder: str | None = None
phoneLabel: str | None = None
phonePlaceholder: str | None = None
noteLabel: str | None = None
notePlaceholder: str | None = None
submitLabel: str | None = None
chips: list[str] | None = None
productIds: list[str] | None = None
status: str | None = None
startsAt: datetime | None = None

View File

@@ -8,13 +8,18 @@ from urllib.parse import quote
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from .auth import hash_password
from .content import ALIASES, CAMPAIGNS, CTAS, DESTINATION_HERO, DESTINATION_REGIONS, DESTINATIONS, HERO_SLIDES, HOTEL_GROUPS, THEMES, VEHICLE_OPTIONS
from .content import ALIASES, CAMPAIGNS, CTAS, DEMAND_FEATURE_CARDS, DEMAND_FORM, DEMAND_HERO, DEMAND_RECOMMENDATIONS, DESTINATION_HERO, DESTINATION_REGIONS, DESTINATIONS, HERO_SLIDES, HOTEL_GROUPS, THEMES, VEHICLE_OPTIONS
from .database import Base, SessionLocal, engine
from .models import (
AdminUser,
Campaign,
CampaignProduct,
CtaBanner,
DemandFeatureCard,
DemandForm,
DemandHero,
DemandRecommendation,
DemandRecommendationProduct,
Destination,
DestinationAlias,
DestinationHero,
@@ -104,7 +109,7 @@ def load_products() -> list[dict]:
def reset_guizhou_content(db: Session) -> dict:
products = load_products()
for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, VehicleOption, HotelGroup, CtaBanner, ThemeCard, DestinationRegion, DestinationHero, HeroSlide, DestinationAlias, Destination, MediaAsset]:
for model in [SiteVersion, DemandRecommendationProduct, DemandRecommendation, DemandForm, DemandFeatureCard, DemandHero, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, VehicleOption, HotelGroup, CtaBanner, ThemeCard, DestinationRegion, DestinationHero, HeroSlide, DestinationAlias, Destination, MediaAsset]:
db.execute(delete(model))
db.flush()
@@ -188,6 +193,29 @@ def reset_guizhou_content(db: Session) -> dict:
sortOrder=index,
)
)
for index, item in enumerate(DEMAND_HERO):
db.add(
DemandHero(
title=item["title"],
kicker=item.get("kicker"),
description=item.get("description"),
steps=item.get("steps", []),
sortOrder=index,
)
)
for index, item in enumerate(DEMAND_FEATURE_CARDS):
db.add(
DemandFeatureCard(
title=item["title"],
description=item.get("description"),
sortOrder=index,
)
)
db.add(DemandForm(**DEMAND_FORM))
for product in products:
create_media(db, product.get("image"), "product", product["title"])
matched_destination = product.get("destinationName") if product.get("destinationName") in destination_map else None
@@ -245,6 +273,22 @@ def reset_guizhou_content(db: Session) -> dict:
for index, product in enumerate(section_products):
db.add(RouteSectionProduct(sectionId=section.id, productId=product.id, sortOrder=index))
for sort_order, seed in enumerate(DEMAND_RECOMMENDATIONS):
recommendation = DemandRecommendation(
title=seed["title"],
subtitle=seed.get("subtitle"),
sortOrder=sort_order,
)
db.add(recommendation)
db.flush()
linked_products = db.scalars(
select(Product)
.where(Product.sourceId >= seed["start"] + 1, Product.sourceId <= seed["end"])
.order_by(Product.sourceId.asc())
).all()
for index, product in enumerate(linked_products):
db.add(DemandRecommendationProduct(recommendationId=recommendation.id, productId=product.id, sortOrder=index))
snapshot = SiteVersion(
title="guizhou-content-reset",
status="published",
@@ -259,6 +303,10 @@ def reset_guizhou_content(db: Session) -> dict:
"routeSections": len(ROUTE_SECTION_DEFAULTS),
"hotelGroups": len(HOTEL_GROUPS),
"vehicleOptions": len(VEHICLE_OPTIONS),
"demandHero": len(DEMAND_HERO),
"demandFeatureCards": len(DEMAND_FEATURE_CARDS),
"demandForm": 1,
"demandRecommendations": len(DEMAND_RECOMMENDATIONS),
},
)
db.add(snapshot)
@@ -274,6 +322,10 @@ def reset_guizhou_content(db: Session) -> dict:
"routeSections": len(ROUTE_SECTION_DEFAULTS),
"hotelGroups": len(HOTEL_GROUPS),
"vehicleOptions": len(VEHICLE_OPTIONS),
"demandHero": len(DEMAND_HERO),
"demandFeatureCards": len(DEMAND_FEATURE_CARDS),
"demandForm": 1,
"demandRecommendations": len(DEMAND_RECOMMENDATIONS),
"siteVersionId": snapshot.id,
}