9cf1c9ab11
Implements the 14 core ORM models for the yunke data-mirror system using SQLAlchemy 2.0 Mapped/mapped_column style, driven by the field lists in docs/superpowers/specs/2026-08-01-yunke-data-dictionary.md: Department, Staff, WechatAccount, WhatsappAccount, Customer, ChatGroup, GroupMember, Message, Media, PhoneCall, Sms, SyncState, YkAccount, SyncRun. Conventions: - Natural source PKs where they exist (friend_id, wx_id, msg_id, room_id, media_hash, call_id, sms_id, dept_id, user_id); surrogate Integer PKs otherwise (SyncState, YkAccount, SyncRun). GroupMember uses a composite (channel, room_id, member_id) PK. - Common audit columns version/raw_json/fetched_at/updated_at on every source-mirrored table. - channel only where meaningful: Customer/Message/Media/ChatGroup/ GroupMember/Sms use messaging channels; PhoneCall.channel is call-type; SyncState.channel scopes the cursor. Omitted on Staff/Department/ WechatAccount/WhatsappAccount/YkAccount/SyncRun. - Message composite index on (channel, owner_account_id, talker_id, timestamp) for the hot per-talker time-order query path. - message_table_name(channel, timestamp) helper documents the future message_<channel>_<YYYYMM> sharding convention; the Message model still maps to a single physical table for now (sharding is a later task). All types are portable generic SQLAlchemy types so tests run on in-memory SQLite while prod targets MySQL. Tests verify Customer round-trip, raw_json JSON round-trip, Message natural PK, GroupMember composite PK, the Message composite index, and full table creation on SQLite. Alembic wired via migrations/env.py to Base.metadata + get_settings().db_url with an async->sync driver swap (mysql+asyncmy -> mysql+pymysql, sqlite+aiosqlite -> sqlite). Initial migration autogenerated against an empty SQLite DB; server defaults normalized to sa.func.now() so the file is MySQL-portable. upgrade head is intentionally not run (no MySQL yet). Co-Authored-By: Claude <noreply@anthropic.com>
332 lines
19 KiB
Python
332 lines
19 KiB
Python
"""initial schema
|
|
|
|
Revision ID: d7a9bc760c6b
|
|
Revises:
|
|
Create Date: 2026-08-01 11:11:18.566666
|
|
|
|
Autogenerated against an empty SQLite database (no MySQL server was available
|
|
during development). All column types are generic (``sa.String``, ``sa.Text``,
|
|
``sa.JSON``, ``sa.BigInteger``, ``sa.Integer``, ``sa.Boolean``, ``sa.DateTime``)
|
|
and the ``server_default=sa.func.now()`` timestamps compile per-dialect, so the
|
|
migration is portable to MySQL. When a MySQL instance is provisioned, ``alembic
|
|
upgrade head`` should be run there directly (no regeneration needed).
|
|
"""
|
|
from collections.abc import Sequence
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision: str = 'd7a9bc760c6b'
|
|
down_revision: str | Sequence[str] | None = None
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
"""Upgrade schema."""
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.create_table('chat_group',
|
|
sa.Column('room_id', sa.String(length=128), nullable=False),
|
|
sa.Column('channel', sa.String(length=16), nullable=False),
|
|
sa.Column('room_name', sa.String(length=255), nullable=True),
|
|
sa.Column('owner_account_id', sa.String(length=128), nullable=True),
|
|
sa.Column('owner_account_nickname', sa.String(length=255), nullable=True),
|
|
sa.Column('member_count', sa.Integer(), nullable=True),
|
|
sa.Column('member_ids', sa.Text(), nullable=True),
|
|
sa.Column('head_image_url', sa.String(length=1024), nullable=True),
|
|
sa.Column('owner_user_id', sa.String(length=64), nullable=True),
|
|
sa.Column('owner_user_phone', sa.String(length=64), nullable=True),
|
|
sa.Column('message_count', sa.Integer(), nullable=True),
|
|
sa.Column('own_message_count', sa.Integer(), nullable=True),
|
|
sa.Column('loss_members', sa.Integer(), nullable=True),
|
|
sa.Column('add_members', sa.Integer(), nullable=True),
|
|
sa.Column('last_chat_time', sa.BigInteger(), nullable=True),
|
|
sa.Column('update_time', sa.BigInteger(), nullable=True),
|
|
sa.Column('version', sa.String(length=64), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('room_id')
|
|
)
|
|
op.create_index('ix_chat_group_owner', 'chat_group', ['channel', 'owner_account_id', 'room_id'], unique=False)
|
|
op.create_table('customer',
|
|
sa.Column('friend_id', sa.String(length=128), nullable=False),
|
|
sa.Column('channel', sa.String(length=16), nullable=False),
|
|
sa.Column('owner_account_id', sa.String(length=128), nullable=True),
|
|
sa.Column('friend_nickname', sa.String(length=255), nullable=True),
|
|
sa.Column('friend_remark', sa.String(length=255), nullable=True),
|
|
sa.Column('real_name', sa.String(length=255), nullable=True),
|
|
sa.Column('phone', sa.String(length=64), nullable=True),
|
|
sa.Column('wechat_phone', sa.String(length=64), nullable=True),
|
|
sa.Column('gender', sa.Integer(), nullable=True),
|
|
sa.Column('region', sa.String(length=255), nullable=True),
|
|
sa.Column('description', sa.Text(), nullable=True),
|
|
sa.Column('sale_name', sa.String(length=255), nullable=True),
|
|
sa.Column('user_id', sa.String(length=64), nullable=True),
|
|
sa.Column('dept_route', sa.String(length=255), nullable=True),
|
|
sa.Column('dept_name', sa.String(length=255), nullable=True),
|
|
sa.Column('head_image_url', sa.String(length=1024), nullable=True),
|
|
sa.Column('last_chat_time', sa.BigInteger(), nullable=True),
|
|
sa.Column('send_num', sa.Integer(), nullable=True),
|
|
sa.Column('receive_num', sa.Integer(), nullable=True),
|
|
sa.Column('message_count', sa.Integer(), nullable=True),
|
|
sa.Column('delete_flag', sa.Integer(), nullable=True),
|
|
sa.Column('is_block', sa.Boolean(), nullable=True),
|
|
sa.Column('friend_state', sa.Integer(), nullable=True),
|
|
sa.Column('version', sa.String(length=64), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('friend_id')
|
|
)
|
|
op.create_index('ix_customer_owner_friend', 'customer', ['channel', 'owner_account_id', 'friend_id'], unique=False)
|
|
op.create_table('department',
|
|
sa.Column('dept_id', sa.String(length=64), nullable=False),
|
|
sa.Column('parent_id', sa.String(length=64), nullable=True),
|
|
sa.Column('name', sa.String(length=255), nullable=True),
|
|
sa.Column('path', sa.String(length=512), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('dept_id')
|
|
)
|
|
op.create_table('group_member',
|
|
sa.Column('channel', sa.String(length=16), nullable=False),
|
|
sa.Column('room_id', sa.String(length=128), nullable=False),
|
|
sa.Column('member_id', sa.String(length=128), nullable=False),
|
|
sa.Column('nickname', sa.String(length=255), nullable=True),
|
|
sa.Column('avatar', sa.String(length=1024), nullable=True),
|
|
sa.Column('display_name', sa.String(length=255), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('channel', 'room_id', 'member_id')
|
|
)
|
|
op.create_table('media',
|
|
sa.Column('media_hash', sa.String(length=64), nullable=False),
|
|
sa.Column('channel', sa.String(length=16), nullable=False),
|
|
sa.Column('media_type', sa.String(length=32), nullable=True),
|
|
sa.Column('original_url', sa.String(length=1024), nullable=True),
|
|
sa.Column('original_th_url', sa.String(length=1024), nullable=True),
|
|
sa.Column('original_hd_url', sa.String(length=1024), nullable=True),
|
|
sa.Column('local_path', sa.String(length=1024), nullable=True),
|
|
sa.Column('size_bytes', sa.BigInteger(), nullable=True),
|
|
sa.Column('mime_type', sa.String(length=128), nullable=True),
|
|
sa.Column('duration', sa.Integer(), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('media_hash')
|
|
)
|
|
op.create_index('ix_media_type_channel', 'media', ['media_type', 'channel'], unique=False)
|
|
op.create_table('message',
|
|
sa.Column('msg_id', sa.String(length=64), nullable=False),
|
|
sa.Column('channel', sa.String(length=16), nullable=False),
|
|
sa.Column('owner_account_id', sa.String(length=128), nullable=True),
|
|
sa.Column('talker_id', sa.String(length=128), nullable=True),
|
|
sa.Column('ori_talker', sa.String(length=128), nullable=True),
|
|
sa.Column('room_id', sa.String(length=128), nullable=True),
|
|
sa.Column('is_send', sa.Boolean(), nullable=True),
|
|
sa.Column('type', sa.Integer(), nullable=True),
|
|
sa.Column('text', sa.Text(), nullable=True),
|
|
sa.Column('file_url', sa.String(length=1024), nullable=True),
|
|
sa.Column('file_th_url', sa.String(length=1024), nullable=True),
|
|
sa.Column('file_hd_url', sa.String(length=1024), nullable=True),
|
|
sa.Column('md5', sa.String(length=64), nullable=True),
|
|
sa.Column('md5_th', sa.String(length=64), nullable=True),
|
|
sa.Column('md5_hd', sa.String(length=64), nullable=True),
|
|
sa.Column('duration', sa.Integer(), nullable=True),
|
|
sa.Column('transcription_text', sa.Text(), nullable=True),
|
|
sa.Column('img_path', sa.String(length=1024), nullable=True),
|
|
sa.Column('original_image', sa.String(length=1024), nullable=True),
|
|
sa.Column('pic_type', sa.Integer(), nullable=True),
|
|
sa.Column('pay_msg_id', sa.String(length=64), nullable=True),
|
|
sa.Column('refer_msg', sa.JSON(), nullable=True),
|
|
sa.Column('sender_name', sa.String(length=255), nullable=True),
|
|
sa.Column('sender_head_url', sa.String(length=1024), nullable=True),
|
|
sa.Column('timestamp', sa.BigInteger(), nullable=True),
|
|
sa.Column('is_del', sa.Boolean(), nullable=True),
|
|
sa.Column('version', sa.String(length=64), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('msg_id')
|
|
)
|
|
op.create_index('ix_message_channel_owner_talker_time', 'message', ['channel', 'owner_account_id', 'talker_id', 'timestamp'], unique=False)
|
|
op.create_table('phone_call',
|
|
sa.Column('call_id', sa.String(length=64), nullable=False),
|
|
sa.Column('channel', sa.String(length=16), nullable=False),
|
|
sa.Column('start_call_time', sa.BigInteger(), nullable=True),
|
|
sa.Column('call_duration', sa.Integer(), nullable=True),
|
|
sa.Column('ring_time', sa.Integer(), nullable=True),
|
|
sa.Column('call_status', sa.Integer(), nullable=True),
|
|
sa.Column('incoming_call', sa.Integer(), nullable=True),
|
|
sa.Column('user_id', sa.String(length=64), nullable=True),
|
|
sa.Column('user_id_name', sa.String(length=255), nullable=True),
|
|
sa.Column('department_id', sa.String(length=64), nullable=True),
|
|
sa.Column('call_number', sa.String(length=64), nullable=True),
|
|
sa.Column('sim_phone', sa.String(length=64), nullable=True),
|
|
sa.Column('sim_number', sa.String(length=64), nullable=True),
|
|
sa.Column('imei', sa.String(length=128), nullable=True),
|
|
sa.Column('record_file', sa.String(length=1024), nullable=True),
|
|
sa.Column('is_upload', sa.Boolean(), nullable=True),
|
|
sa.Column('download_count', sa.Integer(), nullable=True),
|
|
sa.Column('ai_asr', sa.Text(), nullable=True),
|
|
sa.Column('qc_status', sa.Integer(), nullable=True),
|
|
sa.Column('plan_customer_name', sa.String(length=255), nullable=True),
|
|
sa.Column('version', sa.String(length=64), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('call_id')
|
|
)
|
|
op.create_index('ix_phone_call_user_time', 'phone_call', ['user_id', 'start_call_time'], unique=False)
|
|
op.create_table('sms',
|
|
sa.Column('sms_id', sa.String(length=64), nullable=False),
|
|
sa.Column('channel', sa.String(length=16), nullable=False),
|
|
sa.Column('customer_phone', sa.String(length=64), nullable=True),
|
|
sa.Column('content', sa.Text(), nullable=True),
|
|
sa.Column('content_preview', sa.String(length=255), nullable=True),
|
|
sa.Column('sender', sa.String(length=128), nullable=True),
|
|
sa.Column('direction', sa.Integer(), nullable=True),
|
|
sa.Column('user_id', sa.String(length=64), nullable=True),
|
|
sa.Column('sender_department', sa.String(length=255), nullable=True),
|
|
sa.Column('sender_department_id', sa.String(length=64), nullable=True),
|
|
sa.Column('receive_name', sa.String(length=255), nullable=True),
|
|
sa.Column('create_time', sa.BigInteger(), nullable=True),
|
|
sa.Column('sim_phone', sa.String(length=64), nullable=True),
|
|
sa.Column('sim_number', sa.String(length=64), nullable=True),
|
|
sa.Column('source', sa.String(length=64), nullable=True),
|
|
sa.Column('version', sa.String(length=64), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('sms_id')
|
|
)
|
|
op.create_index('ix_sms_user_time', 'sms', ['user_id', 'create_time'], unique=False)
|
|
op.create_table('staff',
|
|
sa.Column('user_id', sa.String(length=64), nullable=False),
|
|
sa.Column('name', sa.String(length=255), nullable=True),
|
|
sa.Column('phone', sa.String(length=64), nullable=True),
|
|
sa.Column('sale_phone', sa.String(length=64), nullable=True),
|
|
sa.Column('dept_id', sa.String(length=64), nullable=True),
|
|
sa.Column('is_on_job', sa.Boolean(), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('user_id')
|
|
)
|
|
op.create_table('sync_run',
|
|
sa.Column('run_id', sa.Integer(), autoincrement=True, nullable=False),
|
|
sa.Column('entity_type', sa.String(length=32), nullable=False),
|
|
sa.Column('channel', sa.String(length=16), nullable=True),
|
|
sa.Column('scope_id', sa.String(length=128), nullable=True),
|
|
sa.Column('status', sa.String(length=16), nullable=False),
|
|
sa.Column('started_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('finished_at', sa.DateTime(), nullable=True),
|
|
sa.Column('records_fetched', sa.Integer(), server_default=sa.text('0'), nullable=False),
|
|
sa.Column('records_upserted', sa.Integer(), server_default=sa.text('0'), nullable=False),
|
|
sa.Column('error', sa.Text(), nullable=True),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('run_id')
|
|
)
|
|
op.create_index('ix_sync_run_entity_status', 'sync_run', ['entity_type', 'status'], unique=False)
|
|
op.create_table('sync_state',
|
|
sa.Column('state_id', sa.Integer(), autoincrement=True, nullable=False),
|
|
sa.Column('entity_type', sa.String(length=32), nullable=False),
|
|
sa.Column('channel', sa.String(length=16), nullable=True),
|
|
sa.Column('scope_id', sa.String(length=128), nullable=True),
|
|
sa.Column('last_version', sa.String(length=64), nullable=True),
|
|
sa.Column('last_timestamp', sa.BigInteger(), nullable=True),
|
|
sa.Column('last_synced_at', sa.DateTime(), nullable=True),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('state_id')
|
|
)
|
|
op.create_index('ix_sync_state_scope', 'sync_state', ['entity_type', 'channel', 'scope_id'], unique=True)
|
|
op.create_table('wechat_account',
|
|
sa.Column('wx_id', sa.String(length=128), nullable=False),
|
|
sa.Column('nick_name', sa.String(length=255), nullable=True),
|
|
sa.Column('alias', sa.String(length=255), nullable=True),
|
|
sa.Column('phone', sa.String(length=64), nullable=True),
|
|
sa.Column('user_id', sa.String(length=64), nullable=True),
|
|
sa.Column('user_name', sa.String(length=255), nullable=True),
|
|
sa.Column('contacts_num', sa.Integer(), nullable=True),
|
|
sa.Column('group_num', sa.Integer(), nullable=True),
|
|
sa.Column('send_num', sa.Integer(), nullable=True),
|
|
sa.Column('receive_num', sa.Integer(), nullable=True),
|
|
sa.Column('is_on_job', sa.Boolean(), nullable=True),
|
|
sa.Column('db_size', sa.Integer(), nullable=True),
|
|
sa.Column('online_status', sa.String(length=32), nullable=True),
|
|
sa.Column('last_update', sa.BigInteger(), nullable=True),
|
|
sa.Column('version', sa.String(length=64), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('wx_id')
|
|
)
|
|
op.create_table('whatsapp_account',
|
|
sa.Column('uid', sa.String(length=128), nullable=False),
|
|
sa.Column('user_id', sa.String(length=64), nullable=True),
|
|
sa.Column('user_name', sa.String(length=255), nullable=True),
|
|
sa.Column('nick_name', sa.String(length=255), nullable=True),
|
|
sa.Column('phone', sa.String(length=64), nullable=True),
|
|
sa.Column('sale_phone', sa.String(length=64), nullable=True),
|
|
sa.Column('sales_bind_phone', sa.String(length=64), nullable=True),
|
|
sa.Column('contacts_num', sa.Integer(), nullable=True),
|
|
sa.Column('group_num', sa.Integer(), nullable=True),
|
|
sa.Column('talker_count', sa.Integer(), nullable=True),
|
|
sa.Column('friend_add_num', sa.Integer(), nullable=True),
|
|
sa.Column('is_on_job', sa.Boolean(), nullable=True),
|
|
sa.Column('last_update', sa.BigInteger(), nullable=True),
|
|
sa.Column('version', sa.String(length=64), nullable=True),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('uid')
|
|
)
|
|
op.create_table('yk_account',
|
|
sa.Column('account_id', sa.Integer(), autoincrement=True, nullable=False),
|
|
sa.Column('user_id', sa.String(length=64), nullable=False),
|
|
sa.Column('company', sa.String(length=64), nullable=False),
|
|
sa.Column('username', sa.String(length=255), nullable=True),
|
|
sa.Column('session_blob_enc', sa.Text(), nullable=True),
|
|
sa.Column('last_login_at', sa.DateTime(), nullable=True),
|
|
sa.Column('is_active', sa.Boolean(), server_default=sa.text('1'), nullable=False),
|
|
sa.Column('raw_json', sa.JSON(), nullable=True),
|
|
sa.Column('fetched_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
|
sa.PrimaryKeyConstraint('account_id')
|
|
)
|
|
op.create_index('ix_yk_account_user_company', 'yk_account', ['user_id', 'company'], unique=True)
|
|
# ### end Alembic commands ###
|
|
|
|
|
|
def downgrade() -> None:
|
|
"""Downgrade schema."""
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.drop_index('ix_yk_account_user_company', table_name='yk_account')
|
|
op.drop_table('yk_account')
|
|
op.drop_table('whatsapp_account')
|
|
op.drop_table('wechat_account')
|
|
op.drop_index('ix_sync_state_scope', table_name='sync_state')
|
|
op.drop_table('sync_state')
|
|
op.drop_index('ix_sync_run_entity_status', table_name='sync_run')
|
|
op.drop_table('sync_run')
|
|
op.drop_table('staff')
|
|
op.drop_index('ix_sms_user_time', table_name='sms')
|
|
op.drop_table('sms')
|
|
op.drop_index('ix_phone_call_user_time', table_name='phone_call')
|
|
op.drop_table('phone_call')
|
|
op.drop_index('ix_message_channel_owner_talker_time', table_name='message')
|
|
op.drop_table('message')
|
|
op.drop_index('ix_media_type_channel', table_name='media')
|
|
op.drop_table('media')
|
|
op.drop_table('group_member')
|
|
op.drop_table('department')
|
|
op.drop_index('ix_customer_owner_friend', table_name='customer')
|
|
op.drop_table('customer')
|
|
op.drop_index('ix_chat_group_owner', table_name='chat_group')
|
|
op.drop_table('chat_group')
|
|
# ### end Alembic commands ###
|