Files
hr-portal/backend/app/models/installation.py
Porsche Chen 360533393f feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 20:12:43 +08:00

363 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Installation System Models
初始化系統資料模型
"""
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Boolean, Text, TIMESTAMP, ForeignKey, ARRAY
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from app.db.base import Base
class InstallationSession(Base):
"""安裝會話"""
__tablename__ = "installation_sessions"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
session_name = Column(String(200))
environment = Column(String(20)) # development/testing/production
# 狀態追蹤
started_at = Column(TIMESTAMP, default=datetime.now)
completed_at = Column(TIMESTAMP)
status = Column(String(20), default='in_progress') # in_progress/completed/failed/paused
# 進度統計
total_checklist_items = Column(Integer)
passed_checklist_items = Column(Integer, default=0)
failed_checklist_items = Column(Integer, default=0)
total_steps = Column(Integer)
completed_steps = Column(Integer, default=0)
failed_steps = Column(Integer, default=0)
executed_by = Column(String(100))
# 存取控制
is_locked = Column(Boolean, default=False)
locked_at = Column(TIMESTAMP)
locked_by = Column(String(100))
lock_reason = Column(String(200))
is_unlocked = Column(Boolean, default=False)
unlocked_at = Column(TIMESTAMP)
unlocked_by = Column(String(100))
unlock_reason = Column(String(200))
unlock_expires_at = Column(TIMESTAMP)
last_viewed_at = Column(TIMESTAMP)
last_viewed_by = Column(String(100))
view_count = Column(Integer, default=0)
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
tenant_info = relationship("InstallationTenantInfo", back_populates="session", uselist=False)
department_setups = relationship("InstallationDepartmentSetup", back_populates="session")
temporary_passwords = relationship("TemporaryPassword", back_populates="session")
access_logs = relationship("InstallationAccessLog", back_populates="session")
checklist_results = relationship("InstallationChecklistResult", back_populates="session")
installation_logs = relationship("InstallationLog", back_populates="session")
class InstallationChecklistItem(Base):
"""檢查項目定義(系統級)"""
__tablename__ = "installation_checklist_items"
id = Column(Integer, primary_key=True, index=True)
category = Column(String(50), nullable=False) # hardware/network/software/container/security
item_code = Column(String(100), unique=True, nullable=False)
item_name = Column(String(200), nullable=False)
check_type = Column(String(50), nullable=False) # command/api/config/manual
check_command = Column(Text) # 自動檢查命令
expected_value = Column(Text)
min_requirement = Column(Text)
recommended_value = Column(Text)
is_required = Column(Boolean, default=True)
sequence_order = Column(Integer, nullable=False)
description = Column(Text)
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
results = relationship("InstallationChecklistResult", back_populates="checklist_item")
class InstallationChecklistResult(Base):
"""檢查結果(租戶級)"""
__tablename__ = "installation_checklist_results"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
checklist_item_id = Column(Integer, ForeignKey("installation_checklist_items.id", ondelete="CASCADE"), nullable=False)
status = Column(String(20), nullable=False) # pass/fail/warning/pending/skip
actual_value = Column(Text)
checked_at = Column(TIMESTAMP)
checked_by = Column(String(100))
auto_checked = Column(Boolean, default=False)
remarks = Column(Text)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
session = relationship("InstallationSession", back_populates="checklist_results")
checklist_item = relationship("InstallationChecklistItem", back_populates="results")
class InstallationStep(Base):
"""安裝步驟定義(系統級)"""
__tablename__ = "installation_steps"
id = Column(Integer, primary_key=True, index=True)
step_code = Column(String(50), unique=True, nullable=False)
step_name = Column(String(200), nullable=False)
phase = Column(String(20), nullable=False) # phase1/phase2/...
sequence_order = Column(Integer, nullable=False)
description = Column(Text)
execution_type = Column(String(50)) # auto/manual/script
execution_script = Column(Text)
depends_on_steps = Column(ARRAY(String)) # 依賴的步驟代碼
is_required = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
logs = relationship("InstallationLog", back_populates="step")
class InstallationLog(Base):
"""安裝執行記錄(租戶級)"""
__tablename__ = "installation_logs"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
step_id = Column(Integer, ForeignKey("installation_steps.id", ondelete="CASCADE"), nullable=False)
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
status = Column(String(20), nullable=False) # pending/running/success/failed/skipped
started_at = Column(TIMESTAMP)
completed_at = Column(TIMESTAMP)
executed_by = Column(String(100))
execution_method = Column(String(50)) # manual/auto/api/script
result_data = Column(JSONB)
error_message = Column(Text)
retry_count = Column(Integer, default=0)
remarks = Column(Text)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
step = relationship("InstallationStep", back_populates="logs")
session = relationship("InstallationSession", back_populates="installation_logs")
class InstallationTenantInfo(Base):
"""租戶初始化資訊"""
__tablename__ = "installation_tenant_info"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True, unique=True) # 不設外鍵,初始化時 tenants 表可能不存在
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
# 公司基本資訊
company_name = Column(String(200))
company_name_en = Column(String(200))
tenant_code = Column(String(50)) # 租戶代碼 = Keycloak Realm
tenant_prefix = Column(String(10)) # 員工編號前綴
tax_id = Column(String(50))
industry = Column(String(100))
company_size = Column(String(20)) # small/medium/large
# 聯絡資訊
tel = Column(String(20)) # 公司電話(對應 tenants.tel
phone = Column(String(50))
fax = Column(String(50))
email = Column(String(200))
website = Column(String(200))
add = Column(Text) # 公司地址(對應 tenants.add
address = Column(Text)
address_en = Column(Text)
# 郵件網域設定
domain_set = Column(Integer, default=2) # 1=組織網域, 2=部門網域
domain = Column(String(100)) # 組織網域domain_set=1 時使用)
# 負責人資訊
representative_name = Column(String(100))
representative_title = Column(String(100))
representative_email = Column(String(200))
representative_phone = Column(String(50))
# 系統管理員資訊
admin_employee_id = Column(String(50))
admin_username = Column(String(100))
admin_legal_name = Column(String(100))
admin_english_name = Column(String(100))
admin_email = Column(String(200))
admin_phone = Column(String(50))
# 初始設定
default_language = Column(String(10), default='zh-TW')
timezone = Column(String(50), default='Asia/Taipei')
date_format = Column(String(20), default='YYYY-MM-DD')
currency = Column(String(10), default='TWD')
# 狀態追蹤
is_completed = Column(Boolean, default=False)
completed_at = Column(TIMESTAMP)
completed_by = Column(String(100))
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
session = relationship("InstallationSession", back_populates="tenant_info")
class InstallationDepartmentSetup(Base):
"""部門架構設定"""
__tablename__ = "installation_department_setup"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
department_code = Column(String(50), nullable=False)
department_name = Column(String(200), nullable=False)
department_name_en = Column(String(200))
email_domain = Column(String(100))
parent_code = Column(String(50))
depth = Column(Integer, default=0)
manager_name = Column(String(100))
is_created = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
session = relationship("InstallationSession", back_populates="department_setups")
class TemporaryPassword(Base):
"""臨時密碼"""
__tablename__ = "temporary_passwords"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
employee_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 employees 表可能不存在
username = Column(String(100), nullable=False)
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
# 密碼資訊
password_hash = Column(String(255), nullable=False)
plain_password = Column(String(100)) # 明文密碼(僅初始化階段)
password_method = Column(String(20)) # auto/manual
is_temporary = Column(Boolean, default=True)
must_change_on_login = Column(Boolean, default=True)
# 有效期限
created_at = Column(TIMESTAMP, default=datetime.now)
expires_at = Column(TIMESTAMP)
# 使用狀態
is_used = Column(Boolean, default=False)
used_at = Column(TIMESTAMP)
first_login_at = Column(TIMESTAMP)
password_changed_at = Column(TIMESTAMP)
# 查看控制
is_viewable = Column(Boolean, default=True)
viewable_until = Column(TIMESTAMP)
view_count = Column(Integer, default=0)
last_viewed_at = Column(TIMESTAMP)
first_viewed_at = Column(TIMESTAMP)
# 明文密碼清除記錄
plain_password_cleared_at = Column(TIMESTAMP)
cleared_reason = Column(String(100))
# Relationships
# 不定義 tenant 和 employee relationship因為沒有 FK constraint
session = relationship("InstallationSession", back_populates="temporary_passwords")
class InstallationAccessLog(Base):
"""存取審計日誌"""
__tablename__ = "installation_access_logs"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"), nullable=False)
action = Column(String(50), nullable=False) # lock/unlock/view/download_pdf
action_by = Column(String(100))
action_method = Column(String(50)) # database/api/system
ip_address = Column(String(50))
user_agent = Column(Text)
access_granted = Column(Boolean)
deny_reason = Column(String(200))
sensitive_data_accessed = Column(ARRAY(String))
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
session = relationship("InstallationSession", back_populates="access_logs")
class InstallationEnvironmentConfig(Base):
"""環境配置記錄"""
__tablename__ = "installation_environment_config"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
config_key = Column(String(100), unique=True, nullable=False, index=True)
config_value = Column(Text)
config_category = Column(String(50), nullable=False, index=True) # redis/database/keycloak/mailserver/nextcloud/traefik
is_sensitive = Column(Boolean, default=False) # 是否為敏感資訊(密碼等)
is_configured = Column(Boolean, default=False)
configured_at = Column(TIMESTAMP)
configured_by = Column(String(100))
description = Column(Text)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
# Relationships
session = relationship("InstallationSession")
class InstallationSystemStatus(Base):
"""系統狀態記錄三階段Initialization/Operational/Transition"""
__tablename__ = "installation_system_status"
id = Column(Integer, primary_key=True, index=True)
current_phase = Column(String(20), nullable=False, index=True) # initialization/operational/transition
previous_phase = Column(String(20))
phase_changed_at = Column(TIMESTAMP)
phase_changed_by = Column(String(100))
phase_change_reason = Column(Text)
# Initialization 階段資訊
initialized_at = Column(TIMESTAMP)
initialized_by = Column(String(100))
initialization_completed = Column(Boolean, default=False)
# Operational 階段資訊
last_health_check_at = Column(TIMESTAMP)
health_check_status = Column(String(20)) # healthy/degraded/unhealthy
operational_since = Column(TIMESTAMP)
# Transition 階段資訊
transition_started_at = Column(TIMESTAMP)
transition_approved_by = Column(String(100))
env_db_consistent = Column(Boolean)
consistency_checked_at = Column(TIMESTAMP)
inconsistencies = Column(Text) # JSON 格式
# 系統鎖定
is_locked = Column(Boolean, default=False)
locked_at = Column(TIMESTAMP)
locked_by = Column(String(100))
lock_reason = Column(String(200))
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)