refactor(admin,ui,test): 完善数据库迁移脚本,添加单元测试并调整前端配置
优化所有alembic迁移脚本的离线模式支持,添加已存在对象检查逻辑,避免重复执行出错。 为各个迁移脚本添加对应的单元测试,覆盖核心场景保障逻辑正确性。 修改前端配置文件,将默认API地址切换为线上服务器8.138.234.141:4000。 新增本地数据备份文件wonderq-local-data.dump和项目发布包wonderq-admin-release-20260823-01.tar。
This commit is contained in:
@@ -9,6 +9,7 @@ from datetime import datetime
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "0017_home_content"
|
||||
@@ -48,6 +49,8 @@ SEED_TEAM_BUILDINGS = (
|
||||
"tag": "户外挑战",
|
||||
"title": "山野挑战,共创极境",
|
||||
"description": "洞穴、瀑降与协作,适合 10-30 人。",
|
||||
"detailSubtitle": "洞穴、瀑降与协作,适合 10-30 人。",
|
||||
"detailParagraphs": ["洞穴、瀑降与协作,适合 10-30 人。"],
|
||||
"image": "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient",
|
||||
"demandKeyword": "户外团建",
|
||||
"isActive": True,
|
||||
@@ -58,6 +61,8 @@ SEED_TEAM_BUILDINGS = (
|
||||
"tag": "团队协作",
|
||||
"title": "峡谷溯溪,默契同行",
|
||||
"description": "溯溪与分组协作,兼顾参与感与安全。",
|
||||
"detailSubtitle": "溯溪与分组协作,兼顾参与感与安全。",
|
||||
"detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"],
|
||||
"image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
|
||||
"demandKeyword": "峡谷团建",
|
||||
"isActive": True,
|
||||
@@ -68,6 +73,8 @@ SEED_TEAM_BUILDINGS = (
|
||||
"tag": "人文聚会",
|
||||
"title": "苗寨共聚,认识彼此",
|
||||
"description": "夜游、长桌宴与文化体验,适合团建收尾。",
|
||||
"detailSubtitle": "夜游、长桌宴与文化体验,适合团建收尾。",
|
||||
"detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"],
|
||||
"image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
|
||||
"demandKeyword": "贵州团建",
|
||||
"isActive": True,
|
||||
@@ -75,11 +82,21 @@ SEED_TEAM_BUILDINGS = (
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
OPTIONAL_SEED_COLUMN_TYPES = {
|
||||
"detailSubtitle": sa.String(),
|
||||
"detailParagraphs": postgresql.JSONB(astext_type=sa.Text()),
|
||||
"images": postgresql.JSONB(astext_type=sa.Text()),
|
||||
}
|
||||
|
||||
SEED_WILD_ARCHIVES = (
|
||||
{
|
||||
"id": "hundred-meter-descent",
|
||||
"title": "百米自降",
|
||||
"image": "https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg",
|
||||
"images": [
|
||||
"https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg"
|
||||
],
|
||||
"demandKeyword": "悬崖瀑降",
|
||||
"isActive": True,
|
||||
"sortOrder": 0,
|
||||
@@ -88,6 +105,7 @@ SEED_WILD_ARCHIVES = (
|
||||
"id": "shilong-cave",
|
||||
"title": "石龙洞",
|
||||
"image": "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg",
|
||||
"images": ["https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg"],
|
||||
"demandKeyword": "地心探险",
|
||||
"isActive": True,
|
||||
"sortOrder": 1,
|
||||
@@ -96,6 +114,7 @@ SEED_WILD_ARCHIVES = (
|
||||
"id": "cliff-current",
|
||||
"title": "绝壁迎流",
|
||||
"image": "https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg",
|
||||
"images": ["https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg"],
|
||||
"demandKeyword": "峡谷探险",
|
||||
"isActive": True,
|
||||
"sortOrder": 2,
|
||||
@@ -104,6 +123,9 @@ SEED_WILD_ARCHIVES = (
|
||||
"id": "canyon-streaming",
|
||||
"title": "峡谷溯溪",
|
||||
"image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
|
||||
"images": [
|
||||
"https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient"
|
||||
],
|
||||
"demandKeyword": "峡谷溯溪",
|
||||
"isActive": True,
|
||||
"sortOrder": 3,
|
||||
@@ -120,13 +142,43 @@ def _create_table(table_name: str, columns: list[sa.Column]) -> None:
|
||||
op.create_index(f"ix_{table_name}_sortOrder", table_name, ["sortOrder"])
|
||||
|
||||
|
||||
def prepare_seed_rows(
|
||||
rows: tuple[dict, ...],
|
||||
available_columns: set[str],
|
||||
timestamp: datetime,
|
||||
) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
key: value
|
||||
for key, value in {**row, "createdAt": timestamp, "updatedAt": timestamp}.items()
|
||||
if key in available_columns
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _seed_table(table_name: str, columns: list[sa.Column], rows: tuple[dict, ...]) -> None:
|
||||
table = sa.table(table_name, *[sa.column(column.name, column.type) for column in columns])
|
||||
timestamp = datetime(2026, 1, 1)
|
||||
op.bulk_insert(
|
||||
table,
|
||||
[{**row, "createdAt": timestamp, "updatedAt": timestamp} for row in rows],
|
||||
column_types = {column.name: column.type for column in columns}
|
||||
if context.is_offline_mode():
|
||||
available_columns = set(column_types)
|
||||
else:
|
||||
bind = op.get_bind()
|
||||
available_columns = {column["name"] for column in inspect(bind).get_columns(table_name)}
|
||||
for name, column_type in OPTIONAL_SEED_COLUMN_TYPES.items():
|
||||
if name in available_columns:
|
||||
column_types.setdefault(name, column_type)
|
||||
|
||||
seed_column_names = available_columns.intersection(column_types)
|
||||
table = sa.table(
|
||||
table_name,
|
||||
*[
|
||||
sa.column(name, column_types[name])
|
||||
for name in column_types
|
||||
if name in seed_column_names
|
||||
],
|
||||
)
|
||||
timestamp = datetime(2026, 1, 1)
|
||||
op.bulk_insert(table, prepare_seed_rows(rows, seed_column_names, timestamp))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
||||
@@ -4,8 +4,9 @@ Revision ID: 0019_home_wild_archive_images
|
||||
Revises: 0018_home_wanfa_recommendations
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
@@ -15,22 +16,40 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def should_add_images_column(existing_columns: set[str]) -> bool:
|
||||
return "images" not in existing_columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"HomeWildArchive",
|
||||
sa.Column(
|
||||
"images",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::jsonb"),
|
||||
),
|
||||
)
|
||||
if context.is_offline_mode():
|
||||
add_images_column = True
|
||||
else:
|
||||
bind = op.get_bind()
|
||||
existing_columns = {column["name"] for column in inspect(bind).get_columns("HomeWildArchive")}
|
||||
add_images_column = should_add_images_column(existing_columns)
|
||||
|
||||
if add_images_column:
|
||||
op.add_column(
|
||||
"HomeWildArchive",
|
||||
sa.Column(
|
||||
"images",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::jsonb"),
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
'UPDATE "HomeWildArchive" SET "images" = jsonb_build_array("image") WHERE jsonb_array_length("images") = 0'
|
||||
'UPDATE "HomeWildArchive" '
|
||||
'SET "images" = jsonb_build_array("image") '
|
||||
'WHERE "images" IS NULL OR CASE '
|
||||
'WHEN jsonb_typeof("images") = \'array\' '
|
||||
'THEN jsonb_array_length("images") = 0 '
|
||||
'ELSE TRUE END'
|
||||
)
|
||||
)
|
||||
op.alter_column("HomeWildArchive", "images", server_default=None)
|
||||
if add_images_column:
|
||||
op.alter_column("HomeWildArchive", "images", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -4,8 +4,9 @@ Revision ID: 0020_home_team_building_details
|
||||
Revises: 0019_home_wild_archive_images
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
@@ -15,35 +16,59 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
DETAIL_COLUMNS = {"detailSubtitle", "detailParagraphs"}
|
||||
|
||||
|
||||
def missing_detail_columns(existing_columns: set[str]) -> set[str]:
|
||||
return DETAIL_COLUMNS.difference(existing_columns)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"HomeTeamBuilding",
|
||||
sa.Column("detailSubtitle", sa.String(), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"HomeTeamBuilding",
|
||||
sa.Column(
|
||||
"detailParagraphs",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::jsonb"),
|
||||
),
|
||||
)
|
||||
if context.is_offline_mode():
|
||||
columns_to_add = DETAIL_COLUMNS
|
||||
else:
|
||||
bind = op.get_bind()
|
||||
existing_columns = {column["name"] for column in inspect(bind).get_columns("HomeTeamBuilding")}
|
||||
columns_to_add = missing_detail_columns(existing_columns)
|
||||
|
||||
if "detailSubtitle" in columns_to_add:
|
||||
op.add_column(
|
||||
"HomeTeamBuilding",
|
||||
sa.Column("detailSubtitle", sa.String(), nullable=False, server_default=""),
|
||||
)
|
||||
if "detailParagraphs" in columns_to_add:
|
||||
op.add_column(
|
||||
"HomeTeamBuilding",
|
||||
sa.Column(
|
||||
"detailParagraphs",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::jsonb"),
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
'UPDATE "HomeTeamBuilding" '
|
||||
'SET "detailSubtitle" = CASE '
|
||||
'WHEN "detailSubtitle" = \'\' THEN "description" '
|
||||
'WHEN COALESCE("detailSubtitle", \'\') = \'\' THEN "description" '
|
||||
'ELSE "detailSubtitle" END, '
|
||||
'"detailParagraphs" = CASE '
|
||||
'WHEN jsonb_array_length("detailParagraphs") = 0 '
|
||||
'WHEN "detailParagraphs" IS NULL OR jsonb_typeof("detailParagraphs") <> \'array\' '
|
||||
'OR (jsonb_typeof("detailParagraphs") = \'array\' '
|
||||
'AND jsonb_array_length("detailParagraphs") = 0) '
|
||||
'THEN jsonb_build_array("description") '
|
||||
'ELSE "detailParagraphs" END '
|
||||
'WHERE "detailSubtitle" = \'\' OR jsonb_array_length("detailParagraphs") = 0'
|
||||
'WHERE COALESCE("detailSubtitle", \'\') = \'\' '
|
||||
'OR "detailParagraphs" IS NULL '
|
||||
'OR jsonb_typeof("detailParagraphs") <> \'array\' '
|
||||
'OR (jsonb_typeof("detailParagraphs") = \'array\' '
|
||||
'AND jsonb_array_length("detailParagraphs") = 0)'
|
||||
)
|
||||
)
|
||||
op.alter_column("HomeTeamBuilding", "detailSubtitle", server_default=None)
|
||||
op.alter_column("HomeTeamBuilding", "detailParagraphs", server_default=None)
|
||||
if "detailSubtitle" in columns_to_add:
|
||||
op.alter_column("HomeTeamBuilding", "detailSubtitle", server_default=None)
|
||||
if "detailParagraphs" in columns_to_add:
|
||||
op.alter_column("HomeTeamBuilding", "detailParagraphs", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -6,8 +6,9 @@ Revises: 0020_home_team_building_details
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from alembic import op
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
@@ -17,30 +18,80 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
DETAIL_RECORD_INDEXES = {
|
||||
"ix_DetailRecord_isActive": ["isActive"],
|
||||
"ix_DetailRecord_sortOrder": ["sortOrder"],
|
||||
}
|
||||
|
||||
|
||||
def missing_detail_record_indexes(existing_indexes: set[str]) -> set[str]:
|
||||
return set(DETAIL_RECORD_INDEXES).difference(existing_indexes)
|
||||
|
||||
|
||||
def build_detail_seed_rows(routes, existing_keys: set[str], timestamp: datetime) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": f"detail-{route['id']}",
|
||||
"key": route["id"],
|
||||
"eyebrow": "玩法推荐",
|
||||
"duration": "5天4晚",
|
||||
"title": route["title"],
|
||||
"subtitle": route["subtitle"],
|
||||
"intro": route["subtitle"],
|
||||
"highlights": ["核心景观串联", "小团出行,按同行人的节奏灵活调整"],
|
||||
"included": ["行程内用车与接送服务"],
|
||||
"excluded": ["往返大交通及个人消费"],
|
||||
"notes": ["最终方案以出行日期、人数和资源确认结果为准。"],
|
||||
"gallery": [route["image"]],
|
||||
"isActive": True,
|
||||
"sortOrder": route["sortOrder"],
|
||||
"createdAt": timestamp,
|
||||
"updatedAt": timestamp,
|
||||
}
|
||||
for route in routes
|
||||
if route["id"] not in existing_keys
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"DetailRecord",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("key", sa.String(), nullable=False),
|
||||
sa.Column("eyebrow", sa.String(), nullable=False),
|
||||
sa.Column("duration", sa.String(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=False),
|
||||
sa.Column("subtitle", sa.Text(), nullable=False),
|
||||
sa.Column("intro", sa.Text(), nullable=False),
|
||||
sa.Column("highlights", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("included", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("excluded", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("notes", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("gallery", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("key"),
|
||||
)
|
||||
op.create_index("ix_DetailRecord_isActive", "DetailRecord", ["isActive"])
|
||||
op.create_index("ix_DetailRecord_sortOrder", "DetailRecord", ["sortOrder"])
|
||||
if context.is_offline_mode():
|
||||
table_exists = False
|
||||
existing_index_names = set()
|
||||
else:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
table_exists = "DetailRecord" in set(inspector.get_table_names())
|
||||
existing_index_names = {
|
||||
index["name"]
|
||||
for index in inspector.get_indexes("DetailRecord")
|
||||
} if table_exists else set()
|
||||
|
||||
if not table_exists:
|
||||
op.create_table(
|
||||
"DetailRecord",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("key", sa.String(), nullable=False),
|
||||
sa.Column("eyebrow", sa.String(), nullable=False),
|
||||
sa.Column("duration", sa.String(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=False),
|
||||
sa.Column("subtitle", sa.Text(), nullable=False),
|
||||
sa.Column("intro", sa.Text(), nullable=False),
|
||||
sa.Column("highlights", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("included", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("excluded", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("notes", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("gallery", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("key"),
|
||||
)
|
||||
existing_index_names = set()
|
||||
|
||||
for index_name in missing_detail_record_indexes(existing_index_names):
|
||||
op.create_index(index_name, "DetailRecord", DETAIL_RECORD_INDEXES[index_name])
|
||||
|
||||
detail_table = sa.table(
|
||||
"DetailRecord",
|
||||
@@ -61,34 +112,18 @@ def upgrade() -> None:
|
||||
sa.column("createdAt", sa.DateTime()),
|
||||
sa.column("updatedAt", sa.DateTime()),
|
||||
)
|
||||
routes = op.get_bind().execute(
|
||||
timestamp = datetime(2026, 1, 1)
|
||||
if context.is_offline_mode():
|
||||
return
|
||||
|
||||
conn = op.get_bind()
|
||||
routes = conn.execute(
|
||||
sa.text('SELECT "id", "title", "subtitle", "image", "sortOrder" FROM "WanfaRoute" ORDER BY "sortOrder" ASC')
|
||||
).mappings().all()
|
||||
timestamp = datetime(2026, 1, 1)
|
||||
op.bulk_insert(
|
||||
detail_table,
|
||||
[
|
||||
{
|
||||
"id": f"detail-{route['id']}",
|
||||
"key": route["id"],
|
||||
"eyebrow": "玩法推荐",
|
||||
"duration": "5天4晚",
|
||||
"title": route["title"],
|
||||
"subtitle": route["subtitle"],
|
||||
"intro": route["subtitle"],
|
||||
"highlights": ["核心景观串联", "小团出行,按同行人的节奏灵活调整"],
|
||||
"included": ["行程内用车与接送服务"],
|
||||
"excluded": ["往返大交通及个人消费"],
|
||||
"notes": ["最终方案以出行日期、人数和资源确认结果为准。"],
|
||||
"gallery": [route["image"]],
|
||||
"isActive": True,
|
||||
"sortOrder": route["sortOrder"],
|
||||
"createdAt": timestamp,
|
||||
"updatedAt": timestamp,
|
||||
}
|
||||
for route in routes
|
||||
],
|
||||
)
|
||||
existing_keys = set(conn.execute(sa.text('SELECT "key" FROM "DetailRecord"')).scalars())
|
||||
seed_rows = build_detail_seed_rows(routes, existing_keys, timestamp)
|
||||
if seed_rows:
|
||||
op.bulk_insert(detail_table, seed_rows)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -4,8 +4,9 @@ Revision ID: 0023_detail_concierge_advisor
|
||||
Revises: 0022_opaque_ids
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "0023_detail_concierge_advisor"
|
||||
@@ -14,9 +15,32 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def should_add_concierge_advisor_column(existing_columns: set[str]) -> bool:
|
||||
return "conciergeAdvisorId" not in existing_columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("DetailRecord", sa.Column("conciergeAdvisorId", sa.String(), nullable=True))
|
||||
if context.is_offline_mode():
|
||||
should_add = True
|
||||
else:
|
||||
existing_columns = {
|
||||
column["name"]
|
||||
for column in inspect(op.get_bind()).get_columns("DetailRecord")
|
||||
}
|
||||
should_add = should_add_concierge_advisor_column(existing_columns)
|
||||
|
||||
if should_add:
|
||||
op.add_column("DetailRecord", sa.Column("conciergeAdvisorId", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("DetailRecord", "conciergeAdvisorId")
|
||||
if context.is_offline_mode():
|
||||
op.drop_column("DetailRecord", "conciergeAdvisorId")
|
||||
return
|
||||
|
||||
existing_columns = {
|
||||
column["name"]
|
||||
for column in inspect(op.get_bind()).get_columns("DetailRecord")
|
||||
}
|
||||
if "conciergeAdvisorId" in existing_columns:
|
||||
op.drop_column("DetailRecord", "conciergeAdvisorId")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Add vehicle demand fields and service configuration."""
|
||||
|
||||
from alembic import op
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
@@ -11,34 +12,115 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("Lead", sa.Column("leadType", sa.String(), nullable=False, server_default="general"))
|
||||
op.add_column("Lead", sa.Column("contactName", sa.String(), nullable=True))
|
||||
op.add_column("Lead", sa.Column("customerId", sa.String(), nullable=True))
|
||||
op.add_column("Lead", sa.Column("vehicleDemand", postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
op.create_index("ix_Lead_leadType", "Lead", ["leadType"])
|
||||
op.create_index("ix_Lead_customerId", "Lead", ["customerId"])
|
||||
op.create_foreign_key("fk_Lead_customerId_Customer", "Lead", "Customer", ["customerId"], ["id"], ondelete="SET NULL")
|
||||
op.alter_column("Lead", "leadType", server_default=None)
|
||||
LEAD_COLUMNS = {
|
||||
"leadType",
|
||||
"contactName",
|
||||
"customerId",
|
||||
"vehicleDemand",
|
||||
}
|
||||
|
||||
op.create_table(
|
||||
"VehicleServiceConfig",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("introTitle", sa.String(), nullable=False),
|
||||
sa.Column("intro", sa.Text(), nullable=False),
|
||||
sa.Column("serviceSections", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("advantages", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("processSteps", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
LEAD_INDEXES = {
|
||||
"ix_Lead_leadType": ["leadType"],
|
||||
"ix_Lead_customerId": ["customerId"],
|
||||
}
|
||||
|
||||
VEHICLE_SERVICE_INDEXES = {
|
||||
"ix_VehicleServiceConfig_isActive": ["isActive"],
|
||||
}
|
||||
|
||||
|
||||
def missing_lead_columns(existing_columns: set[str]) -> set[str]:
|
||||
return LEAD_COLUMNS.difference(existing_columns)
|
||||
|
||||
|
||||
def missing_named_indexes(existing_indexes: set[str], expected_indexes: dict[str, list[str]]) -> set[str]:
|
||||
return set(expected_indexes).difference(existing_indexes)
|
||||
|
||||
|
||||
def should_create_customer_fk(foreign_keys: list[dict]) -> bool:
|
||||
return not any(
|
||||
foreign_key.get("referred_table") == "Customer"
|
||||
and foreign_key.get("referred_columns") == ["id"]
|
||||
and foreign_key.get("constrained_columns") == ["customerId"]
|
||||
for foreign_key in foreign_keys
|
||||
)
|
||||
op.create_index("ix_VehicleServiceConfig_isActive", "VehicleServiceConfig", ["isActive"])
|
||||
op.alter_column("VehicleServiceConfig", "serviceSections", server_default=None)
|
||||
op.alter_column("VehicleServiceConfig", "advantages", server_default=None)
|
||||
op.alter_column("VehicleServiceConfig", "processSteps", server_default=None)
|
||||
op.alter_column("VehicleServiceConfig", "isActive", server_default=None)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
existing_lead_columns = set()
|
||||
existing_lead_indexes = set()
|
||||
existing_lead_foreign_keys = []
|
||||
vehicle_service_table_exists = False
|
||||
existing_vehicle_service_indexes = set()
|
||||
else:
|
||||
inspector = inspect(op.get_bind())
|
||||
existing_lead_columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("Lead")
|
||||
}
|
||||
existing_lead_indexes = {
|
||||
index["name"]
|
||||
for index in inspector.get_indexes("Lead")
|
||||
if index.get("name")
|
||||
}
|
||||
existing_lead_foreign_keys = inspector.get_foreign_keys("Lead")
|
||||
vehicle_service_table_exists = "VehicleServiceConfig" in set(inspector.get_table_names())
|
||||
existing_vehicle_service_indexes = {
|
||||
index["name"]
|
||||
for index in inspector.get_indexes("VehicleServiceConfig")
|
||||
if index.get("name")
|
||||
} if vehicle_service_table_exists else set()
|
||||
|
||||
added_lead_type = "leadType" not in existing_lead_columns
|
||||
lead_columns = {
|
||||
"leadType": sa.Column("leadType", sa.String(), nullable=False, server_default="general"),
|
||||
"contactName": sa.Column("contactName", sa.String(), nullable=True),
|
||||
"customerId": sa.Column("customerId", sa.String(), nullable=True),
|
||||
"vehicleDemand": sa.Column("vehicleDemand", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
}
|
||||
for column_name in ("leadType", "contactName", "customerId", "vehicleDemand"):
|
||||
if column_name in missing_lead_columns(existing_lead_columns):
|
||||
op.add_column("Lead", lead_columns[column_name])
|
||||
|
||||
for index_name in missing_named_indexes(existing_lead_indexes, LEAD_INDEXES):
|
||||
op.create_index(index_name, "Lead", LEAD_INDEXES[index_name])
|
||||
|
||||
if should_create_customer_fk(existing_lead_foreign_keys):
|
||||
op.create_foreign_key(
|
||||
"fk_Lead_customerId_Customer",
|
||||
"Lead",
|
||||
"Customer",
|
||||
["customerId"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
if added_lead_type:
|
||||
op.alter_column("Lead", "leadType", server_default=None)
|
||||
|
||||
if not vehicle_service_table_exists:
|
||||
op.create_table(
|
||||
"VehicleServiceConfig",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("introTitle", sa.String(), nullable=False),
|
||||
sa.Column("intro", sa.Text(), nullable=False),
|
||||
sa.Column("serviceSections", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("advantages", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("processSteps", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("createdAt", sa.DateTime(), nullable=False),
|
||||
sa.Column("updatedAt", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
existing_vehicle_service_indexes = set()
|
||||
for column_name in ("serviceSections", "advantages", "processSteps", "isActive"):
|
||||
if column_name != "isActive":
|
||||
op.alter_column("VehicleServiceConfig", column_name, server_default=None)
|
||||
op.alter_column("VehicleServiceConfig", "isActive", server_default=None)
|
||||
|
||||
for index_name in missing_named_indexes(existing_vehicle_service_indexes, VEHICLE_SERVICE_INDEXES):
|
||||
op.create_index(index_name, "VehicleServiceConfig", VEHICLE_SERVICE_INDEXES[index_name])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
14
WonderQ-Admin/tests/test_detail_concierge_migration.py
Normal file
14
WonderQ-Admin/tests/test_detail_concierge_migration.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0023_detail_concierge_advisor.py"
|
||||
SPEC = spec_from_file_location("detail_concierge_migration", MIGRATION_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MIGRATION = module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MIGRATION)
|
||||
|
||||
|
||||
def test_concierge_advisor_column_is_added_only_when_missing():
|
||||
assert MIGRATION.should_add_concierge_advisor_column(set())
|
||||
assert not MIGRATION.should_add_concierge_advisor_column({"id", "conciergeAdvisorId"})
|
||||
38
WonderQ-Admin/tests/test_detail_records_migration.py
Normal file
38
WonderQ-Admin/tests/test_detail_records_migration.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0021_detail_records.py"
|
||||
SPEC = spec_from_file_location("detail_records_migration", MIGRATION_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MIGRATION = module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MIGRATION)
|
||||
|
||||
|
||||
def test_detail_record_indexes_are_created_only_when_missing():
|
||||
assert MIGRATION.missing_detail_record_indexes(set()) == {
|
||||
"ix_DetailRecord_isActive",
|
||||
"ix_DetailRecord_sortOrder",
|
||||
}
|
||||
assert MIGRATION.missing_detail_record_indexes({"ix_DetailRecord_isActive"}) == {
|
||||
"ix_DetailRecord_sortOrder",
|
||||
}
|
||||
assert MIGRATION.missing_detail_record_indexes(
|
||||
{"ix_DetailRecord_isActive", "ix_DetailRecord_sortOrder"}
|
||||
) == set()
|
||||
|
||||
|
||||
def test_detail_seed_rows_skip_existing_route_keys():
|
||||
routes = [
|
||||
{
|
||||
"id": "family-route",
|
||||
"title": "亲子路线",
|
||||
"subtitle": "贵州亲子慢游",
|
||||
"image": "https://example.test/family.jpg",
|
||||
"sortOrder": 0,
|
||||
}
|
||||
]
|
||||
rows = MIGRATION.build_detail_seed_rows(routes, set(), datetime(2026, 1, 1))
|
||||
assert rows[0]["key"] == "family-route"
|
||||
assert MIGRATION.build_detail_seed_rows(routes, {"family-route"}, datetime(2026, 1, 1)) == []
|
||||
41
WonderQ-Admin/tests/test_home_content_migration.py
Normal file
41
WonderQ-Admin/tests/test_home_content_migration.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0017_home_content.py"
|
||||
SPEC = spec_from_file_location("home_content_migration", MIGRATION_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MIGRATION = module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MIGRATION)
|
||||
|
||||
|
||||
def test_team_building_seed_contains_detail_fallback_fields():
|
||||
for row in MIGRATION.SEED_TEAM_BUILDINGS:
|
||||
assert row["detailSubtitle"] == row["description"]
|
||||
assert row["detailParagraphs"] == [row["description"]]
|
||||
|
||||
|
||||
def test_wild_archive_seed_contains_detail_images_fallback():
|
||||
for row in MIGRATION.SEED_WILD_ARCHIVES:
|
||||
assert row["images"] == [row["image"]]
|
||||
|
||||
|
||||
def test_seed_rows_only_include_columns_available_in_existing_table():
|
||||
timestamp = datetime(2026, 1, 1)
|
||||
|
||||
rows_without_detail_columns = MIGRATION.prepare_seed_rows(
|
||||
MIGRATION.SEED_TEAM_BUILDINGS[:1],
|
||||
{"id", "description", "createdAt", "updatedAt"},
|
||||
timestamp,
|
||||
)
|
||||
assert "detailSubtitle" not in rows_without_detail_columns[0]
|
||||
assert "detailParagraphs" not in rows_without_detail_columns[0]
|
||||
|
||||
rows_with_detail_columns = MIGRATION.prepare_seed_rows(
|
||||
MIGRATION.SEED_TEAM_BUILDINGS[:1],
|
||||
{"id", "description", "detailSubtitle", "detailParagraphs", "createdAt", "updatedAt"},
|
||||
timestamp,
|
||||
)
|
||||
assert rows_with_detail_columns[0]["detailSubtitle"] == rows_with_detail_columns[0]["description"]
|
||||
assert rows_with_detail_columns[0]["detailParagraphs"] == [rows_with_detail_columns[0]["description"]]
|
||||
@@ -0,0 +1,18 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0020_home_team_building_details.py"
|
||||
SPEC = spec_from_file_location("home_team_building_details_migration", MIGRATION_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MIGRATION = module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MIGRATION)
|
||||
|
||||
|
||||
def test_detail_columns_are_added_only_when_missing():
|
||||
assert MIGRATION.missing_detail_columns(set()) == {
|
||||
"detailSubtitle",
|
||||
"detailParagraphs",
|
||||
}
|
||||
assert MIGRATION.missing_detail_columns({"detailSubtitle"}) == {"detailParagraphs"}
|
||||
assert MIGRATION.missing_detail_columns({"detailSubtitle", "detailParagraphs"}) == set()
|
||||
@@ -0,0 +1,14 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0019_home_wild_archive_images.py"
|
||||
SPEC = spec_from_file_location("home_wild_archive_images_migration", MIGRATION_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MIGRATION = module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MIGRATION)
|
||||
|
||||
|
||||
def test_images_column_is_added_only_when_missing():
|
||||
assert MIGRATION.should_add_images_column(set())
|
||||
assert not MIGRATION.should_add_images_column({"id", "image", "images"})
|
||||
38
WonderQ-Admin/tests/test_vehicle_demand_migration.py
Normal file
38
WonderQ-Admin/tests/test_vehicle_demand_migration.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0024_vehicle_demand.py"
|
||||
SPEC = spec_from_file_location("vehicle_demand_migration", MIGRATION_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MIGRATION = module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MIGRATION)
|
||||
|
||||
|
||||
def test_vehicle_demand_columns_are_added_only_when_missing():
|
||||
assert MIGRATION.missing_lead_columns(set()) == {
|
||||
"leadType",
|
||||
"contactName",
|
||||
"customerId",
|
||||
"vehicleDemand",
|
||||
}
|
||||
assert MIGRATION.missing_lead_columns({"leadType", "contactName"}) == {
|
||||
"customerId",
|
||||
"vehicleDemand",
|
||||
}
|
||||
|
||||
|
||||
def test_vehicle_demand_indexes_and_foreign_key_are_only_added_when_missing():
|
||||
assert MIGRATION.missing_named_indexes(set(), MIGRATION.LEAD_INDEXES) == {
|
||||
"ix_Lead_leadType",
|
||||
"ix_Lead_customerId",
|
||||
}
|
||||
assert not MIGRATION.should_create_customer_fk(
|
||||
[
|
||||
{
|
||||
"referred_table": "Customer",
|
||||
"referred_columns": ["id"],
|
||||
"constrained_columns": ["customerId"],
|
||||
}
|
||||
]
|
||||
)
|
||||
BIN
WonderQ-Admin/wonderq-admin-release-20260823-01.tar
Normal file
BIN
WonderQ-Admin/wonderq-admin-release-20260823-01.tar
Normal file
Binary file not shown.
BIN
WonderQ-Admin/wonderq-local-data.dump
Normal file
BIN
WonderQ-Admin/wonderq-local-data.dump
Normal file
Binary file not shown.
Reference in New Issue
Block a user