feat: add map image site module

This commit is contained in:
duanshuwen
2026-07-02 08:57:49 +08:00
parent a47b4b5dd0
commit 7b329907f9
5 changed files with 221 additions and 8 deletions

View File

@@ -0,0 +1,28 @@
"""Add map image module table.
Revision ID: 0002_add_map_image
Revises: 0001_initial_schema
Create Date: 2026-07-01
"""
from alembic import op
from sqlalchemy import inspect
from app.models import MapImage
revision = "0002_add_map_image"
down_revision = "0001_initial_schema"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if "MapImage" not in set(inspect(bind).get_table_names()):
MapImage.__table__.create(bind)
def downgrade() -> None:
bind = op.get_bind()
if "MapImage" in set(inspect(bind).get_table_names()):
MapImage.__table__.drop(bind)

View File

@@ -88,6 +88,16 @@ class DestinationAlias(Base):
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"

View File

@@ -21,6 +21,7 @@ from ..models import (
Destination,
HeroSlide,
Lead,
MapImage,
MediaAsset,
Product,
ProductImage,
@@ -62,6 +63,16 @@ SITE_CONFIG_MODULES = {
"none_to_empty": set(),
"create_defaults": {"isHot": False},
},
"map": {
"model": MapImage,
"entity": "map_image",
"primary": "image",
"fields": {"image", "isActive"},
"none_to_empty": set(),
"create_defaults": {},
"ordered": False,
"singleton": True,
},
"themes": {
"model": ThemeCard,
"entity": "theme_card",
@@ -302,14 +313,32 @@ def hero_slide_admin_dict(item: HeroSlide) -> dict:
}
def map_image_admin_dict(item: MapImage) -> dict:
return {
"id": item.id,
"image": item.image or None,
"isActive": item.isActive,
"createdAt": encode_value(item.createdAt),
"updatedAt": encode_value(item.updatedAt),
}
def site_item_dict(module: str, item) -> dict:
if module == "heroSlides":
return hero_slide_admin_dict(item)
if module == "map":
return map_image_admin_dict(item)
return destination_dict(item) if module == "destinations" else model_dict(item)
def module_items(db: Session, model) -> list:
return db.scalars(select(model).order_by(model.sortOrder.asc())).all()
def module_items(db: Session, config: dict) -> list:
model = config["model"]
stmt = select(model)
if config.get("ordered", True):
stmt = stmt.order_by(model.sortOrder.asc())
else:
stmt = stmt.order_by(model.createdAt.asc())
return db.scalars(stmt).all()
def normalize_site_sort_orders(items: list) -> None:
@@ -328,7 +357,7 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db:
for field, value in config["create_defaults"].items():
payload.setdefault(field, value)
payload["isActive"] = payload.get("isActive", True)
if payload.get("sortOrder") is None:
if config.get("ordered", True) and payload.get("sortOrder") is None:
payload["sortOrder"] = next_site_sort_order(db, config["model"])
if module == "destinations":
slug = clean_site_value(payload.get("slug"))
@@ -491,6 +520,10 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
for item in db.scalars(select(HeroSlide).order_by(HeroSlide.sortOrder.asc())).all()
],
"destinations": [destination_dict(item) for item in destinations],
"map": [
map_image_admin_dict(item)
for item in db.scalars(select(MapImage).order_by(MapImage.createdAt.asc())).all()
],
"themes": [
model_dict(item)
for item in db.scalars(select(ThemeCard).order_by(ThemeCard.sortOrder.asc())).all()
@@ -511,6 +544,8 @@ def create_site_config(
db: Session = Depends(get_db),
):
config = site_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))
db.add(item)
db.flush()
@@ -529,7 +564,9 @@ def reorder_site_config(
db: Session = Depends(get_db),
):
config = site_module(module)
items = module_items(db, config["model"])
if not config.get("ordered", True):
site_config_error(400, "模块不支持排序", "MODULE_CONFIG_REORDER_UNSUPPORTED", {"module": module})
items = module_items(db, config)
current_ids = [item.id for item in items]
requested_ids = body.itemIds
if len(set(requested_ids)) != len(requested_ids) or set(requested_ids) != set(current_ids):
@@ -590,7 +627,8 @@ def delete_site_config(
before = site_item_dict(module, item)
db.delete(item)
db.flush()
normalize_site_sort_orders(module_items(db, config["model"]))
if config.get("ordered", True):
normalize_site_sort_orders(module_items(db, config))
result = {"id": item_id}
audit(db, get_actor_id(request), "delete", config["entity"], item_id, result, before)
db.commit()

View File

@@ -1,6 +1,6 @@
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from ..models import Campaign, CtaBanner, Destination, HeroSlide, Product, ThemeCard
from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, Product, ThemeCard
from ..serializers import destination_dict, model_dict
@@ -14,21 +14,25 @@ ROUTE_SECTION_LABELS = [
def site_config(db: Session, active_only: bool, include_public_extras: bool = True) -> dict:
hero_stmt = select(HeroSlide).order_by(HeroSlide.sortOrder.asc())
destination_stmt = select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc())
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())
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))
hero_slides = db.scalars(hero_stmt).all()
destinations = db.scalars(destination_stmt).all()
map_images = db.scalars(map_stmt).all()
themes = db.scalars(theme_stmt).all()
cta_banners = db.scalars(cta_stmt).all()
result = {
"heroSlides": [model_dict(item) for item in hero_slides],
"destinations": [destination_dict(item) for item in destinations],
"map": [model_dict(item) for item in map_images],
"themes": [model_dict(item) for item in themes],
"ctaBanners": [model_dict(item) for item in cta_banners],
}

