feat: add campaign model and API integration with price and tags

This commit is contained in:
duanshuwen
2026-07-02 13:30:09 +08:00
parent 7b329907f9
commit 521e501992
6 changed files with 430 additions and 9 deletions

View File

@@ -8,9 +8,10 @@ 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, MapImage, MediaAsset, Product, ProductImage, ThemeCard
from app.models import AdminUser, Campaign, 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.routers.shared import site_config
from app.schemas import (
AdminProductQuery,
LeadCreateIn,
@@ -189,6 +190,24 @@ def make_map_image(**overrides):
)
def make_campaign(**overrides):
return Campaign(
id=overrides.get("id", "campaign-test"),
slug=overrides.get("slug", "classic-deal"),
title=overrides.get("title", "经典打卡特惠"),
description=overrides.get("description", "经典首游活动"),
coverImage=overrides.get("coverImage", "https://cdn.example.test/campaign.webp"),
priceAmount=overrides.get("priceAmount", 162500),
priceUnit=overrides.get("priceUnit", "起/人"),
tags=overrides.get("tags", ["臻藏旅位", "赛事庆典"]),
status=overrides.get("status", "draft"),
startsAt=overrides.get("startsAt"),
endsAt=overrides.get("endsAt"),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_admin_user():
return AdminUser(
id="admin-test",
@@ -452,7 +471,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:
@@ -470,7 +489,7 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
def test_admin_site_config_includes_map_array_with_dedicated_contract():
map_image = make_map_image()
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], []])
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], []])
app = authenticated_app(fake_db)
try:
@@ -494,6 +513,40 @@ def test_admin_site_config_includes_map_array_with_dedicated_contract():
assert "targetType" not in body["map"][0]
def test_admin_site_config_includes_campaigns_for_special_offers():
campaign = make_campaign(status="published")
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign]])
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["campaigns"] == [
{
"id": "campaign-test",
"slug": "classic-deal",
"title": "经典打卡特惠",
"description": "经典首游活动",
"coverImage": "https://cdn.example.test/campaign.webp",
"priceAmount": 162500,
"priceUnit": "起/人",
"tags": ["臻藏旅位", "赛事庆典"],
"status": "published",
"startsAt": None,
"endsAt": None,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
assert "sortOrder" not in body["campaigns"][0]
assert "isActive" not in body["campaigns"][0]
assert "targetType" not in body["campaigns"][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)
@@ -564,6 +617,75 @@ def test_site_config_create_map_image_rejects_duplicate_singleton():
assert not fake_db.committed
def test_site_config_create_campaign_defaults_draft_and_audits():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/site-config/campaigns",
json={
"title": " Classic Deal ",
"slug": " classic-deal ",
"description": " Deal summary ",
"coverImage": " https://cdn.example.test/campaign.webp ",
"priceAmount": 188000,
"priceUnit": " 起/人 ",
"tags": [" 季节限定 ", "", "小众秘境"],
"image": "ignored",
"isActive": True,
"sortOrder": 9,
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["title"] == "Classic Deal"
assert body["slug"] == "classic-deal"
assert body["description"] == "Deal summary"
assert body["coverImage"] == "https://cdn.example.test/campaign.webp"
assert body["priceAmount"] == 188000
assert body["priceUnit"] == "起/人"
assert body["tags"] == ["季节限定", "小众秘境"]
assert body["status"] == "draft"
assert "image" not in body
assert "isActive" not in body
assert "sortOrder" not in body
created = fake_db.added[0]
assert isinstance(created, Campaign)
assert created.status == "draft"
assert created.priceAmount == 188000
assert created.priceUnit == "起/人"
assert created.tags == ["季节限定", "小众秘境"]
assert fake_db.added[-1].entity == "campaign"
assert fake_db.committed
def test_site_config_create_campaign_rejects_more_than_three_tags():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/site-config/campaigns",
json={
"title": "Classic Deal",
"slug": "classic-deal",
"tags": ["a", "b", "c", "d"],
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 422
assert response.json()["code"] == "MODULE_CONFIG_VALIDATION_ERROR"
assert response.json()["details"] == {"field": "tags", "max": 3}
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)
@@ -616,6 +738,81 @@ def test_site_config_patch_map_image_updates_allowed_fields_only():
assert fake_db.added[-1].entity == "map_image"
def test_site_config_patch_campaign_updates_contract_fields_only():
campaign = make_campaign(status="draft")
fake_db = FakeDb(get_result=campaign)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/campaigns/campaign-test",
json={
"title": " Summer Deal ",
"slug": " summer-deal ",
"description": None,
"coverImage": " https://cdn.example.test/summer.webp ",
"priceAmount": 57500,
"priceUnit": " 起/人 ",
"tags": ["无可比拟", "尊享礼遇", ""],
"status": "published",
"startsAt": "2026-07-01T08:00:00",
"endsAt": None,
"image": "ignored",
"isActive": False,
"sortOrder": 3,
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["title"] == "Summer Deal"
assert body["slug"] == "summer-deal"
assert body["description"] is None
assert body["coverImage"] == "https://cdn.example.test/summer.webp"
assert body["priceAmount"] == 57500
assert body["priceUnit"] == "起/人"
assert body["tags"] == ["无可比拟", "尊享礼遇"]
assert body["status"] == "published"
assert body["startsAt"] == "2026-07-01T08:00:00"
assert body["endsAt"] is None
assert "image" not in body
assert "isActive" not in body
assert "sortOrder" not in body
assert campaign.status == "published"
assert campaign.priceAmount == 57500
assert campaign.priceUnit == "起/人"
assert campaign.tags == ["无可比拟", "尊享礼遇"]
assert fake_db.added[-1].action == "update"
assert fake_db.added[-1].entity == "campaign"
def test_public_site_config_campaigns_include_price_and_tags():
campaign = make_campaign(status="published", tags=["自然奇景", "小众秘境"])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], []])
result = site_config(fake_db, active_only=True)
assert result["campaigns"] == [
{
"id": "campaign-test",
"slug": "classic-deal",
"title": "经典打卡特惠",
"description": "经典首游活动",
"coverImage": "https://cdn.example.test/campaign.webp",
"priceAmount": 162500,
"priceUnit": "起/人",
"tags": ["自然奇景", "小众秘境"],
"status": "published",
"startsAt": None,
"endsAt": None,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
def test_site_config_patch_updates_destination_contract_fields():
destination = make_destination()
fake_db = FakeDb(get_result=destination)
@@ -742,6 +939,20 @@ def test_site_config_reorder_map_is_not_supported():
assert not fake_db.committed
def test_site_config_reorder_campaigns_is_not_supported():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch("/api/admin/site-config/campaigns/reorder", json={"itemIds": ["campaign-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)
@@ -760,6 +971,24 @@ def test_site_config_delete_map_image_returns_json_and_audits_without_reorder():
assert fake_db.committed
def test_site_config_delete_campaign_returns_json_and_audits_without_reorder():
campaign = make_campaign()
fake_db = FakeDb(get_result=campaign)
app = authenticated_app(fake_db)
try:
response = TestClient(app).delete("/api/admin/site-config/campaigns/campaign-test")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"id": "campaign-test"}
assert fake_db.deleted == [campaign]
assert fake_db.added[-1].action == "delete"
assert fake_db.added[-1].entity == "campaign"
assert fake_db.committed
def test_admin_media_upload_streams_image_to_oss_records_asset_and_audits(monkeypatch):
uploaded = {}