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:
@@ -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"]},
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
31
app/seed.py
31
app/seed.py
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user