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:
106
alembic/versions/0008_demand_page_modules.py
Normal file
106
alembic/versions/0008_demand_page_modules.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Add demand page configurable modules.
|
||||
|
||||
Revision ID: 0008_demand_page_modules
|
||||
Revises: 0007_destination_page_modules
|
||||
Create Date: 2026-07-07
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "0008_demand_page_modules"
|
||||
down_revision = "0007_destination_page_modules"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
existing_tables = set(inspect(bind).get_table_names())
|
||||
|
||||
if "DemandHero" not in existing_tables:
|
||||
op.create_table(
|
||||
"DemandHero",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=False),
|
||||
sa.Column("kicker", sa.String(), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("steps", postgresql.ARRAY(sa.String()), nullable=False, server_default=sa.text("'{}'")),
|
||||
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "DemandFeatureCard" not in existing_tables:
|
||||
op.create_table(
|
||||
"DemandFeatureCard",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "DemandForm" not in existing_tables:
|
||||
op.create_table(
|
||||
"DemandForm",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("destinationLabel", sa.String(), nullable=False, server_default="目的地/玩法"),
|
||||
sa.Column("destinationPlaceholder", sa.String(), nullable=True),
|
||||
sa.Column("phoneLabel", sa.String(), nullable=False, server_default="联系方式"),
|
||||
sa.Column("phonePlaceholder", sa.String(), nullable=True),
|
||||
sa.Column("noteLabel", sa.String(), nullable=False, server_default="补充说明"),
|
||||
sa.Column("notePlaceholder", sa.String(), nullable=True),
|
||||
sa.Column("submitLabel", sa.String(), nullable=False, server_default="提交出行需求"),
|
||||
sa.Column("chips", postgresql.ARRAY(sa.String()), nullable=False, server_default=sa.text("'{}'")),
|
||||
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "DemandRecommendation" not in existing_tables:
|
||||
op.create_table(
|
||||
"DemandRecommendation",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=False),
|
||||
sa.Column("subtitle", sa.Text(), nullable=True),
|
||||
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
if "DemandRecommendationProduct" not in existing_tables:
|
||||
op.create_table(
|
||||
"DemandRecommendationProduct",
|
||||
sa.Column("recommendationId", sa.String(), nullable=False),
|
||||
sa.Column("productId", sa.String(), nullable=False),
|
||||
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.ForeignKeyConstraint(["productId"], ["Product.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["recommendationId"], ["DemandRecommendation.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("recommendationId", "productId"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
existing_tables = set(inspect(bind).get_table_names())
|
||||
for table_name in [
|
||||
"DemandRecommendationProduct",
|
||||
"DemandRecommendation",
|
||||
"DemandForm",
|
||||
"DemandFeatureCard",
|
||||
"DemandHero",
|
||||
]:
|
||||
if table_name in existing_tables:
|
||||
op.drop_table(table_name)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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