feat: add map image site module
This commit is contained in:
28
alembic/versions/0002_add_map_image.py
Normal file
28
alembic/versions/0002_add_map_image.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user