View File

@@ -8,7 +8,7 @@ from app import serializers
from app.auth import hash_password, require_admin
from app.database import get_db
from app.main import create_app
from app.models import AdminUser, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MediaAsset, Product, ProductImage, ThemeCard
from app.models import AdminUser, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, ThemeCard
from app.routers import admin as admin_router
from app.routers.admin import normalize_detail_sections, normalize_images
from app.schemas import (
@@ -179,6 +179,16 @@ def make_cta_banner(**overrides):
)
def make_map_image(**overrides):
return MapImage(
id=overrides.get("id", "map-test"),
image=overrides.get("image", "https://cdn.example.test/map.webp"),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_admin_user():
return AdminUser(
id="admin-test",
@@ -442,7 +452,7 @@ def test_site_config_create_requires_module_primary_field():
def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test")
fake_db = FakeDb(scalar_results=[[], [hero_slide], [], []])
fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], []])
app = authenticated_app(fake_db)
try:
@@ -458,6 +468,32 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
assert "targetValue" not in hero
def test_admin_site_config_includes_map_array_with_dedicated_contract():
map_image = make_map_image()
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], []])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["map"] == [
{
"id": "map-test",
"image": "https://cdn.example.test/map.webp",
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
assert "sortOrder" not in body["map"][0]
assert "title" not in body["map"][0]
assert "targetType" not in body["map"][0]
def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicated_contract():
fake_db = FakeDb(scalar_values=[0])
app = authenticated_app(fake_db)
@@ -487,6 +523,47 @@ def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicat
assert created.targetValue is None
def test_site_config_create_map_image_defaults_active_and_audits():
fake_db = FakeDb(scalar_results=[[]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/site-config/map",
json={"image": " https://cdn.example.test/guizhou-map.webp ", "title": "ignored", "sortOrder": 99},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["image"] == "https://cdn.example.test/guizhou-map.webp"
assert body["isActive"] is True
assert "title" not in body
assert "sortOrder" not in body
created = fake_db.added[0]
assert isinstance(created, MapImage)
assert created.image == "https://cdn.example.test/guizhou-map.webp"
assert created.isActive is True
assert fake_db.added[-1].entity == "map_image"
assert fake_db.committed
def test_site_config_create_map_image_rejects_duplicate_singleton():
fake_db = FakeDb(scalar_results=[[make_map_image()]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post("/api/admin/site-config/map", json={"image": "https://cdn.example.test/new.webp"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 409
assert response.json()["code"] == "MAP_IMAGE_ALREADY_EXISTS"
assert not fake_db.added
assert not fake_db.committed
def test_site_config_patch_hero_slide_ignores_target_fields_and_returns_dedicated_contract():
hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test")
fake_db = FakeDb(get_result=hero_slide)
@@ -515,6 +592,30 @@ def test_site_config_patch_hero_slide_ignores_target_fields_and_returns_dedicate
assert hero_slide.targetValue == "campaign-test"
def test_site_config_patch_map_image_updates_allowed_fields_only():
map_image = make_map_image(isActive=True)
fake_db = FakeDb(get_result=map_image)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/map/map-test",
json={"image": " https://cdn.example.test/new-map.webp ", "isActive": False, "targetType": "ignored", "sortOrder": 1},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["image"] == "https://cdn.example.test/new-map.webp"
assert body["isActive"] is False
assert "targetType" not in body
assert "sortOrder" not in body
assert not hasattr(map_image, "targetType")
assert fake_db.added[-1].action == "update"
assert fake_db.added[-1].entity == "map_image"
def test_site_config_patch_updates_destination_contract_fields():
destination = make_destination()
fake_db = FakeDb(get_result=destination)
@@ -627,6 +728,38 @@ def test_site_config_reorder_rejects_duplicate_missing_and_unknown_ids(item_ids)
assert not fake_db.committed
def test_site_config_reorder_map_is_not_supported():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch("/api/admin/site-config/map/reorder", json={"itemIds": ["map-test"]})
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json()["code"] == "MODULE_CONFIG_REORDER_UNSUPPORTED"
assert not fake_db.committed
def test_site_config_delete_map_image_returns_json_and_audits_without_reorder():
map_image = make_map_image()
fake_db = FakeDb(get_result=map_image)
app = authenticated_app(fake_db)
try:
response = TestClient(app).delete("/api/admin/site-config/map/map-test")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"id": "map-test"}
assert fake_db.deleted == [map_image]
assert fake_db.added[-1].action == "delete"
assert fake_db.added[-1].entity == "map_image"
assert fake_db.committed
def test_admin_media_upload_streams_image_to_oss_records_asset_and_audits(monkeypatch):
uploaded = {}