From 521e50199205029f29b9f1b13a231688efaeaac9 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Thu, 2 Jul 2026 13:30:09 +0800 Subject: [PATCH] feat: add campaign model and API integration with price and tags --- .../versions/0003_campaign_display_fields.py | 53 ++++ app/models.py | 3 + app/routers/admin.py | 51 +++- app/schemas.py | 8 + docs/miniapp-public-api.md | 89 ++++++- tests/test_api_contracts.py | 235 +++++++++++++++++- 6 files changed, 430 insertions(+), 9 deletions(-) create mode 100644 alembic/versions/0003_campaign_display_fields.py diff --git a/alembic/versions/0003_campaign_display_fields.py b/alembic/versions/0003_campaign_display_fields.py new file mode 100644 index 0000000..7537166 --- /dev/null +++ b/alembic/versions/0003_campaign_display_fields.py @@ -0,0 +1,53 @@ +"""Add campaign display fields. + +Revision ID: 0003_campaign_display_fields +Revises: 0002_add_map_image +Create Date: 2026-07-02 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect +from sqlalchemy.dialects import postgresql + + +revision = "0003_campaign_display_fields" +down_revision = "0002_add_map_image" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + if "Campaign" not in set(inspect(bind).get_table_names()): + return + + columns = {column["name"] for column in inspect(bind).get_columns("Campaign")} + if "priceAmount" not in columns: + op.add_column("Campaign", sa.Column("priceAmount", sa.Integer(), nullable=True)) + if "priceUnit" not in columns: + op.add_column("Campaign", sa.Column("priceUnit", sa.String(), nullable=True)) + if "tags" not in columns: + op.add_column( + "Campaign", + sa.Column( + "tags", + postgresql.ARRAY(sa.String()), + nullable=False, + server_default=sa.text("'{}'::varchar[]"), + ), + ) + + +def downgrade() -> None: + bind = op.get_bind() + if "Campaign" not in set(inspect(bind).get_table_names()): + return + + columns = {column["name"] for column in inspect(bind).get_columns("Campaign")} + if "tags" in columns: + op.drop_column("Campaign", "tags") + if "priceUnit" in columns: + op.drop_column("Campaign", "priceUnit") + if "priceAmount" in columns: + op.drop_column("Campaign", "priceAmount") diff --git a/app/models.py b/app/models.py index d394b6b..ab42a5f 100644 --- a/app/models.py +++ b/app/models.py @@ -173,6 +173,9 @@ class Campaign(Base): 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) diff --git a/app/routers/admin.py b/app/routers/admin.py index 50f71ed..1bd8977 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -81,6 +81,16 @@ SITE_CONFIG_MODULES = { "none_to_empty": {"image"}, "create_defaults": {"image": ""}, }, + "campaigns": { + "model": Campaign, + "entity": "campaign", + "primary": "title", + "fields": {"title", "slug", "description", "coverImage", "priceAmount", "priceUnit", "tags", "status", "startsAt", "endsAt"}, + "none_to_empty": set(), + "empty_to_none": {"description", "coverImage", "priceUnit", "startsAt", "endsAt"}, + "create_defaults": {"description": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/人", "tags": [], "status": "draft", "startsAt": None, "endsAt": None}, + "ordered": False, + }, "ctaBanners": { "model": CtaBanner, "entity": "cta_banner", @@ -91,6 +101,8 @@ SITE_CONFIG_MODULES = { }, } +CAMPAIGN_STATUSES = {"draft", "published"} + def media_error(status_code: int, message: str, code: str, details: dict | None = None) -> None: raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}}) @@ -280,13 +292,33 @@ def clean_site_value(value): return value.strip() if isinstance(value, str) else value +def normalize_campaign_tags(value) -> list[str]: + if value is None: + return [] + tags = [item.strip() for item in value if isinstance(item, str) and item.strip()] + if len(tags) > 3: + site_config_error(422, "campaign tags cannot exceed 3", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "tags", "max": 3}) + return tags + + def site_field_value(config: dict, field: str, value): + if field == "tags": + return normalize_campaign_tags(value) value = clean_site_value(value) + if value == "" and field in config.get("empty_to_none", set()): + return None if value is None and field in config["none_to_empty"]: return "" return value +def validate_campaign_status(value): + status_value = clean_site_value(value) + if status_value not in CAMPAIGN_STATUSES: + site_config_error(422, "status must be draft or published", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "status"}) + return status_value + + def validate_site_primary(config: dict, body: SiteConfigPatchIn) -> str: primary = config["primary"] value = clean_site_value(getattr(body, primary)) @@ -356,12 +388,21 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db: payload[config["primary"]] = validate_site_primary(config, body) for field, value in config["create_defaults"].items(): payload.setdefault(field, value) - payload["isActive"] = payload.get("isActive", True) + if "isActive" in config["fields"]: + payload["isActive"] = payload.get("isActive", True) 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")) payload["slug"] = slug or site_slugify(payload["name"]) + if module == "campaigns": + slug = clean_site_value(payload.get("slug")) + if not slug: + site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "slug"}) + payload["slug"] = slug + if not payload.get("priceUnit"): + payload["priceUnit"] = "起/人" + payload["status"] = validate_campaign_status(payload.get("status", "draft")) return payload @@ -375,6 +416,10 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) - site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field}) if module == "destinations" and field == "slug" and not value: site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field}) + if module == "campaigns" and field == "slug" and not value: + site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field}) + if module == "campaigns" and field == "status": + value = validate_campaign_status(value) setattr(item, field, value) @@ -532,6 +577,10 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D model_dict(item) for item in db.scalars(select(CtaBanner).order_by(CtaBanner.sortOrder.asc())).all() ], + "campaigns": [ + model_dict(item) + for item in db.scalars(select(Campaign).order_by(Campaign.updatedAt.desc())).all() + ], } diff --git a/app/schemas.py b/app/schemas.py index 2ef03f6..9052396 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -127,6 +127,14 @@ class SiteConfigPatchIn(BaseModel): label: str | None = None alt: str | None = None image: str | None = None + description: str | None = None + coverImage: str | None = None + priceAmount: int | None = Field(default=None, ge=0) + priceUnit: str | None = None + tags: list[str] | None = None + status: str | None = None + startsAt: datetime | None = None + endsAt: datetime | None = None targetType: str | None = None targetValue: str | None = None isHot: bool | None = None diff --git a/docs/miniapp-public-api.md b/docs/miniapp-public-api.md index 7d92ddd..8c50750 100644 --- a/docs/miniapp-public-api.md +++ b/docs/miniapp-public-api.md @@ -1,6 +1,6 @@ # WonderQ-MiniAPP Public API 对接文档 -最后更新:2026-06-30 +最后更新:2026-07-02 本文档定义 `WonderQ-MiniAPP` 前台 H5/小程序对接 `WonderQ-Admin` 后端所需的 Public API 契约。当前 MiniAPP 主动调用站点配置、产品列表和线索提交 3 个接口;后端已存在的健康检查、产品详情和目的地列表接口建议继续保留,供后续前台按需接入。 @@ -60,6 +60,32 @@ | `targetValue` | `string \| null` | 否 | 点击目标值 | | `isActive` | `boolean` | 否 | 是否启用 | +### `Campaign` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 是 | 活动 ID | +| `slug` | `string` | 是 | 活动标识 | +| `title` | `string` | 是 | 活动标题 | +| `description` | `string \| null` | 否 | 活动描述 | +| `coverImage` | `string \| null` | 否 | 活动封面图 | +| `priceAmount` | `number \| null` | 否 | 参考起价,单位按 `priceUnit` 展示 | +| `priceUnit` | `string \| null` | 否 | 价格单位文案,默认 `起/人` | +| `tags` | `string[]` | 否 | 活动卡片标签,最多 3 个 | +| `status` | `string` | 是 | 活动状态;Public API 只返回 `published` | +| `startsAt` | `string \| null` | 否 | 活动开始时间 | +| `endsAt` | `string \| null` | 否 | 活动结束时间 | + +### `RouteSection` + +`RouteSection` 用于描述首页“精选线路”下的分组。`经典人文打卡线路`、`极限山野户外野咖线路`、`人文+户外综合混搭线路` 等属于“精选线路”的子集,不是独立一级模块。 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 是 | 分组 ID,例如 `routes`、`routes-outdoor`、`routes-mix` | +| `title` | `string` | 是 | 分组标题 | +| `productIds` | `string[]` | 是 | 该分组包含的产品 ID,产品详情来自 `/api/public/products` 的 `items` | + ### `PublicProduct` | 字段 | 类型 | 必填 | 说明 | @@ -131,10 +157,21 @@ | --- | --- | --- | | `heroSlides` | `HeroSlide[]` | 首页顶部轮播 | | `destinations` | `Destination[]` | 首页目的地入口 | +| `map` | `Array<{ id: string; image: string; isActive?: boolean }>` | 贵州地图图片;MiniAPP 当前消费 `map[0].image` | | `themes` | `Theme[]` | 主题甄选入口 | | `ctaBanners` | `CtaBanner[]` | 底部 CTA Banner | -| `campaigns` | `unknown[]` | 后端现有扩展字段,可保留 | -| `routeSections` | `Array<{ id: string; title: string; productIds: string[] }>` | 后端现有扩展字段,可保留 | +| `campaigns` | `Campaign[]` | 活动元信息,可用于“特价优惠”入口;当前不包含活动产品结果列表 | +| `routeSections` | `RouteSection[]` | “精选线路”子分组定义 | + +#### 首页模块数据归属 + +| 首页模块 | 当前接口归属 | 说明 | +| --- | --- | --- | +| 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息,不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 | +| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 只返回分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。 | +| 经典人文打卡线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes`。 | +| 极限山野户外野咖线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-outdoor`。 | +| 人文+户外综合混搭线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-mix`。 | #### 响应示例 @@ -161,8 +198,47 @@ "aliases": [{ "id": "alias-1", "alias": "小七孔" }] } ], + "map": [ + { + "id": "map-1", + "image": "/assets/guizhou/guizhou-map.jpg", + "isActive": true + } + ], "themes": [], - "ctaBanners": [] + "ctaBanners": [], + "campaigns": [ + { + "id": "campaign-1", + "slug": "classic-deal", + "title": "经典打卡特惠", + "description": "经典首游活动", + "coverImage": "/assets/guizhou/libo-xiaoqikong.jpg", + "priceAmount": 162500, + "priceUnit": "起/人", + "tags": ["臻藏旅位", "赛事庆典"], + "status": "published", + "startsAt": null, + "endsAt": null + } + ], + "routeSections": [ + { + "id": "routes", + "title": "经典人文打卡线路", + "productIds": ["8a6e7c4f-0000-4000-9000-000000000001"] + }, + { + "id": "routes-outdoor", + "title": "极限山野户外野咖线路", + "productIds": [] + }, + { + "id": "routes-mix", + "title": "人文+户外综合混搭线路", + "productIds": [] + } + ] } ``` @@ -338,6 +414,8 @@ - `site-config` 与 `products` 会在应用启动时并行请求;任一请求失败时,MiniAPP 会回退到本地静态内容。 - `products.items` 为空时,MiniAPP 会使用本地产品兜底数据。 +- “精选线路”由 `site-config.routeSections` 定义分组,由 `/api/public/products.items` 提供产品详情;`经典人文打卡线路`、`极限山野户外野咖线路`、`人文+户外综合混搭线路` 是“精选线路”的子集。 +- “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。 - 产品搜索当前主要在前端执行,依赖 `title`、`tags`、`destination.name`、`summary`。 - 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`。 - 收藏、浏览历史和最近咨询记录由 MiniAPP 本地存储处理,不需要后端接口。 @@ -346,7 +424,8 @@ ## 后端验证建议 - 为 `GET /health` 增加或保留健康检查测试。 -- 为 `GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides`、`destinations`、`themes`、`ctaBanners` 数组字段。 +- 为 `GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners`、`campaigns`、`routeSections` 数组字段。 +- 为 `GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组,并包含 `routes`、`routes-outdoor`、`routes-mix` 三个当前约定分组。 - 为 `GET /api/public/products` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword`、`destinationId`、`status`、`take` 参数。 - 为 `GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。 - 为 `GET /api/public/destinations` 验证只返回启用目的地及别名字段。 diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index 043bd98..dac804e 100644 --- a/tests/test_api_contracts.py +++ b/tests/test_api_contracts.py @@ -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 = {}