[Phase 0 Reset]
- 清除舊版 app/、alembic/versions/、雜亂測試腳本
- 新 requirements.txt (移除 caldav/redis/keycloak-lib,加入 apscheduler/croniter/docker/paramiko/ping3/dnspython)
[Phase 1 資料庫]
- 9 張資料表 SQLAlchemy Models:tenants / accounts / schedules / schedule_logs /
tenant_schedule_results / account_schedule_results / servers / server_status_logs / system_status_logs
- Alembic migration 001_create_all_tables (已套用到 10.1.0.20:5433/virtual_mis)
- seed.py:schedules 初始 3 筆 / servers 初始 4 筆
[Phase 2 CRUD API]
- GET/POST/PUT/DELETE: /api/v1/tenants / accounts / servers / schedules
- /api/v1/system-status
- 帳號編碼自動產生 (prefix + seq_no 4碼左補0)
- 燈號 (lights) 從最新排程結果取得
[Phase 3 Watchdog]
- APScheduler interval 3分鐘,原子 UPDATE status=Going 防重複執行
- 手動觸發 API: POST /api/v1/schedules/{id}/run
[Phase 4 Service Clients]
- KeycloakClient:vmis-admin realm,REST API (不用 python-keycloak)
- MailClient:Docker Mailserver @ 10.1.0.254:8080,含 MX DNS 驗證
- DockerClient:docker-py 本機 + paramiko SSH 遠端 compose
- NextcloudClient:OCS API user/quota
- SystemChecker:功能驗證 (traefik routers>0 / keycloak token / SMTP EHLO / DB SELECT 1 / ping)
[TDD]
- 37 tests / 37 passed (2.11s)
- SQLite in-memory + StaticPool,無需外部 DB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
207 lines
11 KiB
Python
207 lines
11 KiB
Python
"""create all tables
|
|
|
|
Revision ID: 001
|
|
Revises:
|
|
Create Date: 2026-03-14
|
|
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision = '001'
|
|
down_revision = None
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
# ── tenants ──────────────────────────────────────────────
|
|
op.create_table(
|
|
'tenants',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('code', sa.String(50), nullable=False),
|
|
sa.Column('prefix', sa.String(20), nullable=False, server_default=''),
|
|
sa.Column('name', sa.String(200), nullable=False),
|
|
sa.Column('name_eng', sa.String(200)),
|
|
sa.Column('tax_id', sa.String(20)),
|
|
sa.Column('domain', sa.String(200), nullable=False),
|
|
sa.Column('address', sa.String(500)),
|
|
sa.Column('tel', sa.String(50)),
|
|
sa.Column('contact', sa.String(100)),
|
|
sa.Column('contact_mobile', sa.String(50)),
|
|
sa.Column('contact_email', sa.String(200)),
|
|
sa.Column('keycloak_realm', sa.String(100)),
|
|
sa.Column('plan_code', sa.String(50)),
|
|
sa.Column('employee_limit', sa.Integer()),
|
|
sa.Column('trial_start_date', sa.Date()),
|
|
sa.Column('trial_end_date', sa.Date()),
|
|
sa.Column('quota_per_user', sa.Integer(), nullable=False, server_default='20'),
|
|
sa.Column('total_quota', sa.Integer(), nullable=False, server_default='200'),
|
|
sa.Column('is_manager', sa.Boolean(), nullable=False, server_default='false'),
|
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
|
sa.Column('status', sa.String(20), nullable=False, server_default='trial'),
|
|
sa.Column('note', sa.Text()),
|
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
sa.UniqueConstraint('code', name='uq_tenants_code'),
|
|
sa.UniqueConstraint('domain', name='uq_tenants_domain'),
|
|
)
|
|
op.create_index('ix_tenants_id', 'tenants', ['id'])
|
|
op.create_index('ix_tenants_code', 'tenants', ['code'])
|
|
op.create_index('ix_tenants_domain', 'tenants', ['domain'])
|
|
|
|
# ── accounts ─────────────────────────────────────────────
|
|
op.create_table(
|
|
'accounts',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
|
sa.Column('account_code', sa.String(20), nullable=False),
|
|
sa.Column('sso_account', sa.String(100), nullable=False),
|
|
sa.Column('sso_uuid', sa.String(100)),
|
|
sa.Column('notification_email', sa.String(200), nullable=False),
|
|
sa.Column('email', sa.String(200)),
|
|
sa.Column('legal_name', sa.String(200)),
|
|
sa.Column('english_name', sa.String(200)),
|
|
sa.Column('quota_limit', sa.Integer(), nullable=False, server_default='20'),
|
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
|
sa.Column('default_password', sa.String(200)),
|
|
sa.Column('seq_no', sa.Integer(), nullable=False),
|
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
sa.UniqueConstraint('account_code', name='uq_accounts_code'),
|
|
sa.UniqueConstraint('sso_uuid', name='uq_accounts_sso_uuid'),
|
|
)
|
|
op.create_index('ix_accounts_id', 'accounts', ['id'])
|
|
op.create_index('ix_accounts_tenant_id', 'accounts', ['tenant_id'])
|
|
op.create_index('ix_accounts_account_code', 'accounts', ['account_code'])
|
|
|
|
# ── schedules ────────────────────────────────────────────
|
|
op.create_table(
|
|
'schedules',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('name', sa.String(100), nullable=False),
|
|
sa.Column('cron_timer', sa.String(50), nullable=False),
|
|
sa.Column('status', sa.String(10), nullable=False, server_default='Waiting'),
|
|
sa.Column('last_run_at', sa.DateTime()),
|
|
sa.Column('next_run_at', sa.DateTime()),
|
|
sa.Column('last_status', sa.String(10)),
|
|
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
sa.UniqueConstraint('name', name='uq_schedules_name'),
|
|
)
|
|
op.create_index('ix_schedules_id', 'schedules', ['id'])
|
|
|
|
# ── schedule_logs ────────────────────────────────────────
|
|
op.create_table(
|
|
'schedule_logs',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('schedule_id', sa.Integer(), sa.ForeignKey('schedules.id'), nullable=False),
|
|
sa.Column('schedule_name', sa.String(100), nullable=False),
|
|
sa.Column('started_at', sa.DateTime(), nullable=False),
|
|
sa.Column('ended_at', sa.DateTime()),
|
|
sa.Column('status', sa.String(10), nullable=False, server_default='running'),
|
|
)
|
|
op.create_index('ix_schedule_logs_id', 'schedule_logs', ['id'])
|
|
op.create_index('ix_schedule_logs_schedule_id', 'schedule_logs', ['schedule_id'])
|
|
|
|
# ── tenant_schedule_results ──────────────────────────────
|
|
op.create_table(
|
|
'tenant_schedule_results',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('schedule_log_id', sa.Integer(), sa.ForeignKey('schedule_logs.id'), nullable=False),
|
|
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id'), nullable=False),
|
|
sa.Column('traefik_status', sa.Boolean()),
|
|
sa.Column('traefik_done_at', sa.DateTime()),
|
|
sa.Column('sso_result', sa.Boolean()),
|
|
sa.Column('sso_done_at', sa.DateTime()),
|
|
sa.Column('mailbox_result', sa.Boolean()),
|
|
sa.Column('mailbox_done_at', sa.DateTime()),
|
|
sa.Column('nc_result', sa.Boolean()),
|
|
sa.Column('nc_done_at', sa.DateTime()),
|
|
sa.Column('office_result', sa.Boolean()),
|
|
sa.Column('office_done_at', sa.DateTime()),
|
|
sa.Column('fail_reason', sa.Text()),
|
|
sa.Column('quota_usage', sa.Float()),
|
|
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
)
|
|
op.create_index('ix_tenant_schedule_results_id', 'tenant_schedule_results', ['id'])
|
|
op.create_index('ix_tenant_schedule_results_log_id', 'tenant_schedule_results', ['schedule_log_id'])
|
|
op.create_index('ix_tenant_schedule_results_tenant_id', 'tenant_schedule_results', ['tenant_id'])
|
|
|
|
# ── account_schedule_results ─────────────────────────────
|
|
op.create_table(
|
|
'account_schedule_results',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('schedule_log_id', sa.Integer(), sa.ForeignKey('schedule_logs.id'), nullable=False),
|
|
sa.Column('account_id', sa.Integer(), sa.ForeignKey('accounts.id'), nullable=False),
|
|
sa.Column('sso_uuid', sa.String(100)),
|
|
sa.Column('sso_account', sa.String(100)),
|
|
sa.Column('sso_result', sa.Boolean()),
|
|
sa.Column('sso_done_at', sa.DateTime()),
|
|
sa.Column('mailbox_result', sa.Boolean()),
|
|
sa.Column('mailbox_done_at', sa.DateTime()),
|
|
sa.Column('nc_result', sa.Boolean()),
|
|
sa.Column('nc_done_at', sa.DateTime()),
|
|
sa.Column('fail_reason', sa.Text()),
|
|
sa.Column('quota_usage', sa.Float()),
|
|
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
)
|
|
op.create_index('ix_account_schedule_results_id', 'account_schedule_results', ['id'])
|
|
op.create_index('ix_account_schedule_results_log_id', 'account_schedule_results', ['schedule_log_id'])
|
|
op.create_index('ix_account_schedule_results_account_id', 'account_schedule_results', ['account_id'])
|
|
|
|
# ── servers ──────────────────────────────────────────────
|
|
op.create_table(
|
|
'servers',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('name', sa.String(100), nullable=False),
|
|
sa.Column('ip_address', sa.String(50), nullable=False),
|
|
sa.Column('description', sa.String(200)),
|
|
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
|
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
sa.UniqueConstraint('ip_address', name='uq_servers_ip'),
|
|
)
|
|
op.create_index('ix_servers_id', 'servers', ['id'])
|
|
|
|
# ── server_status_logs ───────────────────────────────────
|
|
op.create_table(
|
|
'server_status_logs',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('schedule_log_id', sa.Integer(), sa.ForeignKey('schedule_logs.id'), nullable=False),
|
|
sa.Column('server_id', sa.Integer(), sa.ForeignKey('servers.id'), nullable=False),
|
|
sa.Column('result', sa.Boolean(), nullable=False),
|
|
sa.Column('response_time', sa.Float()),
|
|
sa.Column('fail_reason', sa.Text()),
|
|
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
)
|
|
op.create_index('ix_server_status_logs_id', 'server_status_logs', ['id'])
|
|
op.create_index('ix_server_status_logs_schedule_log_id', 'server_status_logs', ['schedule_log_id'])
|
|
op.create_index('ix_server_status_logs_server_id', 'server_status_logs', ['server_id'])
|
|
|
|
# ── system_status_logs ───────────────────────────────────
|
|
op.create_table(
|
|
'system_status_logs',
|
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column('schedule_log_id', sa.Integer(), sa.ForeignKey('schedule_logs.id'), nullable=False),
|
|
sa.Column('environment', sa.String(10), nullable=False),
|
|
sa.Column('service_name', sa.String(50), nullable=False),
|
|
sa.Column('service_desc', sa.String(100)),
|
|
sa.Column('result', sa.Boolean(), nullable=False),
|
|
sa.Column('fail_reason', sa.Text()),
|
|
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
|
)
|
|
op.create_index('ix_system_status_logs_id', 'system_status_logs', ['id'])
|
|
op.create_index('ix_system_status_logs_schedule_log_id', 'system_status_logs', ['schedule_log_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_table('system_status_logs')
|
|
op.drop_table('server_status_logs')
|
|
op.drop_table('servers')
|
|
op.drop_table('account_schedule_results')
|
|
op.drop_table('tenant_schedule_results')
|
|
op.drop_table('schedule_logs')
|
|
op.drop_table('schedules')
|
|
op.drop_table('accounts')
|
|
op.drop_table('tenants')
|