feat: add hotel and vehicle option site modules

Add full support for hotel group and vehicle option site management features:
- Define SQLAlchemy models and alembic migration for the new database tables
- Add default sample content entries in content.py
- Extend admin and public API routes to support the new modules
- Update seed script to populate default hotel and vehicle data
- Update all relevant documentation and test cases
This commit is contained in:
duanshuwen
2026-07-03 20:26:44 +08:00
parent 72d388a047
commit 201e835eab
13 changed files with 426 additions and 34 deletions

View File

@@ -76,6 +76,32 @@ CTAS = [
("提交贵州出行需求", "/assets/guizhou/shuichunhe-rafting.jpg", "demand", None),
]
HOTEL_GROUPS = [
{
"title": "经典酒店",
"image": "/assets/guizhou/bailian-hot-spring.jpg",
"description": "城市接驳、景区度假和温泉休整,适合首游贵州的小包团动线。",
},
{
"title": "野奢酒店",
"image": "/assets/guizhou/jianhe-hot-spring.jpg",
"description": "把山地、村寨、星空和温泉留给行程里的慢时刻。",
},
]
VEHICLE_OPTIONS = [
{
"title": "5座舒适用车",
"image": "/assets/guizhou/jiaxiu-tower.jpg",
"description": "适合2-4人家庭或好友小团城市接送、景区穿梭更灵活。",
},
{
"title": "9座精品商务车",
"image": "/assets/guizhou/wanfenglin.jpg",
"description": "适合5-8人同行、亲子或长辈出行留足行李和休息空间。",
},
]
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

@@ -126,6 +126,32 @@ class CtaBanner(Base):
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class HotelGroup(Base):
__tablename__ = "HotelGroup"
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)
image: Mapped[str | None] = mapped_column(String)
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 VehicleOption(Base):
__tablename__ = "VehicleOption"
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)
image: Mapped[str | None] = mapped_column(String)
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 Product(Base):
__tablename__ = "Product"

View File

@@ -20,6 +20,7 @@ from ..models import (
CtaBanner,
Destination,
HeroSlide,
HotelGroup,
Lead,
MapImage,
MediaAsset,
@@ -29,6 +30,7 @@ from ..models import (
RouteSectionProduct,
SiteVersion,
ThemeCard,
VehicleOption,
utc_now,
)
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
@@ -103,6 +105,24 @@ SITE_CONFIG_MODULES = {
"empty_to_none": {"subtitle"},
"create_defaults": {},
},
"hotelGroups": {
"model": HotelGroup,
"entity": "hotel_group",
"primary": "title",
"fields": {"title", "description", "image", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"description", "image"},
"create_defaults": {"description": None, "image": None},
},
"vehicleOptions": {
"model": VehicleOption,
"entity": "vehicle_option",
"primary": "title",
"fields": {"title", "description", "image", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"description", "image"},
"create_defaults": {"description": None, "image": None},
},
"ctaBanners": {
"model": CtaBanner,
"entity": "cta_banner",
@@ -629,6 +649,14 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
for item in db.scalars(select(Campaign).order_by(Campaign.updatedAt.desc())).all()
],
"routeSections": [route_section_dict(item) for item in db.scalars(route_section_query()).all()],
"hotelGroups": [
model_dict(item)
for item in db.scalars(select(HotelGroup).order_by(HotelGroup.sortOrder.asc())).all()
],
"vehicleOptions": [
model_dict(item)
for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all()
],
}

View File

@@ -1,6 +1,6 @@
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, ThemeCard
from ..models import Campaign, CtaBanner, Destination, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption
from ..route_sections import route_section_dict, route_section_query
from ..serializers import destination_dict, model_dict
@@ -11,12 +11,16 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
map_stmt = select(MapImage).order_by(MapImage.createdAt.asc())
theme_stmt = select(ThemeCard).order_by(ThemeCard.sortOrder.asc())
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())
if active_only:
hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True))
destination_stmt = destination_stmt.where(Destination.isActive.is_(True))
map_stmt = map_stmt.where(MapImage.isActive.is_(True))
theme_stmt = theme_stmt.where(ThemeCard.isActive.is_(True))
cta_stmt = cta_stmt.where(CtaBanner.isActive.is_(True))
hotel_stmt = hotel_stmt.where(HotelGroup.isActive.is_(True))
vehicle_stmt = vehicle_stmt.where(VehicleOption.isActive.is_(True))
hero_slides = db.scalars(hero_stmt).all()
destinations = db.scalars(destination_stmt).all()
@@ -37,4 +41,6 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
route_sections = [section for section in route_sections if section.isActive]
result["campaigns"] = [model_dict(item) for item in campaigns]
result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections]
return result
result["hotelGroups"] = [model_dict(item) for item in db.scalars(hotel_stmt).all()]
result["vehicleOptions"] = [model_dict(item) for item in db.scalars(vehicle_stmt).all()]
return result

View File

@@ -8,7 +8,7 @@ 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, DESTINATIONS, HERO_SLIDES, THEMES
from .content import ALIASES, CAMPAIGNS, CTAS, DESTINATIONS, HERO_SLIDES, HOTEL_GROUPS, THEMES, VEHICLE_OPTIONS
from .database import Base, SessionLocal, engine
from .models import (
AdminUser,
@@ -18,6 +18,7 @@ from .models import (
Destination,
DestinationAlias,
HeroSlide,
HotelGroup,
MediaAsset,
Product,
ProductImage,
@@ -25,6 +26,7 @@ from .models import (
RouteSectionProduct,
SiteVersion,
ThemeCard,
VehicleOption,
utc_now,
)
from .route_sections import ROUTE_SECTION_DEFAULTS
@@ -100,7 +102,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, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]:
for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, VehicleOption, HotelGroup, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]:
db.execute(delete(model))
db.flush()
@@ -136,6 +138,27 @@ def reset_guizhou_content(db: Session) -> dict:
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"),
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
@@ -203,6 +226,8 @@ def reset_guizhou_content(db: Session) -> dict:
"themeCards": len(THEMES),
"products": len(products),
"routeSections": len(ROUTE_SECTION_DEFAULTS),
"hotelGroups": len(HOTEL_GROUPS),
"vehicleOptions": len(VEHICLE_OPTIONS),
},
)
db.add(snapshot)
@@ -214,6 +239,8 @@ def reset_guizhou_content(db: Session) -> dict:
"ctaBanners": len(CTAS),
"products": len(products),
"routeSections": len(ROUTE_SECTION_DEFAULTS),
"hotelGroups": len(HOTEL_GROUPS),
"vehicleOptions": len(VEHICLE_OPTIONS),
"siteVersionId": snapshot.id,
}