This commit adds full support for two new destination page modules: a hero banner section and categorized destination regions. Changes include: - New SQLAlchemy models and alembic migration for DestinationHero and DestinationRegion tables - Updated Pydantic SiteConfigPatchIn schema with new optional fields - Integrated admin CRUD support for the new modules - Added default seed data for Guizhou destination regions and hero content - Exposed active modules via public site config API - Added comprehensive test coverage for admin and public endpoints
315 lines
12 KiB
Python
315 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import re
|
||
from pathlib import Path
|
||
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 .database import Base, SessionLocal, engine
|
||
from .models import (
|
||
AdminUser,
|
||
Campaign,
|
||
CampaignProduct,
|
||
CtaBanner,
|
||
Destination,
|
||
DestinationAlias,
|
||
DestinationHero,
|
||
DestinationRegion,
|
||
HeroSlide,
|
||
HotelGroup,
|
||
MediaAsset,
|
||
Product,
|
||
ProductImage,
|
||
RouteSection,
|
||
RouteSectionProduct,
|
||
SiteVersion,
|
||
ThemeCard,
|
||
VehicleOption,
|
||
utc_now,
|
||
)
|
||
from .route_sections import ROUTE_SECTION_DEFAULTS
|
||
|
||
|
||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||
|
||
|
||
def slugify(value: str) -> str:
|
||
return quote(value, safe="").replace("%", "").lower()
|
||
|
||
|
||
def create_media(db: Session, url: str | None, group: str, name: str | None = None) -> None:
|
||
if not url:
|
||
return
|
||
media = db.scalar(select(MediaAsset).where(MediaAsset.url == url))
|
||
if media:
|
||
media.group = group
|
||
media.name = name
|
||
else:
|
||
db.add(MediaAsset(url=url, group=group, name=name))
|
||
|
||
|
||
def default_detail_sections(summary: str, location: str) -> list[dict]:
|
||
return [
|
||
{
|
||
"key": "overview",
|
||
"label": "行程概述",
|
||
"title": "小包团专属概览",
|
||
"blocks": [{"type": "text", "text": f"{summary}。万趣会按同行人、预算、酒店偏好和体力强度重排细节,保留小车小团、错峰入园与在地向导服务。"}],
|
||
},
|
||
{
|
||
"key": "itinerary",
|
||
"label": "每日行程",
|
||
"title": f"{location} 弹性安排",
|
||
"blocks": [{"type": "text", "text": "默认按抵达接站、核心景点游览、特色体验、酒店休整和返程送站安排每日节奏;具体天数、停留时长和餐食可在行前由服务管家二次确认。"}],
|
||
},
|
||
{
|
||
"key": "service",
|
||
"label": "包含/不含服务",
|
||
"title": "费用边界清晰",
|
||
"blocks": [{"type": "text", "text": "通常包含当地用车、行程内住宿、列明门票/体验、必要讲解和服务管家跟进;大交通、个人消费、未列明餐食和自选项目以最终方案为准。"}],
|
||
},
|
||
{
|
||
"key": "notice",
|
||
"label": "出行须知",
|
||
"title": "贵州山地旅行提示",
|
||
"blocks": [{"type": "text", "text": "贵州多山多雨,建议准备防滑鞋、轻便雨具和薄外套;溶洞、漂流、徒步等体验会按天气和同行人体力调整。"}],
|
||
},
|
||
{
|
||
"key": "price",
|
||
"label": "价格区间",
|
||
"title": "按人数、酒店和季节报价",
|
||
"blocks": [{"type": "text", "text": "页面价格为参考起价,节假日、旺季房态、用车车型和体验资源会影响最终报价;提交需求后由服务管家给出可执行方案。"}],
|
||
},
|
||
{
|
||
"key": "manager",
|
||
"label": "服务管家",
|
||
"title": "直接添加服务管家",
|
||
"blocks": [{"type": "text", "text": "点击底部“服务管家”或拨打 18786174929,可直接添加服务管家沟通出行人数、日期、酒店偏好和预算。"}],
|
||
},
|
||
]
|
||
|
||
|
||
def product_subtitle(title: str) -> str:
|
||
return re.sub(r"^【.*?】\s*", "", title).split("·")[0]
|
||
|
||
|
||
def load_products() -> list[dict]:
|
||
with (ROOT_DIR / "data" / "generated-products.json").open(encoding="utf-8") as handle:
|
||
return json.load(handle)
|
||
|
||
|
||
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]:
|
||
db.execute(delete(model))
|
||
db.flush()
|
||
|
||
for index, slide in enumerate(HERO_SLIDES):
|
||
create_media(db, slide["image"], "hero", slide["title"])
|
||
db.add(
|
||
HeroSlide(
|
||
title=slide["title"],
|
||
kicker=slide["kicker"],
|
||
actionLabel=slide["action"],
|
||
image=slide["image"],
|
||
targetType="campaign",
|
||
targetValue=slide["targetValue"],
|
||
sortOrder=index,
|
||
)
|
||
)
|
||
|
||
destination_map: dict[str, str] = {}
|
||
for index, (name, image) in enumerate(DESTINATIONS):
|
||
create_media(db, image, "destination", name)
|
||
destination = Destination(name=name, slug=slugify(name), image=image, isHot=index < 8, sortOrder=index)
|
||
db.add(destination)
|
||
db.flush()
|
||
destination_map[name] = destination.id
|
||
for alias in ALIASES.get(name, []):
|
||
db.add(DestinationAlias(destinationId=destination.id, alias=alias))
|
||
|
||
for index, item in enumerate(DESTINATION_HERO):
|
||
create_media(db, item.get("image"), "destination-hero", item["title"])
|
||
db.add(
|
||
DestinationHero(
|
||
title=item["title"],
|
||
kicker=item.get("kicker"),
|
||
image=item.get("image"),
|
||
sortOrder=index,
|
||
)
|
||
)
|
||
|
||
for index, item in enumerate(DESTINATION_REGIONS):
|
||
db.add(
|
||
DestinationRegion(
|
||
label=item["label"],
|
||
keyword=item.get("keyword"),
|
||
spots=item.get("spots"),
|
||
sortOrder=index,
|
||
)
|
||
)
|
||
|
||
for index, (label, image) in enumerate(THEMES):
|
||
create_media(db, image, "theme", label)
|
||
db.add(ThemeCard(label=label, image=image, targetType="search", targetValue=label, sortOrder=index))
|
||
|
||
for index, (alt, image, target_type, target_value) in enumerate(CTAS):
|
||
create_media(db, image, "cta", alt)
|
||
db.add(CtaBanner(alt=alt, image=image, targetType=target_type, targetValue=target_value, sortOrder=index))
|
||
|
||
for index, group in enumerate(HOTEL_GROUPS):
|
||
create_media(db, group["image"], "hotel-group", group["title"])
|
||
db.add(
|
||
HotelGroup(
|
||
title=group["title"],
|
||
description=group.get("description"),
|
||
image=group.get("image"),
|
||
coverImage=group.get("coverImage") or group.get("image"),
|
||
priceAmount=group.get("priceAmount"),
|
||
priceUnit=group.get("priceUnit") or "起/晚",
|
||
tags=group.get("tags", []),
|
||
status=group.get("status", "published"),
|
||
isActive=group.get("isActive", True),
|
||
sortOrder=index,
|
||
)
|
||
)
|
||
|
||
for index, option in enumerate(VEHICLE_OPTIONS):
|
||
create_media(db, option["image"], "vehicle-option", option["title"])
|
||
db.add(
|
||
VehicleOption(
|
||
title=option["title"],
|
||
description=option.get("description"),
|
||
image=option.get("image"),
|
||
sortOrder=index,
|
||
)
|
||
)
|
||
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
|
||
if not matched_destination:
|
||
matched_destination = next(
|
||
(name for name in destination_map if name in product["title"] or name in product.get("tags", [])),
|
||
None,
|
||
)
|
||
summary = product.get("summary") or " · ".join(product.get("tags", []))
|
||
created = Product(
|
||
sourceId=product["id"],
|
||
title=product["title"],
|
||
subtitle=product_subtitle(product["title"]),
|
||
destinationId=destination_map.get(matched_destination) if matched_destination else None,
|
||
priceAmount=int(product["price"]),
|
||
tags=product.get("tags", []),
|
||
coverImage=product.get("image"),
|
||
summary=summary,
|
||
detailSections=default_detail_sections(summary, matched_destination or "贵州省内定制"),
|
||
status="published",
|
||
sortWeight=product["id"],
|
||
publishedAt=utc_now(),
|
||
)
|
||
db.add(created)
|
||
db.flush()
|
||
db.add(ProductImage(productId=created.id, url=product["image"], alt=product["title"]))
|
||
|
||
for seed in CAMPAIGNS:
|
||
campaign = Campaign(
|
||
slug=seed["slug"],
|
||
title=seed["title"],
|
||
description="万趣贵州小包团活动专题。",
|
||
coverImage=seed["coverImage"],
|
||
status="published",
|
||
)
|
||
db.add(campaign)
|
||
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(CampaignProduct(campaignId=campaign.id, productId=product.id, sortOrder=index))
|
||
|
||
for sort_order, (section_id, title, subtitle, start, end) in enumerate(ROUTE_SECTION_DEFAULTS):
|
||
section = RouteSection(id=section_id, title=title, subtitle=subtitle, sortOrder=sort_order)
|
||
db.add(section)
|
||
db.flush()
|
||
section_products = db.scalars(
|
||
select(Product)
|
||
.where(Product.sourceId >= start + 1, Product.sourceId <= end)
|
||
.order_by(Product.sourceId.asc())
|
||
).all()
|
||
for index, product in enumerate(section_products):
|
||
db.add(RouteSectionProduct(sectionId=section.id, productId=product.id, sortOrder=index))
|
||
|
||
snapshot = SiteVersion(
|
||
title="guizhou-content-reset",
|
||
status="published",
|
||
publishedAt=utc_now(),
|
||
snapshot={
|
||
"heroSlides": len(HERO_SLIDES),
|
||
"destinations": len(DESTINATIONS),
|
||
"destinationHero": len(DESTINATION_HERO),
|
||
"destinationRegions": len(DESTINATION_REGIONS),
|
||
"themeCards": len(THEMES),
|
||
"products": len(products),
|
||
"routeSections": len(ROUTE_SECTION_DEFAULTS),
|
||
"hotelGroups": len(HOTEL_GROUPS),
|
||
"vehicleOptions": len(VEHICLE_OPTIONS),
|
||
},
|
||
)
|
||
db.add(snapshot)
|
||
db.flush()
|
||
return {
|
||
"heroSlides": len(HERO_SLIDES),
|
||
"destinations": len(DESTINATIONS),
|
||
"destinationHero": len(DESTINATION_HERO),
|
||
"destinationRegions": len(DESTINATION_REGIONS),
|
||
"themes": len(THEMES),
|
||
"ctaBanners": len(CTAS),
|
||
"products": len(products),
|
||
"routeSections": len(ROUTE_SECTION_DEFAULTS),
|
||
"hotelGroups": len(HOTEL_GROUPS),
|
||
"vehicleOptions": len(VEHICLE_OPTIONS),
|
||
"siteVersionId": snapshot.id,
|
||
}
|
||
|
||
|
||
def seed_database(reset: bool) -> dict:
|
||
Base.metadata.create_all(bind=engine)
|
||
with SessionLocal() as db:
|
||
user = db.scalar(select(AdminUser).where(AdminUser.email == "admin@example.com"))
|
||
if user:
|
||
user.passwordHash = hash_password("ChangeMe123!")
|
||
user.isActive = True
|
||
user.name = "后台管理员"
|
||
user.role = "super_admin"
|
||
else:
|
||
db.add(
|
||
AdminUser(
|
||
email="admin@example.com",
|
||
name="后台管理员",
|
||
passwordHash=hash_password("ChangeMe123!"),
|
||
role="super_admin",
|
||
)
|
||
)
|
||
result = reset_guizhou_content(db) if reset else {"reset": False}
|
||
db.commit()
|
||
return result
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="Seed WonderQ Admin database")
|
||
parser.add_argument("--no-reset", action="store_true", help="Only ensure admin user exists")
|
||
args = parser.parse_args()
|
||
result = seed_database(reset=not args.no_reset)
|
||
print(f"Seed complete. Admin login: admin@example.com / ChangeMe123!")
|
||
print(json.dumps(result, ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|