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:
@@ -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"]},
|
||||
|
||||
66
app/demand_recommendations.py
Normal file
66
app/demand_recommendations.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
56
app/seed.py
56
app/seed.py
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user