feat(route-sections): add dynamic route sections
Replace hardcoded homepage featured route groups with a fully managed dynamic system: - add database models RouteSection and RouteSectionProduct for storing route groups and their associated products - create Alembic migration 0004_route_sections for the new tables - extend SiteConfigPatchIn schema with subtitle and productIds fields - refactor shared site_config utility to load dynamic route sections instead of fixed groups - implement admin CRUD API with validation for duplicate/conflicting product associations - update public and admin API documentation to reflect the new system - add default route section seed data and comprehensive test coverage
This commit is contained in:
@@ -25,12 +25,15 @@ from ..models import (
|
||||
MediaAsset,
|
||||
Product,
|
||||
ProductImage,
|
||||
RouteSection,
|
||||
RouteSectionProduct,
|
||||
SiteVersion,
|
||||
ThemeCard,
|
||||
utc_now,
|
||||
)
|
||||
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
|
||||
from ..seed import create_media, reset_guizhou_content
|
||||
from ..route_sections import replace_route_section_products, route_section_dict, route_section_query
|
||||
from ..serializers import admin_product_dict, destination_dict, encode_value, lead_dict, model_dict
|
||||
from .shared import site_config
|
||||
|
||||
@@ -91,6 +94,15 @@ SITE_CONFIG_MODULES = {
|
||||
"create_defaults": {"description": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/人", "tags": [], "status": "draft", "startsAt": None, "endsAt": None},
|
||||
"ordered": False,
|
||||
},
|
||||
"routeSections": {
|
||||
"model": RouteSection,
|
||||
"entity": "route_section",
|
||||
"primary": "title",
|
||||
"fields": {"title", "subtitle", "isActive", "sortOrder", "productIds"},
|
||||
"none_to_empty": set(),
|
||||
"empty_to_none": {"subtitle"},
|
||||
"create_defaults": {},
|
||||
},
|
||||
"ctaBanners": {
|
||||
"model": CtaBanner,
|
||||
"entity": "cta_banner",
|
||||
@@ -360,12 +372,16 @@ def site_item_dict(module: str, item) -> dict:
|
||||
return hero_slide_admin_dict(item)
|
||||
if module == "map":
|
||||
return map_image_admin_dict(item)
|
||||
if module == "routeSections":
|
||||
return route_section_dict(item)
|
||||
return destination_dict(item) if module == "destinations" else model_dict(item)
|
||||
|
||||
|
||||
def module_items(db: Session, config: dict) -> list:
|
||||
model = config["model"]
|
||||
stmt = select(model)
|
||||
if model is RouteSection:
|
||||
stmt = stmt.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product))
|
||||
if config.get("ordered", True):
|
||||
stmt = stmt.order_by(model.sortOrder.asc())
|
||||
else:
|
||||
@@ -383,7 +399,7 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db:
|
||||
payload = {
|
||||
field: site_field_value(config, field, getattr(body, field))
|
||||
for field in config["fields"]
|
||||
if field in fields
|
||||
if field in fields and field != "productIds"
|
||||
}
|
||||
payload[config["primary"]] = validate_site_primary(config, body)
|
||||
for field, value in config["create_defaults"].items():
|
||||
@@ -411,6 +427,8 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) -
|
||||
for field in config["fields"]:
|
||||
if field not in fields:
|
||||
continue
|
||||
if field == "productIds":
|
||||
continue
|
||||
value = site_field_value(config, field, getattr(body, field))
|
||||
if field == config["primary"] and not value:
|
||||
site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
|
||||
@@ -423,6 +441,35 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) -
|
||||
setattr(item, field, value)
|
||||
|
||||
|
||||
def create_route_section_config(body: SiteConfigPatchIn, request: Request, db: Session) -> dict:
|
||||
config = site_module("routeSections")
|
||||
items = module_items(db, config)
|
||||
fields = body.model_fields_set
|
||||
now = utc_now()
|
||||
section = RouteSection(
|
||||
title=validate_site_primary(config, body),
|
||||
subtitle=site_field_value(config, "subtitle", body.subtitle) if "subtitle" in fields else None,
|
||||
sortOrder=(
|
||||
site_field_value(config, "sortOrder", body.sortOrder)
|
||||
if "sortOrder" in fields and body.sortOrder is not None
|
||||
else max([item.sortOrder for item in items], default=-1) + 1
|
||||
),
|
||||
isActive=site_field_value(config, "isActive", body.isActive) if "isActive" in fields else True,
|
||||
createdAt=now,
|
||||
updatedAt=now,
|
||||
)
|
||||
section.products = []
|
||||
db.add(section)
|
||||
db.flush()
|
||||
if "productIds" in fields:
|
||||
replace_route_section_products(db, section, body.productIds or [])
|
||||
db.flush()
|
||||
after = route_section_dict(section)
|
||||
audit(db, get_actor_id(request), "create", config["entity"], section.id, after)
|
||||
db.commit()
|
||||
return after
|
||||
|
||||
|
||||
def load_product(db: Session, product_id: str) -> Product:
|
||||
return db.scalars(
|
||||
select(Product)
|
||||
@@ -581,6 +628,7 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
|
||||
model_dict(item)
|
||||
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()],
|
||||
}
|
||||
|
||||
|
||||
@@ -593,6 +641,10 @@ def create_site_config(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
config = site_module(module)
|
||||
if module == "routeSections":
|
||||
return create_route_section_config(body, request, db)
|
||||
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})
|
||||
item = config["model"](**site_create_payload(module, config, body, db))
|
||||
@@ -650,6 +702,8 @@ def update_site_config(
|
||||
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
|
||||
before = site_item_dict(module, item)
|
||||
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 [])
|
||||
db.flush()
|
||||
after = site_item_dict(module, item)
|
||||
audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before)
|
||||
@@ -666,6 +720,8 @@ def delete_site_config(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
config = site_module(module)
|
||||
if config.get("fixed"):
|
||||
site_config_error(405, "固定模块不支持删除", "MODULE_CONFIG_DELETE_UNSUPPORTED", {"module": module})
|
||||
item = db.get(config["model"], item_id)
|
||||
if not item:
|
||||
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
|
||||
|
||||
Reference in New Issue
Block a user