- Add new database columns (coverImage, priceAmount, priceUnit, tags, status) to HotelGroup model - Create hotel_group_dict serializer to handle image/coverImage synchronization and tag formatting - Update site config endpoints to use the new serializer and filter published hotel groups - Add Alembic migration for the new database schema changes - Update test fixtures and API contract tests for the new fields - Revise public and admin API documentation to document the new hotel group fields and usage rules
346 lines
18 KiB
Python
346 lines
18 KiB
Python
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
from .database import Base
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def new_id() -> str:
|
|
return str(uuid4())
|
|
|
|
|
|
class AdminUser(Base):
|
|
__tablename__ = "AdminUser"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
|
passwordHash: Mapped[str] = mapped_column(String, nullable=False)
|
|
role: Mapped[str] = mapped_column(String, default="admin", 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)
|
|
|
|
auditLogs: Mapped[list["AuditLog"]] = relationship(back_populates="actor")
|
|
assignedLeads: Mapped[list["Lead"]] = relationship(back_populates="assignedUser", foreign_keys="Lead.assignedUserId")
|
|
|
|
|
|
class MediaAsset(Base):
|
|
__tablename__ = "MediaAsset"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
url: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
name: Mapped[str | None] = mapped_column(String)
|
|
mimeType: Mapped[str | None] = mapped_column(String)
|
|
sizeBytes: Mapped[int | None] = mapped_column(Integer)
|
|
group: Mapped[str | None] = mapped_column(String)
|
|
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 HeroSlide(Base):
|
|
__tablename__ = "HeroSlide"
|
|
|
|
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)
|
|
actionLabel: Mapped[str | None] = mapped_column(String)
|
|
image: Mapped[str] = mapped_column(String, nullable=False)
|
|
targetType: Mapped[str | None] = mapped_column(String)
|
|
targetValue: 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 Destination(Base):
|
|
__tablename__ = "Destination"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
name: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
slug: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
region: Mapped[str | None] = mapped_column(String)
|
|
image: Mapped[str | None] = mapped_column(String)
|
|
isHot: Mapped[bool] = mapped_column(Boolean, default=False, 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)
|
|
|
|
aliases: Mapped[list["DestinationAlias"]] = relationship(back_populates="destination", cascade="all, delete-orphan")
|
|
products: Mapped[list["Product"]] = relationship(back_populates="destination")
|
|
|
|
|
|
class DestinationAlias(Base):
|
|
__tablename__ = "DestinationAlias"
|
|
__table_args__ = (UniqueConstraint("alias", "destinationId"),)
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
alias: Mapped[str] = mapped_column(String, nullable=False)
|
|
destinationId: Mapped[str] = mapped_column(String, ForeignKey("Destination.id", ondelete="CASCADE"), nullable=False)
|
|
|
|
destination: Mapped[Destination] = relationship(back_populates="aliases")
|
|
|
|
|
|
class MapImage(Base):
|
|
__tablename__ = "MapImage"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
image: Mapped[str] = mapped_column(String, 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 ThemeCard(Base):
|
|
__tablename__ = "ThemeCard"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
label: Mapped[str] = mapped_column(String, nullable=False)
|
|
image: Mapped[str] = mapped_column(String, nullable=False)
|
|
targetType: Mapped[str | None] = mapped_column(String)
|
|
targetValue: 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 CtaBanner(Base):
|
|
__tablename__ = "CtaBanner"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
alt: Mapped[str] = mapped_column(String, nullable=False)
|
|
image: Mapped[str] = mapped_column(String, nullable=False)
|
|
targetType: Mapped[str] = mapped_column(String, nullable=False)
|
|
targetValue: 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 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)
|
|
coverImage: Mapped[str | None] = mapped_column(String)
|
|
priceAmount: Mapped[int | None] = mapped_column(Integer)
|
|
priceUnit: Mapped[str | None] = mapped_column(String, default="起/晚")
|
|
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, nullable=False)
|
|
status: Mapped[str] = mapped_column(String, default="published", 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 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"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
sourceId: Mapped[int | None] = mapped_column(Integer, unique=True)
|
|
title: Mapped[str] = mapped_column(String, nullable=False)
|
|
subtitle: Mapped[str | None] = mapped_column(String)
|
|
destinationId: Mapped[str | None] = mapped_column(String, ForeignKey("Destination.id", ondelete="SET NULL"))
|
|
priceAmount: Mapped[int | None] = mapped_column(Integer)
|
|
priceUnit: Mapped[str] = mapped_column(String, default="起/人", nullable=False)
|
|
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, nullable=False)
|
|
coverImage: Mapped[str | None] = mapped_column(String)
|
|
summary: Mapped[str | None] = mapped_column(Text)
|
|
detailSections: Mapped[dict | list | None] = mapped_column(JSONB)
|
|
status: Mapped[str] = mapped_column(String, default="published", nullable=False)
|
|
sortWeight: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
publishedAt: Mapped[datetime | None] = mapped_column(DateTime)
|
|
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)
|
|
|
|
destination: Mapped[Destination | None] = relationship(back_populates="products")
|
|
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")
|
|
leads: Mapped[list["Lead"]] = relationship(back_populates="sourceProduct")
|
|
orders: Mapped[list["Order"]] = relationship(back_populates="product")
|
|
|
|
|
|
class ProductImage(Base):
|
|
__tablename__ = "ProductImage"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
productId: Mapped[str] = mapped_column(String, ForeignKey("Product.id", ondelete="CASCADE"), nullable=False)
|
|
url: Mapped[str] = mapped_column(String, nullable=False)
|
|
alt: Mapped[str | None] = mapped_column(String)
|
|
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
|
|
product: Mapped[Product] = relationship(back_populates="images")
|
|
|
|
|
|
class Campaign(Base):
|
|
__tablename__ = "Campaign"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
slug: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
title: Mapped[str] = mapped_column(String, nullable=False)
|
|
description: Mapped[str | None] = mapped_column(Text)
|
|
coverImage: Mapped[str | None] = mapped_column(String)
|
|
priceAmount: Mapped[int | None] = mapped_column(Integer)
|
|
priceUnit: Mapped[str | None] = mapped_column(String, default="起/人")
|
|
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, nullable=False)
|
|
status: Mapped[str] = mapped_column(String, default="published", nullable=False)
|
|
startsAt: Mapped[datetime | None] = mapped_column(DateTime)
|
|
endsAt: Mapped[datetime | None] = mapped_column(DateTime)
|
|
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["CampaignProduct"]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
|
|
|
|
|
|
class CampaignProduct(Base):
|
|
__tablename__ = "CampaignProduct"
|
|
|
|
campaignId: Mapped[str] = mapped_column(String, ForeignKey("Campaign.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)
|
|
|
|
campaign: Mapped[Campaign] = relationship(back_populates="products")
|
|
product: Mapped[Product] = relationship(back_populates="campaignLinks")
|
|
|
|
|
|
class RouteSection(Base):
|
|
__tablename__ = "RouteSection"
|
|
|
|
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["RouteSectionProduct"]] = relationship(back_populates="section", cascade="all, delete-orphan")
|
|
|
|
|
|
class RouteSectionProduct(Base):
|
|
__tablename__ = "RouteSectionProduct"
|
|
|
|
sectionId: Mapped[str] = mapped_column(String, ForeignKey("RouteSection.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)
|
|
|
|
section: Mapped[RouteSection] = relationship(back_populates="products")
|
|
product: Mapped[Product] = relationship(back_populates="routeSectionLinks")
|
|
|
|
|
|
class Lead(Base):
|
|
__tablename__ = "Lead"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
destination: Mapped[str | None] = mapped_column(String)
|
|
phone: Mapped[str] = mapped_column(String, nullable=False)
|
|
travelDate: Mapped[datetime | None] = mapped_column(DateTime)
|
|
peopleCount: Mapped[int | None] = mapped_column(Integer)
|
|
budgetMin: Mapped[int | None] = mapped_column(Integer)
|
|
budgetMax: Mapped[int | None] = mapped_column(Integer)
|
|
note: Mapped[str | None] = mapped_column(Text)
|
|
sourcePage: Mapped[str | None] = mapped_column(String)
|
|
sourceProductId: Mapped[str | None] = mapped_column(String, ForeignKey("Product.id", ondelete="SET NULL"))
|
|
status: Mapped[str] = mapped_column(String, default="new", nullable=False)
|
|
assignedUserId: Mapped[str | None] = mapped_column(String, ForeignKey("AdminUser.id", ondelete="SET NULL"))
|
|
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)
|
|
|
|
sourceProduct: Mapped[Product | None] = relationship(back_populates="leads")
|
|
assignedUser: Mapped[AdminUser | None] = relationship(back_populates="assignedLeads", foreign_keys=[assignedUserId])
|
|
followups: Mapped[list["LeadFollowup"]] = relationship(back_populates="lead", cascade="all, delete-orphan")
|
|
|
|
|
|
class LeadFollowup(Base):
|
|
__tablename__ = "LeadFollowup"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
leadId: Mapped[str] = mapped_column(String, ForeignKey("Lead.id", ondelete="CASCADE"), nullable=False)
|
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
nextAt: Mapped[datetime | None] = mapped_column(DateTime)
|
|
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
|
|
|
|
lead: Mapped[Lead] = relationship(back_populates="followups")
|
|
|
|
|
|
class Customer(Base):
|
|
__tablename__ = "Customer"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
phone: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
|
name: Mapped[str | None] = mapped_column(String)
|
|
wechat: Mapped[str | None] = mapped_column(String)
|
|
note: Mapped[str | None] = mapped_column(Text)
|
|
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)
|
|
|
|
orders: Mapped[list["Order"]] = relationship(back_populates="customer")
|
|
|
|
|
|
class Order(Base):
|
|
__tablename__ = "Order"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
customerId: Mapped[str | None] = mapped_column(String, ForeignKey("Customer.id", ondelete="SET NULL"))
|
|
productId: Mapped[str | None] = mapped_column(String, ForeignKey("Product.id", ondelete="SET NULL"))
|
|
status: Mapped[str] = mapped_column(String, default="draft", nullable=False)
|
|
travelDate: Mapped[datetime | None] = mapped_column(DateTime)
|
|
amount: Mapped[int | None] = mapped_column(Integer)
|
|
note: Mapped[str | None] = mapped_column(Text)
|
|
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)
|
|
|
|
customer: Mapped[Customer | None] = relationship(back_populates="orders")
|
|
product: Mapped[Product | None] = relationship(back_populates="orders")
|
|
|
|
|
|
class SiteVersion(Base):
|
|
__tablename__ = "SiteVersion"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
title: Mapped[str] = mapped_column(String, nullable=False)
|
|
status: Mapped[str] = mapped_column(String, default="draft", nullable=False)
|
|
snapshot: Mapped[dict | list] = mapped_column(JSONB, nullable=False)
|
|
publishedAt: Mapped[datetime | None] = mapped_column(DateTime)
|
|
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
|
|
|
|
|
|
class AuditLog(Base):
|
|
__tablename__ = "AuditLog"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
|
actorId: Mapped[str | None] = mapped_column(String, ForeignKey("AdminUser.id", ondelete="SET NULL"))
|
|
action: Mapped[str] = mapped_column(String, nullable=False)
|
|
entity: Mapped[str] = mapped_column(String, nullable=False)
|
|
entityId: Mapped[str | None] = mapped_column(String)
|
|
before: Mapped[dict | list | None] = mapped_column(JSONB)
|
|
after: Mapped[dict | list | None] = mapped_column(JSONB)
|
|
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)
|
|
|
|
actor: Mapped[AdminUser | None] = relationship(back_populates="auditLogs")
|