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>
This commit is contained in:
66
backend/.env.example
Normal file
66
backend/.env.example
Normal file
@@ -0,0 +1,66 @@
|
||||
# ============================================================================
|
||||
# HR Portal Backend 環境變數配置
|
||||
# 複製此文件為 .env 並填入實際值
|
||||
# ============================================================================
|
||||
|
||||
# 基本資訊
|
||||
PROJECT_NAME="HR Portal API"
|
||||
VERSION="2.0.0"
|
||||
ENVIRONMENT="development" # development, staging, production
|
||||
HOST="0.0.0.0"
|
||||
PORT=8000
|
||||
|
||||
# 資料庫配置
|
||||
DATABASE_URL="postgresql://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal"
|
||||
DATABASE_ECHO=False
|
||||
|
||||
# CORS 配置 (多個來源用逗號分隔)
|
||||
ALLOWED_ORIGINS="http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei"
|
||||
|
||||
# Keycloak 配置
|
||||
KEYCLOAK_URL="https://auth.ease.taipei"
|
||||
KEYCLOAK_REALM="porscheworld"
|
||||
KEYCLOAK_CLIENT_ID="hr-backend"
|
||||
KEYCLOAK_CLIENT_SECRET="your-client-secret-here"
|
||||
KEYCLOAK_ADMIN_USERNAME="admin"
|
||||
KEYCLOAK_ADMIN_PASSWORD="your-admin-password"
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET_KEY="your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM="HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# 郵件配置 (Docker Mailserver)
|
||||
MAIL_SERVER="10.1.0.30"
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=True
|
||||
MAIL_ADMIN_USER="admin@porscheworld.tw"
|
||||
MAIL_ADMIN_PASSWORD="your-mail-admin-password"
|
||||
|
||||
# NAS 配置 (Synology DS920+)
|
||||
NAS_HOST="10.1.0.30"
|
||||
NAS_PORT=5000
|
||||
NAS_USERNAME="your-nas-username"
|
||||
NAS_PASSWORD="your-nas-password"
|
||||
NAS_WEBDAV_URL="https://nas.lab.taipei/webdav"
|
||||
NAS_SMB_SHARE="Working"
|
||||
|
||||
# 日誌配置
|
||||
LOG_LEVEL="INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
LOG_FILE="logs/hr_portal.log"
|
||||
|
||||
# 分頁配置
|
||||
DEFAULT_PAGE_SIZE=20
|
||||
MAX_PAGE_SIZE=100
|
||||
|
||||
# 郵件配額 (MB)
|
||||
EMAIL_QUOTA_JUNIOR=1000
|
||||
EMAIL_QUOTA_MID=2000
|
||||
EMAIL_QUOTA_SENIOR=5000
|
||||
EMAIL_QUOTA_MANAGER=10000
|
||||
|
||||
# NAS 配額 (GB)
|
||||
NAS_QUOTA_JUNIOR=50
|
||||
NAS_QUOTA_MID=100
|
||||
NAS_QUOTA_SENIOR=200
|
||||
NAS_QUOTA_MANAGER=500
|
||||
59
backend/.gitignore
vendored
Normal file
59
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment Variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Alembic
|
||||
alembic/versions/*.pyc
|
||||
59
backend/Dockerfile
Normal file
59
backend/Dockerfile
Normal file
@@ -0,0 +1,59 @@
|
||||
# ============================================================================
|
||||
# HR Portal Backend Dockerfile
|
||||
# FastAPI + Python 3.11 + PostgreSQL
|
||||
# ============================================================================
|
||||
|
||||
FROM python:3.11-slim as base
|
||||
|
||||
# 設定環境變數
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# 設定工作目錄
|
||||
WORKDIR /app
|
||||
|
||||
# 安裝系統依賴
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ============================================================================
|
||||
# Builder Stage - 安裝 Python 依賴
|
||||
# ============================================================================
|
||||
FROM base as builder
|
||||
|
||||
# 複製需求檔案
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安裝 Python 依賴
|
||||
RUN pip install --user --no-warn-script-location -r requirements.txt
|
||||
|
||||
# ============================================================================
|
||||
# Runtime Stage - 最終映像
|
||||
# ============================================================================
|
||||
FROM base
|
||||
|
||||
# 從 builder 複製已安裝的套件
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
|
||||
# 確保 scripts 在 PATH 中
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
|
||||
# 複製應用程式碼
|
||||
COPY . /app
|
||||
|
||||
# 創建日誌目錄
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 健康檢查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5.0)" || exit 1
|
||||
|
||||
# 啟動命令
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
27
backend/Dockerfile.dev
Normal file
27
backend/Dockerfile.dev
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安裝系統依賴
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 複製需求檔案
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安裝 Python 依賴
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 複製應用代碼
|
||||
COPY . .
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 10181
|
||||
|
||||
# 啟動命令 (開發模式)
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "10181"]
|
||||
117
backend/alembic.ini
Normal file
117
backend/alembic.ini
Normal file
@@ -0,0 +1,117 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# sqlalchemy.url will be set from app config in env.py
|
||||
# sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
92
backend/alembic/env.py
Normal file
92
backend/alembic/env.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from logging.config import fileConfig
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add the parent directory to sys.path to import app modules
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Import settings and Base
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
|
||||
# Import all models for Alembic to detect
|
||||
# 使用統一的 models import,自動包含所有 Models
|
||||
from app.models import * # noqa: F403, F401
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Set the SQLAlchemy URL from settings
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
114
backend/alembic/versions/0001_5_add_tenants_table.py
Normal file
114
backend/alembic/versions/0001_5_add_tenants_table.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""add tenants table for multi-tenant support
|
||||
|
||||
Revision ID: 0001_5
|
||||
Revises: fba4e3f40f05
|
||||
Create Date: 2026-02-15 19:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0001_5'
|
||||
down_revision: Union[str, None] = 'fba4e3f40f05'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 創建 tenants 表
|
||||
op.create_table(
|
||||
'tenants',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('code', sa.String(length=50), nullable=False, comment='租戶代碼 (唯一)'),
|
||||
sa.Column('name', sa.String(length=200), nullable=False, comment='租戶名稱'),
|
||||
sa.Column('status', sa.String(length=20), nullable=False, server_default='active', comment='狀態'),
|
||||
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.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# 創建索引
|
||||
op.create_index('idx_tenants_code', 'tenants', ['code'], unique=True)
|
||||
op.create_index('idx_tenants_status', 'tenants', ['status'])
|
||||
|
||||
# 添加預設租戶 (Porsche World)
|
||||
# 注意: keycloak_realm 欄位在 0005 migration 才加入,這裡先不設定
|
||||
op.execute("""
|
||||
INSERT INTO tenants (code, name, status)
|
||||
VALUES ('porscheworld', 'Porsche World', 'active')
|
||||
""")
|
||||
|
||||
# 為現有表添加 tenant_id 欄位
|
||||
op.add_column('employees', sa.Column('tenant_id', sa.Integer(), nullable=True))
|
||||
op.add_column('business_units', sa.Column('tenant_id', sa.Integer(), nullable=True))
|
||||
op.add_column('departments', sa.Column('tenant_id', sa.Integer(), nullable=True))
|
||||
op.add_column('employee_identities', sa.Column('tenant_id', sa.Integer(), nullable=True))
|
||||
op.add_column('network_drives', sa.Column('tenant_id', sa.Integer(), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('tenant_id', sa.Integer(), nullable=True))
|
||||
|
||||
# 將所有現有記錄設定為預設租戶
|
||||
op.execute("UPDATE employees SET tenant_id = 1 WHERE tenant_id IS NULL")
|
||||
op.execute("UPDATE business_units SET tenant_id = 1 WHERE tenant_id IS NULL")
|
||||
op.execute("UPDATE departments SET tenant_id = 1 WHERE tenant_id IS NULL")
|
||||
op.execute("UPDATE employee_identities SET tenant_id = 1 WHERE tenant_id IS NULL")
|
||||
op.execute("UPDATE network_drives SET tenant_id = 1 WHERE tenant_id IS NULL")
|
||||
op.execute("UPDATE audit_logs SET tenant_id = 1 WHERE tenant_id IS NULL")
|
||||
|
||||
# 將 tenant_id 設為 NOT NULL
|
||||
op.alter_column('employees', 'tenant_id', nullable=False)
|
||||
op.alter_column('business_units', 'tenant_id', nullable=False)
|
||||
op.alter_column('departments', 'tenant_id', nullable=False)
|
||||
op.alter_column('employee_identities', 'tenant_id', nullable=False)
|
||||
op.alter_column('network_drives', 'tenant_id', nullable=False)
|
||||
op.alter_column('audit_logs', 'tenant_id', nullable=False)
|
||||
|
||||
# 添加外鍵約束
|
||||
op.create_foreign_key('fk_employees_tenant', 'employees', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('fk_business_units_tenant', 'business_units', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('fk_departments_tenant', 'departments', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('fk_employee_identities_tenant', 'employee_identities', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('fk_network_drives_tenant', 'network_drives', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('fk_audit_logs_tenant', 'audit_logs', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
# 添加索引
|
||||
op.create_index('idx_employees_tenant', 'employees', ['tenant_id'])
|
||||
op.create_index('idx_business_units_tenant', 'business_units', ['tenant_id'])
|
||||
op.create_index('idx_departments_tenant', 'departments', ['tenant_id'])
|
||||
op.create_index('idx_employee_identities_tenant', 'employee_identities', ['tenant_id'])
|
||||
op.create_index('idx_network_drives_tenant', 'network_drives', ['tenant_id'])
|
||||
op.create_index('idx_audit_logs_tenant', 'audit_logs', ['tenant_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 移除索引
|
||||
op.drop_index('idx_audit_logs_tenant', table_name='audit_logs')
|
||||
op.drop_index('idx_network_drives_tenant', table_name='network_drives')
|
||||
op.drop_index('idx_employee_identities_tenant', table_name='employee_identities')
|
||||
op.drop_index('idx_departments_tenant', table_name='departments')
|
||||
op.drop_index('idx_business_units_tenant', table_name='business_units')
|
||||
op.drop_index('idx_employees_tenant', table_name='employees')
|
||||
|
||||
# 移除外鍵
|
||||
op.drop_constraint('fk_audit_logs_tenant', 'audit_logs', type_='foreignkey')
|
||||
op.drop_constraint('fk_network_drives_tenant', 'network_drives', type_='foreignkey')
|
||||
op.drop_constraint('fk_employee_identities_tenant', 'employee_identities', type_='foreignkey')
|
||||
op.drop_constraint('fk_departments_tenant', 'departments', type_='foreignkey')
|
||||
op.drop_constraint('fk_business_units_tenant', 'business_units', type_='foreignkey')
|
||||
op.drop_constraint('fk_employees_tenant', 'employees', type_='foreignkey')
|
||||
|
||||
# 移除 tenant_id 欄位
|
||||
op.drop_column('audit_logs', 'tenant_id')
|
||||
op.drop_column('network_drives', 'tenant_id')
|
||||
op.drop_column('employee_identities', 'tenant_id')
|
||||
op.drop_column('departments', 'tenant_id')
|
||||
op.drop_column('business_units', 'tenant_id')
|
||||
op.drop_column('employees', 'tenant_id')
|
||||
|
||||
# 移除 tenants 表
|
||||
op.drop_index('idx_tenants_status', table_name='tenants')
|
||||
op.drop_index('idx_tenants_code', table_name='tenants')
|
||||
op.drop_table('tenants')
|
||||
@@ -0,0 +1,81 @@
|
||||
"""add email_accounts and permissions tables
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: fba4e3f40f05
|
||||
Create Date: 2026-02-15 18:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0002'
|
||||
down_revision: Union[str, None] = '0001_5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 創建 email_accounts 表
|
||||
op.create_table(
|
||||
'email_accounts',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
|
||||
sa.Column('employee_id', sa.Integer(), nullable=False, comment='員工 ID'),
|
||||
sa.Column('email_address', sa.String(length=255), nullable=False, comment='郵件地址'),
|
||||
sa.Column('quota_mb', sa.Integer(), nullable=False, server_default='2048', comment='配額 (MB)'),
|
||||
sa.Column('forward_to', sa.String(length=255), nullable=True, comment='轉寄地址'),
|
||||
sa.Column('auto_reply', sa.Text(), nullable=True, comment='自動回覆內容'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'),
|
||||
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.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
|
||||
)
|
||||
|
||||
# 創建索引
|
||||
op.create_index('idx_email_accounts_email', 'email_accounts', ['email_address'], unique=True)
|
||||
op.create_index('idx_email_accounts_employee', 'email_accounts', ['employee_id'])
|
||||
op.create_index('idx_email_accounts_tenant', 'email_accounts', ['tenant_id'])
|
||||
op.create_index('idx_email_accounts_active', 'email_accounts', ['is_active'])
|
||||
|
||||
# 創建 permissions 表
|
||||
op.create_table(
|
||||
'permissions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
|
||||
sa.Column('employee_id', sa.Integer(), nullable=False, comment='員工 ID'),
|
||||
sa.Column('system_name', sa.String(length=100), nullable=False, comment='系統名稱'),
|
||||
sa.Column('access_level', sa.String(length=50), nullable=False, server_default='user', comment='存取層級'),
|
||||
sa.Column('granted_at', sa.DateTime(), nullable=False, server_default=sa.text('now()'), comment='授予時間'),
|
||||
sa.Column('granted_by', sa.Integer(), nullable=True, comment='授予人'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['granted_by'], ['employees.id'], ondelete='SET NULL'),
|
||||
sa.UniqueConstraint('employee_id', 'system_name', name='uq_employee_system'),
|
||||
)
|
||||
|
||||
# 創建索引
|
||||
op.create_index('idx_permissions_employee', 'permissions', ['employee_id'])
|
||||
op.create_index('idx_permissions_tenant', 'permissions', ['tenant_id'])
|
||||
op.create_index('idx_permissions_system', 'permissions', ['system_name'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 刪除 permissions 表
|
||||
op.drop_index('idx_permissions_system', table_name='permissions')
|
||||
op.drop_index('idx_permissions_tenant', table_name='permissions')
|
||||
op.drop_index('idx_permissions_employee', table_name='permissions')
|
||||
op.drop_table('permissions')
|
||||
|
||||
# 刪除 email_accounts 表
|
||||
op.drop_index('idx_email_accounts_active', table_name='email_accounts')
|
||||
op.drop_index('idx_email_accounts_tenant', table_name='email_accounts')
|
||||
op.drop_index('idx_email_accounts_employee', table_name='email_accounts')
|
||||
op.drop_index('idx_email_accounts_email', table_name='email_accounts')
|
||||
op.drop_table('email_accounts')
|
||||
141
backend/alembic/versions/0003_extend_organization_structure.py
Normal file
141
backend/alembic/versions/0003_extend_organization_structure.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""extend organization structure
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-02-15 15:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0003'
|
||||
down_revision: Union[str, None] = '0002'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 擴充 business_units 表
|
||||
op.add_column('business_units', sa.Column('primary_domain', sa.String(100), nullable=True))
|
||||
op.add_column('business_units', sa.Column('email_address', sa.String(255), nullable=True))
|
||||
op.add_column('business_units', sa.Column('email_quota_mb', sa.Integer(), server_default='10240', nullable=False))
|
||||
|
||||
# 擴充 departments 表
|
||||
op.add_column('departments', sa.Column('email_address', sa.String(255), nullable=True))
|
||||
op.add_column('departments', sa.Column('email_quota_mb', sa.Integer(), server_default='5120', nullable=False))
|
||||
|
||||
# 擴充 email_accounts 表 (支援組織/事業部/部門信箱)
|
||||
op.add_column('email_accounts', sa.Column('account_type', sa.String(20), server_default='personal', nullable=False))
|
||||
op.add_column('email_accounts', sa.Column('department_id', sa.Integer(), nullable=True))
|
||||
op.add_column('email_accounts', sa.Column('business_unit_id', sa.Integer(), nullable=True))
|
||||
|
||||
# 添加外鍵約束
|
||||
op.create_foreign_key('fk_email_accounts_department', 'email_accounts', 'departments', ['department_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('fk_email_accounts_business_unit', 'email_accounts', 'business_units', ['business_unit_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
# 更新現有的 business_units 資料 (三大事業部)
|
||||
op.execute("""
|
||||
UPDATE business_units SET
|
||||
primary_domain = 'ease.taipei',
|
||||
email_address = 'business@ease.taipei'
|
||||
WHERE name = '業務發展部' OR code = 'BD'
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE business_units SET
|
||||
primary_domain = 'lab.taipei',
|
||||
email_address = 'tech@lab.taipei'
|
||||
WHERE name = '技術發展部' OR code = 'TD'
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
UPDATE business_units SET
|
||||
primary_domain = 'porscheworld.tw',
|
||||
email_address = 'operations@porscheworld.tw'
|
||||
WHERE name = '營運管理部' OR code = 'OM'
|
||||
""")
|
||||
|
||||
# 插入初始部門資料
|
||||
# 業務發展部 (假設 id=1)
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
|
||||
SELECT 1, id, '玄鐵風能', 'WIND', 'wind@ease.taipei', 5120, NOW()
|
||||
FROM business_units WHERE name = '業務發展部' LIMIT 1
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
|
||||
SELECT 1, id, '虛擬公司', 'VIRTUAL', 'virtual@ease.taipei', 5120, NOW()
|
||||
FROM business_units WHERE name = '業務發展部' LIMIT 1
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
|
||||
SELECT 1, id, '國際碳權', 'CARBON', 'carbon@ease.taipei', 5120, NOW()
|
||||
FROM business_units WHERE name = '業務發展部' LIMIT 1
|
||||
""")
|
||||
|
||||
# 技術發展部 (假設 id=2)
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
|
||||
SELECT 1, id, '智能研發', 'AI', 'ai@lab.taipei', 5120, NOW()
|
||||
FROM business_units WHERE name = '技術發展部' LIMIT 1
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
|
||||
SELECT 1, id, '軟體開發', 'DEV', 'dev@lab.taipei', 5120, NOW()
|
||||
FROM business_units WHERE name = '技術發展部' LIMIT 1
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
|
||||
SELECT 1, id, '虛擬MIS', 'MIS', 'mis@lab.taipei', 5120, NOW()
|
||||
FROM business_units WHERE name = '技術發展部' LIMIT 1
|
||||
""")
|
||||
|
||||
# 營運管理部 (假設 id=3)
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
|
||||
SELECT 1, id, '人資', 'HR', 'hr@porscheworld.tw', 5120, NOW()
|
||||
FROM business_units WHERE name = '營運管理部' LIMIT 1
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
|
||||
SELECT 1, id, '財務', 'FIN', 'finance@porscheworld.tw', 5120, NOW()
|
||||
FROM business_units WHERE name = '營運管理部' LIMIT 1
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
|
||||
SELECT 1, id, '總務', 'ADMIN', 'admin@porscheworld.tw', 5120, NOW()
|
||||
FROM business_units WHERE name = '營運管理部' LIMIT 1
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 移除外鍵約束
|
||||
op.drop_constraint('fk_email_accounts_business_unit', 'email_accounts', type_='foreignkey')
|
||||
op.drop_constraint('fk_email_accounts_department', 'email_accounts', type_='foreignkey')
|
||||
|
||||
# 移除 email_accounts 擴充欄位
|
||||
op.drop_column('email_accounts', 'business_unit_id')
|
||||
op.drop_column('email_accounts', 'department_id')
|
||||
op.drop_column('email_accounts', 'account_type')
|
||||
|
||||
# 移除 departments 擴充欄位
|
||||
op.drop_column('departments', 'email_quota_mb')
|
||||
op.drop_column('departments', 'email_address')
|
||||
|
||||
# 移除 business_units 擴充欄位
|
||||
op.drop_column('business_units', 'email_quota_mb')
|
||||
op.drop_column('business_units', 'email_address')
|
||||
op.drop_column('business_units', 'primary_domain')
|
||||
|
||||
# 刪除部門資料 (downgrade 時)
|
||||
op.execute("DELETE FROM departments WHERE tenant_id = 1")
|
||||
42
backend/alembic/versions/0004_add_batch_logs_table.py
Normal file
42
backend/alembic/versions/0004_add_batch_logs_table.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""add batch_logs table
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-02-18 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0004'
|
||||
down_revision: Union[str, None] = '0003'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'batch_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('batch_name', sa.String(length=100), nullable=False, comment='批次名稱'),
|
||||
sa.Column('status', sa.String(length=20), nullable=False, comment='執行狀態: success/failed/warning'),
|
||||
sa.Column('message', sa.Text(), nullable=True, comment='執行訊息或錯誤詳情'),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=False, comment='開始時間'),
|
||||
sa.Column('finished_at', sa.DateTime(), nullable=True, comment='完成時間'),
|
||||
sa.Column('duration_seconds', sa.Integer(), nullable=True, comment='執行時間 (秒)'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_batch_logs_id', 'batch_logs', ['id'], unique=False)
|
||||
op.create_index('ix_batch_logs_batch_name', 'batch_logs', ['batch_name'], unique=False)
|
||||
op.create_index('ix_batch_logs_started_at', 'batch_logs', ['started_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_batch_logs_started_at', table_name='batch_logs')
|
||||
op.drop_index('ix_batch_logs_batch_name', table_name='batch_logs')
|
||||
op.drop_index('ix_batch_logs_id', table_name='batch_logs')
|
||||
op.drop_table('batch_logs')
|
||||
343
backend/alembic/versions/0005_multi_tenant_refactor.py
Normal file
343
backend/alembic/versions/0005_multi_tenant_refactor.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""multi-tenant architecture refactor
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-02-19 00:00:00.000000
|
||||
|
||||
重構內容:
|
||||
1. departments 改為統一樹狀結構 (新增 parent_id, email_domain, depth)
|
||||
2. business_units 資料遷移為 departments 第一層節點,廢棄 business_units 表
|
||||
3. 新增 department_members 表 (員工多部門歸屬)
|
||||
4. employee_identities 資料遷移至 department_members,標記廢棄
|
||||
5. employees 新增 keycloak_user_id (唯一 SSO 識別碼)
|
||||
6. tenants 新增 keycloak_realm (= tenant_code)
|
||||
7. 新增 RBAC: system_functions_cache, roles, role_rights, user_role_assignments
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '0005'
|
||||
down_revision: Union[str, None] = '0004'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# =========================================================
|
||||
# A-1: 修改 departments 表 - 新增樹狀結構欄位
|
||||
# =========================================================
|
||||
op.add_column('departments', sa.Column('parent_id', sa.Integer(), nullable=True))
|
||||
op.add_column('departments', sa.Column('email_domain', sa.String(100), nullable=True))
|
||||
op.add_column('departments', sa.Column('depth', sa.Integer(), server_default='1', nullable=False))
|
||||
|
||||
# 新增自我參照外鍵 (parent_id → departments.id)
|
||||
op.create_foreign_key(
|
||||
'fk_departments_parent',
|
||||
'departments', 'departments',
|
||||
['parent_id'], ['id'],
|
||||
ondelete='CASCADE'
|
||||
)
|
||||
|
||||
# 新增索引
|
||||
op.create_index('idx_departments_parent', 'departments', ['parent_id'])
|
||||
op.create_index('idx_departments_depth', 'departments', ['depth'])
|
||||
|
||||
# =========================================================
|
||||
# A-2: 將 business_units 資料遷移為 departments 第一層節點
|
||||
# =========================================================
|
||||
|
||||
# 先將 business_unit_id 改為 nullable (新插入的第一層節點不需要此欄位)
|
||||
op.alter_column('departments', 'business_unit_id', nullable=True)
|
||||
|
||||
# 先將現有 departments 的 depth 設為 1 (它們都是原本的子部門)
|
||||
op.execute("UPDATE departments SET depth = 1")
|
||||
|
||||
# 將 business_units 插入 departments 為第一層節點 (depth=0, parent_id=NULL)
|
||||
op.execute("""
|
||||
INSERT INTO departments (tenant_id, code, name, email_domain, parent_id, depth, is_active, created_at, email_address, email_quota_mb)
|
||||
SELECT
|
||||
bu.tenant_id,
|
||||
bu.code,
|
||||
bu.name,
|
||||
bu.email_domain,
|
||||
NULL,
|
||||
0,
|
||||
bu.is_active,
|
||||
bu.created_at,
|
||||
bu.email_address,
|
||||
bu.email_quota_mb
|
||||
FROM business_units bu
|
||||
""")
|
||||
|
||||
# 更新原有子部門,parent_id 指向剛插入的第一層節點
|
||||
op.execute("""
|
||||
UPDATE departments AS d
|
||||
SET parent_id = top.id
|
||||
FROM departments AS top
|
||||
JOIN business_units bu ON bu.code = top.code AND bu.tenant_id = top.tenant_id
|
||||
WHERE d.business_unit_id = bu.id
|
||||
AND top.depth = 0
|
||||
AND d.depth = 1
|
||||
""")
|
||||
|
||||
# 更新 unique constraint (移除舊的,建立新的)
|
||||
op.drop_constraint('uq_department_bu_code', 'departments', type_='unique')
|
||||
op.create_unique_constraint(
|
||||
'uq_tenant_parent_dept_code',
|
||||
'departments',
|
||||
['tenant_id', 'parent_id', 'code']
|
||||
)
|
||||
|
||||
# 移除 departments.business_unit_id 外鍵和欄位
|
||||
op.drop_constraint('departments_business_unit_id_fkey', 'departments', type_='foreignkey')
|
||||
op.drop_index('ix_departments_business_unit_id', table_name='departments')
|
||||
op.drop_column('departments', 'business_unit_id')
|
||||
|
||||
# 重建 tenant 索引 (原名 idx_departments_tenant 已存在,建立新名稱)
|
||||
op.create_index('idx_dept_tenant_id', 'departments', ['tenant_id'])
|
||||
|
||||
# =========================================================
|
||||
# A-3: 新增 department_members 表
|
||||
# =========================================================
|
||||
op.create_table(
|
||||
'department_members',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
|
||||
sa.Column('employee_id', sa.Integer(), nullable=False, comment='員工 ID'),
|
||||
sa.Column('department_id', sa.Integer(), nullable=False, comment='部門 ID'),
|
||||
sa.Column('position', sa.String(100), nullable=True, comment='在該部門的職稱'),
|
||||
sa.Column('membership_type', sa.String(50), server_default='permanent', nullable=False,
|
||||
comment='成員類型: permanent/temporary/project'),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.Column('joined_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('ended_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('employee_id', 'department_id', name='uq_employee_department'),
|
||||
)
|
||||
op.create_index('idx_dept_members_tenant', 'department_members', ['tenant_id'])
|
||||
op.create_index('idx_dept_members_employee', 'department_members', ['employee_id'])
|
||||
op.create_index('idx_dept_members_department', 'department_members', ['department_id'])
|
||||
|
||||
# =========================================================
|
||||
# A-4: 遷移 employee_identities → department_members
|
||||
# =========================================================
|
||||
op.execute("""
|
||||
INSERT INTO department_members (tenant_id, employee_id, department_id, position, is_active, joined_at)
|
||||
SELECT
|
||||
ei.tenant_id,
|
||||
ei.employee_id,
|
||||
ei.department_id,
|
||||
ei.job_title,
|
||||
ei.is_active,
|
||||
COALESCE(ei.started_at, ei.created_at)
|
||||
FROM employee_identities ei
|
||||
WHERE ei.department_id IS NOT NULL
|
||||
ON CONFLICT (employee_id, department_id) DO NOTHING
|
||||
""")
|
||||
|
||||
# 標記 employee_identities 為廢棄
|
||||
op.add_column('employee_identities', sa.Column(
|
||||
'deprecated_at', sa.DateTime(),
|
||||
server_default=sa.text('now()'),
|
||||
nullable=True,
|
||||
comment='廢棄標記 - 資料已遷移至 department_members'
|
||||
))
|
||||
|
||||
# =========================================================
|
||||
# A-5: employees 新增 keycloak_user_id
|
||||
# =========================================================
|
||||
op.add_column('employees', sa.Column(
|
||||
'keycloak_user_id', sa.String(36),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
comment='Keycloak User UUID (唯一 SSO 識別碼,永久不變)'
|
||||
))
|
||||
op.create_index('idx_employees_keycloak', 'employees', ['keycloak_user_id'])
|
||||
|
||||
# =========================================================
|
||||
# A-6: tenants 新增 keycloak_realm
|
||||
# =========================================================
|
||||
op.add_column('tenants', sa.Column(
|
||||
'keycloak_realm', sa.String(100),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
comment='Keycloak Realm 名稱 (等同 tenant_code)'
|
||||
))
|
||||
op.create_index('idx_tenants_realm', 'tenants', ['keycloak_realm'])
|
||||
|
||||
# 為現有租戶設定 keycloak_realm = tenant_code
|
||||
op.execute("UPDATE tenants SET keycloak_realm = code WHERE keycloak_realm IS NULL")
|
||||
|
||||
# =========================================================
|
||||
# A-7: 新增 RBAC 表
|
||||
# =========================================================
|
||||
|
||||
# system_functions_cache (從 System Admin 同步的系統功能定義)
|
||||
op.create_table(
|
||||
'system_functions_cache',
|
||||
sa.Column('id', sa.Integer(), nullable=False, comment='與 System Admin 的 id 一致'),
|
||||
sa.Column('service_code', sa.String(50), nullable=False, comment='服務代碼: hr/erp/mail/ai'),
|
||||
sa.Column('function_code', sa.String(100), nullable=False, comment='功能代碼: HR_EMPLOYEE_VIEW'),
|
||||
sa.Column('function_name', sa.String(200), nullable=False, comment='功能名稱'),
|
||||
sa.Column('function_category', sa.String(50), nullable=True, comment='功能分類: query/manage/approve/report'),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.Column('synced_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False,
|
||||
comment='最後同步時間'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('function_code', name='uq_function_code'),
|
||||
)
|
||||
op.create_index('idx_func_cache_service', 'system_functions_cache', ['service_code'])
|
||||
op.create_index('idx_func_cache_category', 'system_functions_cache', ['function_category'])
|
||||
|
||||
# roles (租戶層級角色)
|
||||
op.create_table(
|
||||
'roles',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
|
||||
sa.Column('role_code', sa.String(100), nullable=False, comment='角色代碼 (租戶內唯一)'),
|
||||
sa.Column('role_name', sa.String(200), nullable=False, comment='角色名稱'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id', 'role_code', name='uq_tenant_role_code'),
|
||||
)
|
||||
op.create_index('idx_roles_tenant', 'roles', ['tenant_id'])
|
||||
|
||||
# role_rights (角色 → 系統功能 CRUD 權限)
|
||||
op.create_table(
|
||||
'role_rights',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||
sa.Column('function_id', sa.Integer(), nullable=False),
|
||||
sa.Column('can_read', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('can_create', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('can_update', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('can_delete', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['function_id'], ['system_functions_cache.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('role_id', 'function_id', name='uq_role_function'),
|
||||
)
|
||||
op.create_index('idx_role_rights_role', 'role_rights', ['role_id'])
|
||||
op.create_index('idx_role_rights_function', 'role_rights', ['function_id'])
|
||||
|
||||
# user_role_assignments (使用者角色分配,直接對人不對部門)
|
||||
op.create_table(
|
||||
'user_role_assignments',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
|
||||
sa.Column('keycloak_user_id', sa.String(36), nullable=False, comment='Keycloak User UUID'),
|
||||
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||
sa.Column('assigned_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('assigned_by', sa.String(36), nullable=True, comment='分配者 keycloak_user_id'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('keycloak_user_id', 'role_id', name='uq_user_role'),
|
||||
)
|
||||
op.create_index('idx_user_roles_tenant', 'user_role_assignments', ['tenant_id'])
|
||||
op.create_index('idx_user_roles_keycloak', 'user_role_assignments', ['keycloak_user_id'])
|
||||
|
||||
# =========================================================
|
||||
# A-8: Seed data - HR 系統功能 + 初始角色
|
||||
# =========================================================
|
||||
op.execute("""
|
||||
INSERT INTO system_functions_cache (id, service_code, function_code, function_name, function_category) VALUES
|
||||
(1, 'hr', 'HR_EMPLOYEE_VIEW', '員工查詢', 'query'),
|
||||
(2, 'hr', 'HR_EMPLOYEE_MANAGE', '員工管理', 'manage'),
|
||||
(3, 'hr', 'HR_DEPT_MANAGE', '部門管理', 'manage'),
|
||||
(4, 'hr', 'HR_ROLE_MANAGE', '角色管理', 'manage'),
|
||||
(5, 'hr', 'HR_AUDIT_VIEW', '審計日誌查詢', 'query')
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
INSERT INTO roles (tenant_id, role_code, role_name, description) VALUES
|
||||
(1, 'HR_ADMIN', '人資管理員', '可管理員工資料、組織架構、角色分配'),
|
||||
(1, 'HR_VIEWER', '人資查詢者', '只能查詢員工資料'),
|
||||
(1, 'SYSTEM_ADMIN', '系統管理員', '擁有所有 HR 系統功能權限')
|
||||
""")
|
||||
|
||||
# HR_ADMIN 擁有所有 HR 功能的完整權限
|
||||
op.execute("""
|
||||
INSERT INTO role_rights (role_id, function_id, can_read, can_create, can_update, can_delete)
|
||||
SELECT r.id, f.id,
|
||||
true,
|
||||
CASE WHEN f.function_category = 'manage' THEN true ELSE false END,
|
||||
CASE WHEN f.function_category IN ('manage', 'approve') THEN true ELSE false END,
|
||||
CASE WHEN f.function_category = 'manage' THEN true ELSE false END
|
||||
FROM roles r, system_functions_cache f
|
||||
WHERE r.role_code = 'HR_ADMIN' AND r.tenant_id = 1
|
||||
""")
|
||||
|
||||
# HR_VIEWER 只有查詢權限
|
||||
op.execute("""
|
||||
INSERT INTO role_rights (role_id, function_id, can_read, can_create, can_update, can_delete)
|
||||
SELECT r.id, f.id, true, false, false, false
|
||||
FROM roles r, system_functions_cache f
|
||||
WHERE r.role_code = 'HR_VIEWER' AND r.tenant_id = 1
|
||||
AND f.function_category = 'query'
|
||||
""")
|
||||
|
||||
# SYSTEM_ADMIN 擁有所有功能的完整 CRUD
|
||||
op.execute("""
|
||||
INSERT INTO role_rights (role_id, function_id, can_read, can_create, can_update, can_delete)
|
||||
SELECT r.id, f.id, true, true, true, true
|
||||
FROM roles r, system_functions_cache f
|
||||
WHERE r.role_code = 'SYSTEM_ADMIN' AND r.tenant_id = 1
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 移除 RBAC 表
|
||||
op.drop_table('user_role_assignments')
|
||||
op.drop_table('role_rights')
|
||||
op.drop_table('roles')
|
||||
op.drop_table('system_functions_cache')
|
||||
|
||||
# 移除 keycloak 欄位
|
||||
op.drop_index('idx_tenants_realm', table_name='tenants')
|
||||
op.drop_column('tenants', 'keycloak_realm')
|
||||
|
||||
op.drop_index('idx_employees_keycloak', table_name='employees')
|
||||
op.drop_column('employees', 'keycloak_user_id')
|
||||
|
||||
# 移除廢棄標記
|
||||
op.drop_column('employee_identities', 'deprecated_at')
|
||||
|
||||
# 移除 department_members
|
||||
op.drop_table('department_members')
|
||||
|
||||
# 還原 departments (重新加回 business_unit_id) - 注意: 資料無法完全還原
|
||||
op.add_column('departments', sa.Column('business_unit_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_departments_business_unit',
|
||||
'departments', 'business_units',
|
||||
['business_unit_id'], ['id'],
|
||||
ondelete='CASCADE'
|
||||
)
|
||||
op.drop_constraint('uq_tenant_parent_dept_code', 'departments', type_='unique')
|
||||
op.create_unique_constraint(
|
||||
'uq_tenant_bu_dept_code',
|
||||
'departments',
|
||||
['tenant_id', 'business_unit_id', 'code']
|
||||
)
|
||||
op.drop_index('idx_dept_tenant_id', table_name='departments')
|
||||
op.drop_index('idx_departments_parent', table_name='departments')
|
||||
op.drop_index('idx_departments_depth', table_name='departments')
|
||||
op.drop_constraint('fk_departments_parent', 'departments', type_='foreignkey')
|
||||
op.drop_column('departments', 'parent_id')
|
||||
op.drop_column('departments', 'email_domain')
|
||||
op.drop_column('departments', 'depth')
|
||||
op.create_index('idx_dept_tenant', 'departments', ['tenant_id'])
|
||||
28
backend/alembic/versions/0006_add_name_en_to_departments.py
Normal file
28
backend/alembic/versions/0006_add_name_en_to_departments.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""add name_en to departments
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-02-19 09:50:17.913124
|
||||
|
||||
補充 migration 0005 遺漏的欄位:
|
||||
- departments.name_en (英文名稱)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0006'
|
||||
down_revision: Union[str, None] = '0005'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('departments', sa.Column('name_en', sa.String(100), nullable=True, comment='英文名稱'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('departments', 'name_en')
|
||||
@@ -0,0 +1,77 @@
|
||||
"""rename tables to match org schema
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-02-20 12:00:33.776853
|
||||
|
||||
重新命名資料表以符合組織架構規格:
|
||||
- tenants → organizes
|
||||
- departments → org_departments
|
||||
- roles → org_user_roles
|
||||
- employees → org_employees
|
||||
- department_members → org_dept_members
|
||||
- user_role_assignments → org_user_role_assignments
|
||||
- role_rights → org_role_rights
|
||||
- email_accounts → org_email_accounts
|
||||
- network_drives → org_network_drives
|
||||
- permissions → org_permissions
|
||||
- audit_logs → org_audit_logs
|
||||
- batch_logs → org_batch_logs
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0007'
|
||||
down_revision: Union[str, None] = '0006'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# =========================================================
|
||||
# 重新命名資料表(按照依賴順序,從子表到父表)
|
||||
# =========================================================
|
||||
|
||||
# 1. 批次作業和日誌表(無外鍵依賴)
|
||||
op.rename_table('batch_logs', 'org_batch_logs')
|
||||
op.rename_table('audit_logs', 'org_audit_logs')
|
||||
|
||||
# 2. 員工相關子表(依賴 employees)
|
||||
op.rename_table('permissions', 'org_permissions')
|
||||
op.rename_table('network_drives', 'org_network_drives')
|
||||
op.rename_table('email_accounts', 'org_email_accounts')
|
||||
|
||||
# 3. 角色權限相關(依賴 roles)
|
||||
op.rename_table('role_rights', 'org_role_rights')
|
||||
op.rename_table('user_role_assignments', 'org_user_role_assignments')
|
||||
|
||||
# 4. 部門成員(依賴 departments, employees)
|
||||
op.rename_table('department_members', 'org_dept_members')
|
||||
|
||||
# 5. 主要業務表
|
||||
op.rename_table('roles', 'org_user_roles')
|
||||
op.rename_table('departments', 'org_departments')
|
||||
op.rename_table('employees', 'org_employees')
|
||||
|
||||
# 6. 租戶/組織表(最後,因為其他表都依賴它)
|
||||
op.rename_table('tenants', 'organizes')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 反向操作:從父表到子表
|
||||
op.rename_table('organizes', 'tenants')
|
||||
op.rename_table('org_employees', 'employees')
|
||||
op.rename_table('org_departments', 'departments')
|
||||
op.rename_table('org_user_roles', 'roles')
|
||||
op.rename_table('org_dept_members', 'department_members')
|
||||
op.rename_table('org_user_role_assignments', 'user_role_assignments')
|
||||
op.rename_table('org_role_rights', 'role_rights')
|
||||
op.rename_table('org_email_accounts', 'email_accounts')
|
||||
op.rename_table('org_network_drives', 'network_drives')
|
||||
op.rename_table('org_permissions', 'permissions')
|
||||
op.rename_table('org_audit_logs', 'audit_logs')
|
||||
op.rename_table('org_batch_logs', 'batch_logs')
|
||||
71
backend/alembic/versions/0008_add_is_active_to_all_tables.py
Normal file
71
backend/alembic/versions/0008_add_is_active_to_all_tables.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""add is_active to all tables
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-02-20
|
||||
|
||||
統一為所有資料表新增 is_active 布林欄位
|
||||
- true: 資料啟用
|
||||
- false: 資料不啟用
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0008'
|
||||
down_revision = '0007'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. organizes (目前只有 status,新增 is_active,預設從 status 轉換)
|
||||
op.add_column('organizes', sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否啟用'))
|
||||
op.execute("""
|
||||
UPDATE organizes
|
||||
SET is_active = CASE
|
||||
WHEN status = 'active' THEN true
|
||||
WHEN status = 'trial' THEN true
|
||||
ELSE false
|
||||
END
|
||||
""")
|
||||
op.alter_column('organizes', 'is_active', nullable=False)
|
||||
|
||||
# 2. org_employees (目前只有 status,新增 is_active)
|
||||
op.add_column('org_employees', sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否啟用'))
|
||||
op.execute("""
|
||||
UPDATE org_employees
|
||||
SET is_active = CASE
|
||||
WHEN status = 'active' THEN true
|
||||
ELSE false
|
||||
END
|
||||
""")
|
||||
op.alter_column('org_employees', 'is_active', nullable=False)
|
||||
|
||||
# 3. org_batch_logs (目前只有 status,但這是執行狀態不是啟用狀態,仍新增 is_active)
|
||||
op.add_column('org_batch_logs', sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'))
|
||||
|
||||
# 4. 其他已有 is_active 的表不需處理
|
||||
# - org_departments (已有 is_active)
|
||||
# - business_units (已有 is_active)
|
||||
# - org_dept_members (已有 is_active)
|
||||
# - org_email_accounts (已有 is_active)
|
||||
# - org_network_drives (已有 is_active)
|
||||
# - org_user_roles (已有 is_active)
|
||||
# - org_user_role_assignments (已有 is_active)
|
||||
# - system_functions_cache (已有 is_active)
|
||||
|
||||
# 5. 檢查其他表是否需要 is_active (根據業務邏輯)
|
||||
# - org_audit_logs: 審計日誌不需要 is_active (不可停用)
|
||||
# - org_permissions: 已有 is_active (透過 ended_at 判斷)
|
||||
#
|
||||
# 注意: tenant_domains, subscriptions, invoices, payments 等表可能不存在於目前的資料庫
|
||||
# 這些表屬於多租戶進階功能,將在後續 migration 中建立
|
||||
# 暫時跳過這些表的 is_active 處理
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('org_batch_logs', 'is_active')
|
||||
op.drop_column('org_employees', 'is_active')
|
||||
op.drop_column('organizes', 'is_active')
|
||||
144
backend/alembic/versions/0009_add_tenant_scoped_sequences.py
Normal file
144
backend/alembic/versions/0009_add_tenant_scoped_sequences.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""add tenant scoped sequences
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-02-20
|
||||
|
||||
為每個租戶建立獨立的序號生成器
|
||||
- 每個租戶的資料從 1 開始編號
|
||||
- 保證租戶內序號連續性
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '0009'
|
||||
down_revision = '0008'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# =========================================================
|
||||
# 方案:為主要實體表新增租戶內序號欄位
|
||||
# =========================================================
|
||||
|
||||
# 1. org_employees: 新增 seq_no (租戶內序號)
|
||||
op.add_column('org_employees', sa.Column('seq_no', sa.Integer(), nullable=True, comment='租戶內序號'))
|
||||
|
||||
# 為現有資料填充 seq_no
|
||||
op.execute("""
|
||||
WITH numbered AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) as seq
|
||||
FROM org_employees
|
||||
)
|
||||
UPDATE org_employees
|
||||
SET seq_no = numbered.seq
|
||||
FROM numbered
|
||||
WHERE org_employees.id = numbered.id
|
||||
""")
|
||||
|
||||
# 設為 NOT NULL
|
||||
op.alter_column('org_employees', 'seq_no', nullable=False)
|
||||
|
||||
# 建立唯一索引:tenant_id + seq_no
|
||||
op.create_index(
|
||||
'idx_org_employees_tenant_seq',
|
||||
'org_employees',
|
||||
['tenant_id', 'seq_no'],
|
||||
unique=True
|
||||
)
|
||||
|
||||
# 2. org_departments: 新增 seq_no
|
||||
op.add_column('org_departments', sa.Column('seq_no', sa.Integer(), nullable=True, comment='租戶內序號'))
|
||||
|
||||
op.execute("""
|
||||
WITH numbered AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) as seq
|
||||
FROM org_departments
|
||||
)
|
||||
UPDATE org_departments
|
||||
SET seq_no = numbered.seq
|
||||
FROM numbered
|
||||
WHERE org_departments.id = numbered.id
|
||||
""")
|
||||
|
||||
op.alter_column('org_departments', 'seq_no', nullable=False)
|
||||
op.create_index(
|
||||
'idx_org_departments_tenant_seq',
|
||||
'org_departments',
|
||||
['tenant_id', 'seq_no'],
|
||||
unique=True
|
||||
)
|
||||
|
||||
# 3. org_user_roles: 新增 seq_no
|
||||
op.add_column('org_user_roles', sa.Column('seq_no', sa.Integer(), nullable=True, comment='租戶內序號'))
|
||||
|
||||
op.execute("""
|
||||
WITH numbered AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) as seq
|
||||
FROM org_user_roles
|
||||
)
|
||||
UPDATE org_user_roles
|
||||
SET seq_no = numbered.seq
|
||||
FROM numbered
|
||||
WHERE org_user_roles.id = numbered.id
|
||||
""")
|
||||
|
||||
op.alter_column('org_user_roles', 'seq_no', nullable=False)
|
||||
op.create_index(
|
||||
'idx_org_user_roles_tenant_seq',
|
||||
'org_user_roles',
|
||||
['tenant_id', 'seq_no'],
|
||||
unique=True
|
||||
)
|
||||
|
||||
# 4. 建立自動生成序號的觸發器函數
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION generate_tenant_seq_no()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- 如果 seq_no 未提供,自動生成
|
||||
IF NEW.seq_no IS NULL THEN
|
||||
SELECT COALESCE(MAX(seq_no), 0) + 1
|
||||
INTO NEW.seq_no
|
||||
FROM org_employees
|
||||
WHERE tenant_id = NEW.tenant_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# 5. 為各表建立觸發器
|
||||
for table_name in ['org_employees', 'org_departments', 'org_user_roles']:
|
||||
trigger_name = f'trigger_{table_name}_seq_no'
|
||||
op.execute(f"""
|
||||
CREATE TRIGGER {trigger_name}
|
||||
BEFORE INSERT ON {table_name}
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_seq_no();
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 移除觸發器
|
||||
for table_name in ['org_employees', 'org_departments', 'org_user_roles']:
|
||||
trigger_name = f'trigger_{table_name}_seq_no'
|
||||
op.execute(f"DROP TRIGGER IF EXISTS {trigger_name} ON {table_name}")
|
||||
|
||||
# 移除函數
|
||||
op.execute("DROP FUNCTION IF EXISTS generate_tenant_seq_no()")
|
||||
|
||||
# 移除索引和欄位
|
||||
op.drop_index('idx_org_user_roles_tenant_seq', table_name='org_user_roles')
|
||||
op.drop_column('org_user_roles', 'seq_no')
|
||||
|
||||
op.drop_index('idx_org_departments_tenant_seq', table_name='org_departments')
|
||||
op.drop_column('org_departments', 'seq_no')
|
||||
|
||||
op.drop_index('idx_org_employees_tenant_seq', table_name='org_employees')
|
||||
op.drop_column('org_employees', 'seq_no')
|
||||
341
backend/alembic/versions/0010_refactor_to_final_architecture.py
Normal file
341
backend/alembic/versions/0010_refactor_to_final_architecture.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""refactor to final architecture
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-02-20
|
||||
|
||||
完整架構重構:
|
||||
1. 統一表名前綴:org_* → tenant_*(organizes → tenants)
|
||||
2. 統一通用欄位:is_active, edit_by, created_at, updated_at
|
||||
3. 擴充 tenants 表業務欄位
|
||||
4. 新增 personal_services 表
|
||||
5. 新增 tenant_emp_resumes 表(人員基本資料)
|
||||
6. 新增 tenant_emp_setting 表(員工任用設定,使用複合主鍵)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = '0010'
|
||||
down_revision = '0009'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# =========================================================
|
||||
# Phase 1: 統一表名前綴(org_* → tenant_*)
|
||||
# =========================================================
|
||||
|
||||
# 1.1 核心表
|
||||
op.rename_table('organizes', 'tenants')
|
||||
op.rename_table('org_departments', 'tenant_departments')
|
||||
op.rename_table('org_employees', 'tenant_employees')
|
||||
op.rename_table('org_user_roles', 'tenant_user_roles')
|
||||
|
||||
# 1.2 關聯表
|
||||
op.rename_table('org_dept_members', 'tenant_dept_members')
|
||||
op.rename_table('org_email_accounts', 'tenant_email_accounts')
|
||||
op.rename_table('org_network_drives', 'tenant_network_drives')
|
||||
op.rename_table('org_permissions', 'tenant_permissions')
|
||||
op.rename_table('org_role_rights', 'tenant_role_rights')
|
||||
op.rename_table('org_user_role_assignments', 'tenant_user_role_assignments')
|
||||
|
||||
# 1.3 系統表
|
||||
op.rename_table('org_audit_logs', 'tenant_audit_logs')
|
||||
op.rename_table('org_batch_logs', 'tenant_batch_logs')
|
||||
|
||||
# =========================================================
|
||||
# Phase 2: 統一通用欄位(所有表)
|
||||
# =========================================================
|
||||
|
||||
# 需要更新的所有表(包含新命名的)
|
||||
tables_to_update = [
|
||||
'business_units',
|
||||
'employee_identities',
|
||||
'system_functions_cache',
|
||||
'tenant_audit_logs',
|
||||
'tenant_batch_logs',
|
||||
'tenant_departments',
|
||||
'tenant_dept_members',
|
||||
'tenant_email_accounts',
|
||||
'tenant_employees',
|
||||
'tenant_network_drives',
|
||||
'tenant_permissions',
|
||||
'tenant_role_rights',
|
||||
'tenant_user_role_assignments',
|
||||
'tenant_user_roles',
|
||||
'tenants',
|
||||
]
|
||||
|
||||
for table_name in tables_to_update:
|
||||
# 2.1 新增 edit_by(使用 op.add_column,安全處理已存在情況)
|
||||
try:
|
||||
op.add_column(table_name, sa.Column('edit_by', sa.String(100), comment='資料編輯者'))
|
||||
except:
|
||||
pass # 已存在則跳過
|
||||
|
||||
# 2.2 確保有 created_at
|
||||
# 先檢查是否有 created_at 或 create_at
|
||||
result = op.get_bind().execute(sa.text(f"""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = '{table_name}'
|
||||
AND column_name IN ('created_at', 'create_at')
|
||||
"""))
|
||||
existing_cols = [row[0] for row in result]
|
||||
|
||||
if 'create_at' in existing_cols and 'created_at' not in existing_cols:
|
||||
op.alter_column(table_name, 'create_at', new_column_name='created_at')
|
||||
elif not existing_cols:
|
||||
op.add_column(table_name, sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')))
|
||||
|
||||
# 2.3 確保有 updated_at
|
||||
result2 = op.get_bind().execute(sa.text(f"""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = '{table_name}'
|
||||
AND column_name IN ('updated_at', 'edit_at')
|
||||
"""))
|
||||
existing_cols2 = [row[0] for row in result2]
|
||||
|
||||
if 'edit_at' in existing_cols2 and 'updated_at' not in existing_cols2:
|
||||
op.alter_column(table_name, 'edit_at', new_column_name='updated_at')
|
||||
elif not existing_cols2:
|
||||
op.add_column(table_name, sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')))
|
||||
|
||||
# =========================================================
|
||||
# Phase 3: 擴充 tenants 表
|
||||
# =========================================================
|
||||
|
||||
# 3.1 移除重複欄位
|
||||
op.drop_column('tenants', 'keycloak_realm') # 與 code 重複
|
||||
|
||||
# 3.2 欄位已在 migration 0007 重新命名,跳過
|
||||
# tenant_code → code (已完成)
|
||||
# company_name → name (已完成)
|
||||
|
||||
# 3.3 新增業務欄位
|
||||
op.add_column('tenants', sa.Column('name_eng', sa.String(200), comment='公司英文名稱'))
|
||||
op.add_column('tenants', sa.Column('tax_id', sa.String(20), comment='公司統編'))
|
||||
op.add_column('tenants', sa.Column('prefix', sa.String(10), nullable=False, server_default='ORG', comment='公司簡稱(員工編號前綴)'))
|
||||
op.add_column('tenants', sa.Column('domain_set', sa.Integer, nullable=False, server_default='2', comment='網域條件:1=組織網域,2=部門網域'))
|
||||
op.add_column('tenants', sa.Column('domain', sa.String(100), comment='組織網域(domain_set=1時使用)'))
|
||||
op.add_column('tenants', sa.Column('tel', sa.String(20), comment='公司代表號'))
|
||||
op.add_column('tenants', sa.Column('add', sa.Text, comment='公司登記地址'))
|
||||
op.add_column('tenants', sa.Column('fax', sa.String(20), comment='公司傳真電話'))
|
||||
op.add_column('tenants', sa.Column('contact', sa.String(100), comment='公司聯絡人'))
|
||||
op.add_column('tenants', sa.Column('contact_email', sa.String(255), comment='公司聯絡人電子郵件'))
|
||||
op.add_column('tenants', sa.Column('contact_mobil', sa.String(20), comment='公司聯絡人行動電話'))
|
||||
op.add_column('tenants', sa.Column('is_sysmana', sa.Boolean, nullable=False, server_default='false', comment='是否為系統管理公司'))
|
||||
op.add_column('tenants', sa.Column('quota', sa.Integer, nullable=False, server_default='100', comment='組織配額(GB)'))
|
||||
|
||||
# 3.4 更新現有租戶 1 的資料
|
||||
op.execute("""
|
||||
UPDATE tenants
|
||||
SET
|
||||
name_eng = 'Porscheworld',
|
||||
tax_id = '82871784',
|
||||
prefix = 'PWD',
|
||||
domain_set = 2,
|
||||
tel = '0226262026',
|
||||
add = '新北市淡水區北新路197號7樓',
|
||||
contact = 'porsche.chen',
|
||||
contact_email = 'porsche.chen@gmail.com',
|
||||
contact_mobil = '0910326333',
|
||||
is_sysmana = true,
|
||||
quota = 100
|
||||
WHERE id = 1
|
||||
""")
|
||||
|
||||
# =========================================================
|
||||
# Phase 4: 新增 personal_services 表
|
||||
# =========================================================
|
||||
|
||||
op.create_table(
|
||||
'personal_services',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('service_name', sa.String(100), nullable=False, comment='服務名稱'),
|
||||
sa.Column('service_code', sa.String(50), unique=True, nullable=False, comment='服務代碼'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'),
|
||||
sa.Column('edit_by', sa.String(100), comment='資料編輯者'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
)
|
||||
|
||||
# 插入預設資料
|
||||
op.execute("""
|
||||
INSERT INTO personal_services (service_name, service_code, is_active) VALUES
|
||||
('單一簽入', 'SSO', true),
|
||||
('電子郵件', 'Email', true),
|
||||
('日曆', 'Calendar', true),
|
||||
('網路硬碟', 'Drive', true),
|
||||
('線上office工具', 'Office', true)
|
||||
""")
|
||||
|
||||
# =========================================================
|
||||
# Phase 5: 新增 tenant_emp_resumes 表(人員基本資料)
|
||||
# =========================================================
|
||||
|
||||
op.create_table(
|
||||
'tenant_emp_resumes',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('seq_no', sa.Integer(), nullable=False, comment='租戶內序號'),
|
||||
sa.Column('name_tw', sa.String(100), nullable=False, comment='中文姓名'),
|
||||
sa.Column('name_eng', sa.String(100), comment='英文姓名'),
|
||||
sa.Column('personal_id', sa.String(20), comment='身份證號/護照號碼'),
|
||||
sa.Column('personal_tel', sa.String(20), comment='通訊電話'),
|
||||
sa.Column('personal_add', sa.Text, comment='通訊地址'),
|
||||
sa.Column('emergency_contact', sa.String(100), comment='緊急聯絡人'),
|
||||
sa.Column('emergency_tel', sa.String(20), comment='緊急聯絡人電話'),
|
||||
sa.Column('academic_qualification', sa.String(200), comment='最高學歷'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('edit_by', sa.String(100)),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.UniqueConstraint('tenant_id', 'seq_no', name='uq_tenant_resume_seq'),
|
||||
)
|
||||
|
||||
# 建立序號觸發器
|
||||
op.execute("""
|
||||
CREATE TRIGGER trigger_tenant_emp_resumes_seq_no
|
||||
BEFORE INSERT ON tenant_emp_resumes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_seq_no();
|
||||
""")
|
||||
|
||||
# =========================================================
|
||||
# Phase 6: 新增 tenant_emp_setting 表(員工任用設定)
|
||||
# 使用複合主鍵:(tenant_id, seq_no)
|
||||
# =========================================================
|
||||
|
||||
op.create_table(
|
||||
'tenant_emp_setting',
|
||||
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False, comment='租戶 ID'),
|
||||
sa.Column('seq_no', sa.Integer(), nullable=False, comment='租戶內序號'),
|
||||
sa.Column('tenant_resume_id', sa.Integer(), sa.ForeignKey('tenant_emp_resumes.id', ondelete='CASCADE'), nullable=False, comment='人員基本資料 ID'),
|
||||
sa.Column('tenant_emp_code', sa.String(20), nullable=False, comment='員工工號(自動生成:prefix + seq_no)'),
|
||||
sa.Column('hire_at', sa.Date(), nullable=False, comment='到職日期'),
|
||||
sa.Column('tenant_dep_ids', postgresql.ARRAY(sa.Integer()), comment='部門設定(陣列)'),
|
||||
sa.Column('tenant_user_roles_ids', postgresql.ARRAY(sa.Integer()), comment='使用者角色(陣列)'),
|
||||
sa.Column('tenant_user_quota', sa.Integer(), nullable=False, server_default='20', comment='配額設定(GB)'),
|
||||
sa.Column('tenant_user_personal_services', postgresql.ARRAY(sa.Integer()), comment='個人化服務設定(陣列)'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('edit_by', sa.String(100)),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
# 複合主鍵
|
||||
sa.PrimaryKeyConstraint('tenant_id', 'seq_no', name='pk_tenant_emp_setting'),
|
||||
|
||||
# 其他唯一約束
|
||||
sa.UniqueConstraint('tenant_id', 'tenant_emp_code', name='uq_tenant_emp_code'),
|
||||
sa.UniqueConstraint('tenant_id', 'tenant_resume_id', name='uq_tenant_resume'), # 一人一筆任用檔
|
||||
)
|
||||
|
||||
# 建立索引
|
||||
op.create_index('idx_tenant_emp_setting_tenant', 'tenant_emp_setting', ['tenant_id'])
|
||||
op.create_index('idx_tenant_emp_setting_resume', 'tenant_emp_setting', ['tenant_resume_id'])
|
||||
|
||||
# 建立序號觸發器(針對複合主鍵表調整)
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION generate_tenant_emp_setting_seq()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.seq_no IS NULL THEN
|
||||
SELECT COALESCE(MAX(seq_no), 0) + 1
|
||||
INTO NEW.seq_no
|
||||
FROM tenant_emp_setting
|
||||
WHERE tenant_id = NEW.tenant_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_tenant_emp_setting_seq_no
|
||||
BEFORE INSERT ON tenant_emp_setting
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_emp_setting_seq();
|
||||
""")
|
||||
|
||||
# 建立員工工號自動生成觸發器
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION generate_tenant_emp_code()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
tenant_prefix VARCHAR(10);
|
||||
BEGIN
|
||||
IF NEW.tenant_emp_code IS NULL OR NEW.tenant_emp_code = '' THEN
|
||||
-- 取得租戶的 prefix
|
||||
SELECT prefix INTO tenant_prefix
|
||||
FROM tenants
|
||||
WHERE id = NEW.tenant_id;
|
||||
|
||||
-- 生成工號:prefix + LPAD(seq_no, 4, '0')
|
||||
-- 例如:PWD + 0001 = PWD0001
|
||||
NEW.tenant_emp_code := tenant_prefix || LPAD(NEW.seq_no::TEXT, 4, '0');
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_generate_emp_code
|
||||
BEFORE INSERT ON tenant_emp_setting
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_emp_code();
|
||||
""")
|
||||
|
||||
# =========================================================
|
||||
# Phase 7: 更新現有觸發器函數(支援新表)
|
||||
# =========================================================
|
||||
|
||||
# 更新原有的 generate_tenant_seq_no 函數,使其支援不同表
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION generate_tenant_seq_no()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.seq_no IS NULL THEN
|
||||
-- 動態根據 TG_TABLE_NAME 決定查詢哪個表
|
||||
EXECUTE format('
|
||||
SELECT COALESCE(MAX(seq_no), 0) + 1
|
||||
FROM %I
|
||||
WHERE tenant_id = $1
|
||||
', TG_TABLE_NAME)
|
||||
INTO NEW.seq_no
|
||||
USING NEW.tenant_id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 移除新建的觸發器和函數
|
||||
op.execute("DROP TRIGGER IF EXISTS trigger_generate_emp_code ON tenant_emp_setting")
|
||||
op.execute("DROP FUNCTION IF EXISTS generate_tenant_emp_code()")
|
||||
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_emp_setting_seq_no ON tenant_emp_setting")
|
||||
op.execute("DROP FUNCTION IF EXISTS generate_tenant_emp_setting_seq()")
|
||||
|
||||
op.drop_index('idx_tenant_emp_setting_resume', table_name='tenant_emp_setting')
|
||||
op.drop_index('idx_tenant_emp_setting_tenant', table_name='tenant_emp_setting')
|
||||
op.drop_table('tenant_emp_setting')
|
||||
|
||||
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_emp_resumes_seq_no ON tenant_emp_resumes")
|
||||
op.drop_table('tenant_emp_resumes')
|
||||
|
||||
op.drop_table('personal_services')
|
||||
|
||||
# 恢復表名(tenant_* → org_*)
|
||||
op.rename_table('tenant_batch_logs', 'org_batch_logs')
|
||||
op.rename_table('tenant_audit_logs', 'org_audit_logs')
|
||||
op.rename_table('tenant_user_role_assignments', 'org_user_role_assignments')
|
||||
op.rename_table('tenant_role_rights', 'org_role_rights')
|
||||
op.rename_table('tenant_permissions', 'org_permissions')
|
||||
op.rename_table('tenant_network_drives', 'org_network_drives')
|
||||
op.rename_table('tenant_email_accounts', 'org_email_accounts')
|
||||
op.rename_table('tenant_dept_members', 'org_dept_members')
|
||||
op.rename_table('tenant_user_roles', 'org_user_roles')
|
||||
op.rename_table('tenant_employees', 'org_employees')
|
||||
op.rename_table('tenant_departments', 'org_departments')
|
||||
op.rename_table('tenants', 'organizes')
|
||||
115
backend/alembic/versions/0011_cleanup_and_finalize.py
Normal file
115
backend/alembic/versions/0011_cleanup_and_finalize.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""cleanup and finalize
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-02-20
|
||||
|
||||
架構收尾與清理:
|
||||
1. 移除廢棄表:business_units, employee_identities
|
||||
2. 為 tenant_role_rights 新增 is_active
|
||||
3. 重新命名觸發器:org_* → tenant_*
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '0011'
|
||||
down_revision = '0010'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# =========================================================
|
||||
# Phase 1: 移除廢棄表(先移除外鍵約束)
|
||||
# =========================================================
|
||||
|
||||
# 1.1 先移除依賴 business_units 的外鍵
|
||||
# employee_identities.business_unit_id FK
|
||||
op.drop_constraint('employee_identities_business_unit_id_fkey', 'employee_identities', type_='foreignkey')
|
||||
|
||||
# tenant_email_accounts.business_unit_id FK(如果存在)
|
||||
try:
|
||||
op.drop_constraint('fk_email_accounts_business_unit', 'tenant_email_accounts', type_='foreignkey')
|
||||
except:
|
||||
pass # 可能不存在
|
||||
|
||||
# 1.2 移除 employee_identities 表(已被 tenant_emp_setting 取代)
|
||||
op.drop_table('employee_identities')
|
||||
|
||||
# 1.3 移除 business_units 表(已被 tenant_departments 取代)
|
||||
op.drop_table('business_units')
|
||||
|
||||
# =========================================================
|
||||
# Phase 2: 為 tenant_role_rights 新增 is_active
|
||||
# =========================================================
|
||||
|
||||
op.add_column('tenant_role_rights', sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'))
|
||||
|
||||
# =========================================================
|
||||
# Phase 3: 重新命名觸發器(org_* → tenant_*)
|
||||
# =========================================================
|
||||
|
||||
# 3.1 tenant_departments
|
||||
op.execute("""
|
||||
DROP TRIGGER IF EXISTS trigger_org_departments_seq_no ON tenant_departments;
|
||||
|
||||
CREATE TRIGGER trigger_tenant_departments_seq_no
|
||||
BEFORE INSERT ON tenant_departments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_seq_no();
|
||||
""")
|
||||
|
||||
# 3.2 tenant_employees
|
||||
op.execute("""
|
||||
DROP TRIGGER IF EXISTS trigger_org_employees_seq_no ON tenant_employees;
|
||||
|
||||
CREATE TRIGGER trigger_tenant_employees_seq_no
|
||||
BEFORE INSERT ON tenant_employees
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_seq_no();
|
||||
""")
|
||||
|
||||
# 3.3 tenant_user_roles
|
||||
op.execute("""
|
||||
DROP TRIGGER IF EXISTS trigger_org_user_roles_seq_no ON tenant_user_roles;
|
||||
|
||||
CREATE TRIGGER trigger_tenant_user_roles_seq_no
|
||||
BEFORE INSERT ON tenant_user_roles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_seq_no();
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢復觸發器名稱
|
||||
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_user_roles_seq_no ON tenant_user_roles")
|
||||
op.execute("""
|
||||
CREATE TRIGGER trigger_org_user_roles_seq_no
|
||||
BEFORE INSERT ON tenant_user_roles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_seq_no()
|
||||
""")
|
||||
|
||||
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_employees_seq_no ON tenant_employees")
|
||||
op.execute("""
|
||||
CREATE TRIGGER trigger_org_employees_seq_no
|
||||
BEFORE INSERT ON tenant_employees
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_seq_no()
|
||||
""")
|
||||
|
||||
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_departments_seq_no ON tenant_departments")
|
||||
op.execute("""
|
||||
CREATE TRIGGER trigger_org_departments_seq_no
|
||||
BEFORE INSERT ON tenant_departments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_seq_no()
|
||||
""")
|
||||
|
||||
# 移除 is_active
|
||||
op.drop_column('tenant_role_rights', 'is_active')
|
||||
|
||||
# 恢復廢棄表(不實現,downgrade 不支援重建複雜資料)
|
||||
# op.create_table('employee_identities', ...)
|
||||
# op.create_table('business_units', ...)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""refactor emp settings to relational
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-02-20
|
||||
|
||||
調整 tenant_emp_setting 表結構:
|
||||
1. 表名改為複數:tenant_emp_setting → tenant_emp_settings
|
||||
2. 移除 ARRAY 欄位(改用關聯表)
|
||||
3. 新增 Keycloak 欄位
|
||||
4. 調整配額欄位命名
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = '0012'
|
||||
down_revision = '0011'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# =========================================================
|
||||
# Phase 1: 重新命名表(單數 → 複數)
|
||||
# =========================================================
|
||||
op.rename_table('tenant_emp_setting', 'tenant_emp_settings')
|
||||
|
||||
# =========================================================
|
||||
# Phase 2: 新增 Keycloak 欄位
|
||||
# =========================================================
|
||||
op.add_column('tenant_emp_settings', sa.Column(
|
||||
'tenant_keycloak_user_id',
|
||||
sa.String(36),
|
||||
nullable=True, # 先設為 nullable,稍後更新資料後改為 NOT NULL
|
||||
comment='Keycloak User UUID'
|
||||
))
|
||||
|
||||
op.add_column('tenant_emp_settings', sa.Column(
|
||||
'tenant_keycloak_username',
|
||||
sa.String(100),
|
||||
nullable=True,
|
||||
comment='Keycloak 使用者帳號'
|
||||
))
|
||||
|
||||
# =========================================================
|
||||
# Phase 3: 新增郵件配額欄位
|
||||
# =========================================================
|
||||
op.add_column('tenant_emp_settings', sa.Column(
|
||||
'email_quota_mb',
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default='5120',
|
||||
comment='郵件配額 (MB)'
|
||||
))
|
||||
|
||||
# =========================================================
|
||||
# Phase 4: 重新命名配額欄位
|
||||
# =========================================================
|
||||
op.alter_column('tenant_emp_settings', 'tenant_user_quota',
|
||||
new_column_name='storage_quota_gb')
|
||||
|
||||
# =========================================================
|
||||
# Phase 5: 移除 ARRAY 欄位(改用關聯表)
|
||||
# =========================================================
|
||||
op.drop_column('tenant_emp_settings', 'tenant_dep_ids')
|
||||
op.drop_column('tenant_emp_settings', 'tenant_user_roles_ids')
|
||||
op.drop_column('tenant_emp_settings', 'tenant_user_personal_services')
|
||||
|
||||
# =========================================================
|
||||
# Phase 6: 更新觸發器名稱(配合表名變更)
|
||||
# =========================================================
|
||||
op.execute("""
|
||||
DROP TRIGGER IF EXISTS trigger_tenant_emp_setting_seq_no ON tenant_emp_settings;
|
||||
DROP TRIGGER IF EXISTS trigger_generate_emp_code ON tenant_emp_settings;
|
||||
|
||||
CREATE TRIGGER trigger_tenant_emp_settings_seq_no
|
||||
BEFORE INSERT ON tenant_emp_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_emp_setting_seq();
|
||||
|
||||
CREATE TRIGGER trigger_generate_emp_code
|
||||
BEFORE INSERT ON tenant_emp_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_emp_code();
|
||||
""")
|
||||
|
||||
# =========================================================
|
||||
# Phase 7: 更新索引名稱(配合表名變更)
|
||||
# =========================================================
|
||||
op.execute("""
|
||||
ALTER INDEX IF EXISTS idx_tenant_emp_setting_tenant
|
||||
RENAME TO idx_tenant_emp_settings_tenant;
|
||||
|
||||
ALTER INDEX IF EXISTS idx_tenant_emp_setting_resume
|
||||
RENAME TO idx_tenant_emp_settings_resume;
|
||||
""")
|
||||
|
||||
# =========================================================
|
||||
# Phase 8: 建立 tenant_emp_personal_service_settings 表(如果不存在)
|
||||
# =========================================================
|
||||
# 檢查表是否存在
|
||||
connection = op.get_bind()
|
||||
result = connection.execute(sa.text("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'tenant_emp_personal_service_settings'
|
||||
);
|
||||
"""))
|
||||
table_exists = result.fetchone()[0]
|
||||
|
||||
if not table_exists:
|
||||
op.create_table(
|
||||
'tenant_emp_personal_service_settings',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'),
|
||||
nullable=False, comment='租戶 ID'),
|
||||
sa.Column('tenant_keycloak_user_id', sa.String(36), nullable=False,
|
||||
comment='Keycloak User UUID'),
|
||||
sa.Column('service_id', sa.Integer(), sa.ForeignKey('personal_services.id', ondelete='CASCADE'),
|
||||
nullable=False, comment='個人化服務 ID'),
|
||||
sa.Column('quota_gb', sa.Integer(), nullable=True, comment='儲存配額 (GB),適用於 Drive'),
|
||||
sa.Column('quota_mb', sa.Integer(), nullable=True, comment='郵件配額 (MB),適用於 Email'),
|
||||
sa.Column('enabled_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
comment='啟用時間'),
|
||||
sa.Column('enabled_by', sa.String(36), nullable=True, comment='啟用者 keycloak_user_id'),
|
||||
sa.Column('disabled_at', sa.DateTime(), nullable=True, comment='停用時間(軟刪除)'),
|
||||
sa.Column('disabled_by', sa.String(36), nullable=True, comment='停用者 keycloak_user_id'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'),
|
||||
sa.Column('edit_by', sa.String(36), nullable=True, comment='最後編輯者 keycloak_user_id'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.UniqueConstraint('tenant_id', 'tenant_keycloak_user_id', 'service_id', name='uq_emp_service'),
|
||||
sa.Index('idx_emp_service_tenant', 'tenant_id'),
|
||||
sa.Index('idx_emp_service_user', 'tenant_keycloak_user_id'),
|
||||
sa.Index('idx_emp_service_service', 'service_id'),
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# Phase 9: 更新現有觸發器支援 tenant_keycloak_user_id
|
||||
# =========================================================
|
||||
# DepartmentMember 已經使用 employee_id,不需要調整
|
||||
# UserRoleAssignment 已經使用 keycloak_user_id,不需要調整
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢復 ARRAY 欄位
|
||||
op.add_column('tenant_emp_settings', sa.Column('tenant_dep_ids', postgresql.ARRAY(sa.Integer()), comment='部門設定(陣列)'))
|
||||
op.add_column('tenant_emp_settings', sa.Column('tenant_user_roles_ids', postgresql.ARRAY(sa.Integer()), comment='使用者角色(陣列)'))
|
||||
op.add_column('tenant_emp_settings', sa.Column('tenant_user_personal_services', postgresql.ARRAY(sa.Integer()), comment='個人化服務設定(陣列)'))
|
||||
|
||||
# 恢復配額欄位名稱
|
||||
op.alter_column('tenant_emp_settings', 'storage_quota_gb', new_column_name='tenant_user_quota')
|
||||
|
||||
# 移除郵件配額欄位
|
||||
op.drop_column('tenant_emp_settings', 'email_quota_mb')
|
||||
|
||||
# 移除 Keycloak 欄位
|
||||
op.drop_column('tenant_emp_settings', 'tenant_keycloak_username')
|
||||
op.drop_column('tenant_emp_settings', 'tenant_keycloak_user_id')
|
||||
|
||||
# 恢復索引名稱
|
||||
op.execute("""
|
||||
ALTER INDEX IF EXISTS idx_tenant_emp_settings_tenant
|
||||
RENAME TO idx_tenant_emp_setting_tenant;
|
||||
|
||||
ALTER INDEX IF EXISTS idx_tenant_emp_settings_resume
|
||||
RENAME TO idx_tenant_emp_setting_resume;
|
||||
""")
|
||||
|
||||
# 恢復觸發器名稱
|
||||
op.execute("""
|
||||
DROP TRIGGER IF EXISTS trigger_tenant_emp_settings_seq_no ON tenant_emp_settings;
|
||||
DROP TRIGGER IF EXISTS trigger_generate_emp_code ON tenant_emp_settings;
|
||||
|
||||
CREATE TRIGGER trigger_tenant_emp_setting_seq_no
|
||||
BEFORE INSERT ON tenant_emp_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_emp_setting_seq();
|
||||
|
||||
CREATE TRIGGER trigger_generate_emp_code
|
||||
BEFORE INSERT ON tenant_emp_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION generate_tenant_emp_code();
|
||||
""")
|
||||
|
||||
# 恢復表名
|
||||
op.rename_table('tenant_emp_settings', 'tenant_emp_setting')
|
||||
|
||||
# 移除 tenant_emp_personal_service_settings 表(如果是這個 migration 建立的)
|
||||
# 為了安全,downgrade 不刪除此表
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add tenant initialization fields
|
||||
|
||||
Revision ID: 0013
|
||||
Revises: 0526fc6e6496
|
||||
Create Date: 2026-02-21
|
||||
|
||||
新增租戶初始化相關欄位:
|
||||
1. is_initialized - 租戶是否已完成初始化
|
||||
2. initialized_at - 初始化完成時間
|
||||
3. initialized_by - 執行初始化的使用者名稱
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '0013'
|
||||
down_revision = '0526fc6e6496' # 基於最新的 migration
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 新增初始化狀態欄位
|
||||
op.add_column('tenants', sa.Column(
|
||||
'is_initialized',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default='false',
|
||||
comment='是否已完成初始化設定'
|
||||
))
|
||||
|
||||
# 新增初始化時間欄位
|
||||
op.add_column('tenants', sa.Column(
|
||||
'initialized_at',
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
comment='初始化完成時間'
|
||||
))
|
||||
|
||||
# 新增初始化執行者欄位
|
||||
op.add_column('tenants', sa.Column(
|
||||
'initialized_by',
|
||||
sa.String(255),
|
||||
nullable=True,
|
||||
comment='執行初始化的使用者名稱'
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 移除初始化欄位
|
||||
op.drop_column('tenants', 'initialized_by')
|
||||
op.drop_column('tenants', 'initialized_at')
|
||||
op.drop_column('tenants', 'is_initialized')
|
||||
347
backend/alembic/versions/0014_add_installation_system.py
Normal file
347
backend/alembic/versions/0014_add_installation_system.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""add installation system tables
|
||||
|
||||
Revision ID: 0014
|
||||
Revises: 0013
|
||||
Create Date: 2026-02-22 16:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0014'
|
||||
down_revision = '0013'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""建立初始化系統相關資料表"""
|
||||
|
||||
# 1. installation_sessions (安裝會話)
|
||||
# 注意:tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
|
||||
op.create_table(
|
||||
'installation_sessions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL,初始化時可能還沒有 tenant
|
||||
sa.Column('session_name', sa.String(200), nullable=True),
|
||||
sa.Column('environment', sa.String(20), nullable=True),
|
||||
|
||||
# 狀態追蹤
|
||||
sa.Column('started_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('completed_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('status', sa.String(20), server_default='in_progress'),
|
||||
|
||||
# 進度統計
|
||||
sa.Column('total_checklist_items', sa.Integer(), nullable=True),
|
||||
sa.Column('passed_checklist_items', sa.Integer(), server_default='0'),
|
||||
sa.Column('failed_checklist_items', sa.Integer(), server_default='0'),
|
||||
sa.Column('total_steps', sa.Integer(), nullable=True),
|
||||
sa.Column('completed_steps', sa.Integer(), server_default='0'),
|
||||
sa.Column('failed_steps', sa.Integer(), server_default='0'),
|
||||
|
||||
sa.Column('executed_by', sa.String(100), nullable=True),
|
||||
|
||||
# 存取控制
|
||||
sa.Column('is_locked', sa.Boolean(), server_default='false'),
|
||||
sa.Column('locked_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('locked_by', sa.String(100), nullable=True),
|
||||
sa.Column('lock_reason', sa.String(200), nullable=True),
|
||||
|
||||
sa.Column('is_unlocked', sa.Boolean(), server_default='false'),
|
||||
sa.Column('unlocked_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('unlocked_by', sa.String(100), nullable=True),
|
||||
sa.Column('unlock_reason', sa.String(200), nullable=True),
|
||||
sa.Column('unlock_expires_at', sa.TIMESTAMP(), nullable=True),
|
||||
|
||||
sa.Column('last_viewed_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('last_viewed_by', sa.String(100), nullable=True),
|
||||
sa.Column('view_count', sa.Integer(), server_default='0'),
|
||||
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
# 移除外鍵約束,因為初始化時 tenants 表可能還不存在
|
||||
)
|
||||
op.create_index('idx_installation_sessions_tenant', 'installation_sessions', ['tenant_id'])
|
||||
op.create_index('idx_installation_sessions_status', 'installation_sessions', ['status'])
|
||||
|
||||
# 2. installation_checklist_items (檢查項目定義)
|
||||
op.create_table(
|
||||
'installation_checklist_items',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('category', sa.String(50), nullable=False),
|
||||
sa.Column('item_code', sa.String(100), unique=True, nullable=False),
|
||||
sa.Column('item_name', sa.String(200), nullable=False),
|
||||
sa.Column('check_type', sa.String(50), nullable=False),
|
||||
sa.Column('check_command', sa.Text(), nullable=True),
|
||||
sa.Column('expected_value', sa.Text(), nullable=True),
|
||||
sa.Column('min_requirement', sa.Text(), nullable=True),
|
||||
sa.Column('recommended_value', sa.Text(), nullable=True),
|
||||
sa.Column('is_required', sa.Boolean(), server_default='true'),
|
||||
sa.Column('sequence_order', sa.Integer(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_checklist_items_category', 'installation_checklist_items', ['category'])
|
||||
op.create_index('idx_checklist_items_order', 'installation_checklist_items', ['sequence_order'])
|
||||
|
||||
# 3. installation_checklist_results (檢查結果)
|
||||
# 注意:tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
|
||||
op.create_table(
|
||||
'installation_checklist_results',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
|
||||
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||
sa.Column('checklist_item_id', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(20), nullable=False),
|
||||
sa.Column('actual_value', sa.Text(), nullable=True),
|
||||
sa.Column('checked_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('checked_by', sa.String(100), nullable=True),
|
||||
sa.Column('auto_checked', sa.Boolean(), server_default='false'),
|
||||
sa.Column('remarks', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
# 移除 tenant_id 外鍵約束
|
||||
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['checklist_item_id'], ['installation_checklist_items.id'], ondelete='CASCADE')
|
||||
)
|
||||
op.create_index('idx_checklist_results_session', 'installation_checklist_results', ['session_id'])
|
||||
op.create_index('idx_checklist_results_status', 'installation_checklist_results', ['status'])
|
||||
|
||||
# 4. installation_steps (安裝步驟定義)
|
||||
op.create_table(
|
||||
'installation_steps',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('step_code', sa.String(50), unique=True, nullable=False),
|
||||
sa.Column('step_name', sa.String(200), nullable=False),
|
||||
sa.Column('phase', sa.String(20), nullable=False),
|
||||
sa.Column('sequence_order', sa.Integer(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('execution_type', sa.String(50), nullable=True),
|
||||
sa.Column('execution_script', sa.Text(), nullable=True),
|
||||
sa.Column('depends_on_steps', postgresql.ARRAY(sa.String()), nullable=True),
|
||||
sa.Column('is_required', sa.Boolean(), server_default='true'),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_installation_steps_phase', 'installation_steps', ['phase'])
|
||||
op.create_index('idx_installation_steps_order', 'installation_steps', ['sequence_order'])
|
||||
|
||||
# 5. installation_logs (安裝執行記錄)
|
||||
# 注意:tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
|
||||
op.create_table(
|
||||
'installation_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
|
||||
sa.Column('step_id', sa.Integer(), nullable=False),
|
||||
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(20), nullable=False),
|
||||
sa.Column('started_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('completed_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('executed_by', sa.String(100), nullable=True),
|
||||
sa.Column('execution_method', sa.String(50), nullable=True),
|
||||
sa.Column('result_data', postgresql.JSONB(), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('retry_count', sa.Integer(), server_default='0'),
|
||||
sa.Column('remarks', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
# 移除 tenant_id 外鍵約束
|
||||
sa.ForeignKeyConstraint(['step_id'], ['installation_steps.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE')
|
||||
)
|
||||
op.create_index('idx_installation_logs_session', 'installation_logs', ['session_id'])
|
||||
op.create_index('idx_installation_logs_status', 'installation_logs', ['status'])
|
||||
|
||||
# 6. installation_tenant_info (租戶初始化資訊)
|
||||
# 注意:tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
|
||||
op.create_table(
|
||||
'installation_tenant_info',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), unique=True, nullable=True), # 允許 NULL
|
||||
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||
|
||||
# 公司基本資訊
|
||||
sa.Column('company_name', sa.String(200), nullable=True),
|
||||
sa.Column('company_name_en', sa.String(200), nullable=True),
|
||||
sa.Column('tax_id', sa.String(50), nullable=True),
|
||||
sa.Column('industry', sa.String(100), nullable=True),
|
||||
sa.Column('company_size', sa.String(20), nullable=True),
|
||||
|
||||
# 聯絡資訊
|
||||
sa.Column('phone', sa.String(50), nullable=True),
|
||||
sa.Column('fax', sa.String(50), nullable=True),
|
||||
sa.Column('email', sa.String(200), nullable=True),
|
||||
sa.Column('website', sa.String(200), nullable=True),
|
||||
sa.Column('address', sa.Text(), nullable=True),
|
||||
sa.Column('address_en', sa.Text(), nullable=True),
|
||||
|
||||
# 負責人資訊
|
||||
sa.Column('representative_name', sa.String(100), nullable=True),
|
||||
sa.Column('representative_title', sa.String(100), nullable=True),
|
||||
sa.Column('representative_email', sa.String(200), nullable=True),
|
||||
sa.Column('representative_phone', sa.String(50), nullable=True),
|
||||
|
||||
# 系統管理員資訊
|
||||
sa.Column('admin_employee_id', sa.String(50), nullable=True),
|
||||
sa.Column('admin_username', sa.String(100), nullable=True),
|
||||
sa.Column('admin_legal_name', sa.String(100), nullable=True),
|
||||
sa.Column('admin_english_name', sa.String(100), nullable=True),
|
||||
sa.Column('admin_email', sa.String(200), nullable=True),
|
||||
sa.Column('admin_phone', sa.String(50), nullable=True),
|
||||
|
||||
# 初始設定
|
||||
sa.Column('default_language', sa.String(10), server_default='zh-TW'),
|
||||
sa.Column('timezone', sa.String(50), server_default='Asia/Taipei'),
|
||||
sa.Column('date_format', sa.String(20), server_default='YYYY-MM-DD'),
|
||||
sa.Column('currency', sa.String(10), server_default='TWD'),
|
||||
|
||||
# 狀態追蹤
|
||||
sa.Column('is_completed', sa.Boolean(), server_default='false'),
|
||||
sa.Column('completed_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('completed_by', sa.String(100), nullable=True),
|
||||
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
# 移除 tenant_id 外鍵約束
|
||||
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='SET NULL')
|
||||
)
|
||||
|
||||
# 7. installation_department_setup (部門架構設定)
|
||||
# 注意:tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
|
||||
op.create_table(
|
||||
'installation_department_setup',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
|
||||
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||
sa.Column('department_code', sa.String(50), nullable=False),
|
||||
sa.Column('department_name', sa.String(200), nullable=False),
|
||||
sa.Column('department_name_en', sa.String(200), nullable=True),
|
||||
sa.Column('email_domain', sa.String(100), nullable=True),
|
||||
sa.Column('parent_code', sa.String(50), nullable=True),
|
||||
sa.Column('depth', sa.Integer(), server_default='0'),
|
||||
sa.Column('manager_name', sa.String(100), nullable=True),
|
||||
sa.Column('is_created', sa.Boolean(), server_default='false'),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
# 移除 tenant_id 外鍵約束
|
||||
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE')
|
||||
)
|
||||
op.create_index('idx_dept_setup_session', 'installation_department_setup', ['session_id'])
|
||||
|
||||
# 8. temporary_passwords (臨時密碼)
|
||||
# 注意:tenant_id 和 employee_id 不設外鍵,因為初始化時這些表可能還不存在
|
||||
op.create_table(
|
||||
'temporary_passwords',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
|
||||
sa.Column('employee_id', sa.Integer(), nullable=True), # 允許 NULL,不設外鍵
|
||||
sa.Column('username', sa.String(100), nullable=False),
|
||||
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||
|
||||
# 密碼資訊
|
||||
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||
sa.Column('plain_password', sa.String(100), nullable=True),
|
||||
sa.Column('password_method', sa.String(20), nullable=True),
|
||||
sa.Column('is_temporary', sa.Boolean(), server_default='true'),
|
||||
sa.Column('must_change_on_login', sa.Boolean(), server_default='true'),
|
||||
|
||||
# 有效期限
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('expires_at', sa.TIMESTAMP(), nullable=True),
|
||||
|
||||
# 使用狀態
|
||||
sa.Column('is_used', sa.Boolean(), server_default='false'),
|
||||
sa.Column('used_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('first_login_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('password_changed_at', sa.TIMESTAMP(), nullable=True),
|
||||
|
||||
# 查看控制
|
||||
sa.Column('is_viewable', sa.Boolean(), server_default='true'),
|
||||
sa.Column('viewable_until', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('view_count', sa.Integer(), server_default='0'),
|
||||
sa.Column('last_viewed_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('first_viewed_at', sa.TIMESTAMP(), nullable=True),
|
||||
|
||||
# 明文密碼清除記錄
|
||||
sa.Column('plain_password_cleared_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('cleared_reason', sa.String(100), nullable=True),
|
||||
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
# 移除 tenant_id 和 employee_id 的外鍵約束
|
||||
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='SET NULL')
|
||||
)
|
||||
op.create_index('idx_temp_pwd_username', 'temporary_passwords', ['username'])
|
||||
op.create_index('idx_temp_pwd_session', 'temporary_passwords', ['session_id'])
|
||||
|
||||
# 9. installation_access_logs (存取審計日誌)
|
||||
op.create_table(
|
||||
'installation_access_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('session_id', sa.Integer(), nullable=False),
|
||||
sa.Column('action', sa.String(50), nullable=False),
|
||||
sa.Column('action_by', sa.String(100), nullable=True),
|
||||
sa.Column('action_method', sa.String(50), nullable=True),
|
||||
sa.Column('ip_address', sa.String(50), nullable=True),
|
||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||
sa.Column('access_granted', sa.Boolean(), nullable=True),
|
||||
sa.Column('deny_reason', sa.String(200), nullable=True),
|
||||
sa.Column('sensitive_data_accessed', postgresql.ARRAY(sa.String()), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE')
|
||||
)
|
||||
op.create_index('idx_access_logs_session', 'installation_access_logs', ['session_id'])
|
||||
op.create_index('idx_access_logs_action', 'installation_access_logs', ['action'])
|
||||
op.create_index('idx_access_logs_created', 'installation_access_logs', ['created_at'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""移除初始化系統相關資料表"""
|
||||
|
||||
op.drop_index('idx_access_logs_created', 'installation_access_logs')
|
||||
op.drop_index('idx_access_logs_action', 'installation_access_logs')
|
||||
op.drop_index('idx_access_logs_session', 'installation_access_logs')
|
||||
op.drop_table('installation_access_logs')
|
||||
|
||||
op.drop_index('idx_temp_pwd_session', 'temporary_passwords')
|
||||
op.drop_index('idx_temp_pwd_username', 'temporary_passwords')
|
||||
op.drop_table('temporary_passwords')
|
||||
|
||||
op.drop_index('idx_dept_setup_session', 'installation_department_setup')
|
||||
op.drop_table('installation_department_setup')
|
||||
|
||||
op.drop_table('installation_tenant_info')
|
||||
|
||||
op.drop_index('idx_installation_logs_status', 'installation_logs')
|
||||
op.drop_index('idx_installation_logs_session', 'installation_logs')
|
||||
op.drop_table('installation_logs')
|
||||
|
||||
op.drop_index('idx_installation_steps_order', 'installation_steps')
|
||||
op.drop_index('idx_installation_steps_phase', 'installation_steps')
|
||||
op.drop_table('installation_steps')
|
||||
|
||||
op.drop_index('idx_checklist_results_status', 'installation_checklist_results')
|
||||
op.drop_index('idx_checklist_results_session', 'installation_checklist_results')
|
||||
op.drop_table('installation_checklist_results')
|
||||
|
||||
op.drop_index('idx_checklist_items_order', 'installation_checklist_items')
|
||||
op.drop_index('idx_checklist_items_category', 'installation_checklist_items')
|
||||
op.drop_table('installation_checklist_items')
|
||||
|
||||
op.drop_index('idx_installation_sessions_status', 'installation_sessions')
|
||||
op.drop_index('idx_installation_sessions_tenant', 'installation_sessions')
|
||||
op.drop_table('installation_sessions')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
"""remove_deprecated_tenant_employees_table
|
||||
|
||||
Revision ID: 5e95bf5ff0af
|
||||
Revises: 844ac73765a3
|
||||
Create Date: 2026-02-23 01:07:49.244754
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5e95bf5ff0af'
|
||||
down_revision: Union[str, None] = '844ac73765a3'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 警告:這個 migration 會刪除所有相關的測試資料
|
||||
# 確保在開發環境執行,不要在正式環境執行
|
||||
|
||||
# Step 1: 刪除所有參照 tenant_employees 的外鍵約束
|
||||
op.drop_constraint('department_members_employee_id_fkey', 'tenant_dept_members', type_='foreignkey')
|
||||
op.drop_constraint('email_accounts_employee_id_fkey', 'tenant_email_accounts', type_='foreignkey')
|
||||
op.drop_constraint('network_drives_employee_id_fkey', 'tenant_network_drives', type_='foreignkey')
|
||||
op.drop_constraint('permissions_employee_id_fkey', 'tenant_permissions', type_='foreignkey')
|
||||
op.drop_constraint('permissions_granted_by_fkey', 'tenant_permissions', type_='foreignkey')
|
||||
|
||||
# Step 2: 清空相關表的測試資料(因為外鍵已被移除)
|
||||
op.execute('TRUNCATE TABLE tenant_dept_members CASCADE')
|
||||
op.execute('TRUNCATE TABLE tenant_email_accounts CASCADE')
|
||||
op.execute('TRUNCATE TABLE tenant_network_drives CASCADE')
|
||||
op.execute('TRUNCATE TABLE tenant_permissions CASCADE')
|
||||
|
||||
# Step 3: 刪除 tenant_employees 表
|
||||
op.drop_table('tenant_employees')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 不支援 downgrade(無法復原已刪除的資料)
|
||||
raise NotImplementedError("Cannot downgrade from removing tenant_employees table")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add tenant code prefix and domain fields to installation_tenant_info
|
||||
|
||||
Revision ID: 844ac73765a3
|
||||
Revises: ba655ef20407
|
||||
Create Date: 2026-02-23 00:33:56.554891
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '844ac73765a3'
|
||||
down_revision: Union[str, None] = 'ba655ef20407'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 新增租戶代碼和員工編號前綴欄位
|
||||
op.add_column('installation_tenant_info', sa.Column('tenant_code', sa.String(50), nullable=True))
|
||||
op.add_column('installation_tenant_info', sa.Column('tenant_prefix', sa.String(10), nullable=True))
|
||||
|
||||
# 新增郵件網域相關欄位
|
||||
op.add_column('installation_tenant_info', sa.Column('domain_set', sa.Integer, nullable=True, server_default='2'))
|
||||
op.add_column('installation_tenant_info', sa.Column('domain', sa.String(100), nullable=True))
|
||||
|
||||
# 新增公司聯絡資訊欄位(對應 tenants 表)
|
||||
op.add_column('installation_tenant_info', sa.Column('tel', sa.String(20), nullable=True))
|
||||
op.add_column('installation_tenant_info', sa.Column('add', sa.Text, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 移除欄位
|
||||
op.drop_column('installation_tenant_info', 'add')
|
||||
op.drop_column('installation_tenant_info', 'tel')
|
||||
op.drop_column('installation_tenant_info', 'domain')
|
||||
op.drop_column('installation_tenant_info', 'domain_set')
|
||||
op.drop_column('installation_tenant_info', 'tenant_prefix')
|
||||
op.drop_column('installation_tenant_info', 'tenant_code')
|
||||
@@ -0,0 +1,78 @@
|
||||
"""create_system_functions_table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 5e95bf5ff0af
|
||||
Create Date: 2026-02-23 10:40:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '5e95bf5ff0af'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 建立 system_functions 資料表
|
||||
op.create_table(
|
||||
'system_functions',
|
||||
sa.Column('id', sa.Integer(), nullable=False, comment='資料編號'),
|
||||
sa.Column('code', sa.String(length=200), nullable=False, comment='系統功能代碼/功能英文名稱'),
|
||||
sa.Column('upper_function_id', sa.Integer(), nullable=False, server_default='0', comment='上層功能代碼 (0為初始層)'),
|
||||
sa.Column('name', sa.String(length=200), nullable=False, comment='系統功能中文名稱'),
|
||||
sa.Column('function_type', sa.Integer(), nullable=False, comment='系統功能類型 (1:node, 2:function)'),
|
||||
sa.Column('order', sa.Integer(), nullable=False, comment='系統功能次序'),
|
||||
sa.Column('function_icon', sa.String(length=200), nullable=False, server_default='', comment='功能圖示'),
|
||||
sa.Column('module_code', sa.String(length=200), nullable=True, comment='功能模組名稱 (function_type=2 必填)'),
|
||||
sa.Column('module_functions', postgresql.JSON(), nullable=False, server_default='[]', comment='模組項目 (View,Create,Read,Update,Delete,Print,File)'),
|
||||
sa.Column('description', sa.Text(), nullable=False, server_default='', comment='說明 (富文本格式)'),
|
||||
sa.Column('is_mana', sa.Boolean(), nullable=False, server_default='true', comment='系統管理'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='啟用'),
|
||||
sa.Column('edit_by', sa.Integer(), nullable=False, comment='資料建立者'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), comment='資料最新建立時間'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True, onupdate=sa.func.now(), comment='資料最新修改時間'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# 建立索引
|
||||
op.create_index('ix_system_functions_code', 'system_functions', ['code'])
|
||||
op.create_index('ix_system_functions_upper_function_id', 'system_functions', ['upper_function_id'])
|
||||
op.create_index('ix_system_functions_function_type', 'system_functions', ['function_type'])
|
||||
op.create_index('ix_system_functions_is_active', 'system_functions', ['is_active'])
|
||||
|
||||
# 建立自動編號序列 (從 10 開始)
|
||||
op.execute("""
|
||||
CREATE SEQUENCE system_functions_id_seq
|
||||
START WITH 10
|
||||
INCREMENT BY 1
|
||||
MINVALUE 10
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
""")
|
||||
|
||||
# 設定 id 欄位使用序列
|
||||
op.execute("""
|
||||
ALTER TABLE system_functions
|
||||
ALTER COLUMN id SET DEFAULT nextval('system_functions_id_seq');
|
||||
""")
|
||||
|
||||
# 將序列所有權給予資料表
|
||||
op.execute("""
|
||||
ALTER SEQUENCE system_functions_id_seq
|
||||
OWNED BY system_functions.id;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_system_functions_is_active', table_name='system_functions')
|
||||
op.drop_index('ix_system_functions_function_type', table_name='system_functions')
|
||||
op.drop_index('ix_system_functions_upper_function_id', table_name='system_functions')
|
||||
op.drop_index('ix_system_functions_code', table_name='system_functions')
|
||||
op.execute('DROP SEQUENCE IF EXISTS system_functions_id_seq CASCADE')
|
||||
op.drop_table('system_functions')
|
||||
@@ -0,0 +1,78 @@
|
||||
"""add system status table
|
||||
|
||||
Revision ID: ba655ef20407
|
||||
Revises: ddbf7bb95812
|
||||
Create Date: 2026-02-22 20:47:37.691492
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ba655ef20407'
|
||||
down_revision: Union[str, None] = 'ddbf7bb95812'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""建立系統狀態記錄表"""
|
||||
|
||||
# installation_system_status (系統狀態記錄)
|
||||
# 用途:記錄系統當前所處的階段 (Initialization/Operational/Transition)
|
||||
op.create_table(
|
||||
'installation_system_status',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('current_phase', sa.String(20), nullable=False), # initialization/operational/transition
|
||||
sa.Column('previous_phase', sa.String(20), nullable=True),
|
||||
sa.Column('phase_changed_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('phase_changed_by', sa.String(100), nullable=True),
|
||||
sa.Column('phase_change_reason', sa.Text(), nullable=True),
|
||||
|
||||
# Initialization 階段資訊
|
||||
sa.Column('initialized_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('initialized_by', sa.String(100), nullable=True),
|
||||
sa.Column('initialization_completed', sa.Boolean(), server_default='false'),
|
||||
|
||||
# Operational 階段資訊
|
||||
sa.Column('last_health_check_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('health_check_status', sa.String(20), nullable=True), # healthy/degraded/unhealthy
|
||||
sa.Column('operational_since', sa.TIMESTAMP(), nullable=True),
|
||||
|
||||
# Transition 階段資訊
|
||||
sa.Column('transition_started_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('transition_approved_by', sa.String(100), nullable=True),
|
||||
sa.Column('env_db_consistent', sa.Boolean(), nullable=True),
|
||||
sa.Column('consistency_checked_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('inconsistencies', sa.Text(), nullable=True), # JSON 格式記錄不一致項目
|
||||
|
||||
# 系統鎖定(防止誤操作)
|
||||
sa.Column('is_locked', sa.Boolean(), server_default='false'),
|
||||
sa.Column('locked_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('locked_by', sa.String(100), nullable=True),
|
||||
sa.Column('lock_reason', sa.String(200), nullable=True),
|
||||
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
op.create_index('idx_system_status_phase', 'installation_system_status', ['current_phase'])
|
||||
|
||||
# 插入預設記錄(初始階段)
|
||||
op.execute("""
|
||||
INSERT INTO installation_system_status
|
||||
(current_phase, initialization_completed, is_locked)
|
||||
VALUES ('initialization', false, false)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除系統狀態記錄表"""
|
||||
|
||||
op.drop_index('idx_system_status_phase', 'installation_system_status')
|
||||
op.drop_table('installation_system_status')
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add environment config table
|
||||
|
||||
Revision ID: ddbf7bb95812
|
||||
Revises: 0014
|
||||
Create Date: 2026-02-22 20:32:58.070446
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ddbf7bb95812'
|
||||
down_revision: Union[str, None] = '0014'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""建立環境配置資料表"""
|
||||
|
||||
# installation_environment_config (環境配置記錄)
|
||||
# 用途:記錄所有環境配置資訊,供初始化檢查使用
|
||||
op.create_table(
|
||||
'installation_environment_config',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||
sa.Column('config_key', sa.String(100), nullable=False, unique=True),
|
||||
sa.Column('config_value', sa.Text(), nullable=True),
|
||||
sa.Column('config_category', sa.String(50), nullable=False), # redis/database/keycloak/mailserver/nextcloud/traefik
|
||||
sa.Column('is_sensitive', sa.Boolean(), server_default='false'),
|
||||
sa.Column('is_configured', sa.Boolean(), server_default='false'),
|
||||
sa.Column('configured_at', sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column('configured_by', sa.String(100), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='SET NULL')
|
||||
)
|
||||
|
||||
op.create_index('idx_env_config_key', 'installation_environment_config', ['config_key'])
|
||||
op.create_index('idx_env_config_category', 'installation_environment_config', ['config_category'])
|
||||
op.create_index('idx_env_config_session', 'installation_environment_config', ['session_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除環境配置資料表"""
|
||||
|
||||
op.drop_index('idx_env_config_session', 'installation_environment_config')
|
||||
op.drop_index('idx_env_config_category', 'installation_environment_config')
|
||||
op.drop_index('idx_env_config_key', 'installation_environment_config')
|
||||
op.drop_table('installation_environment_config')
|
||||
30
backend/alembic/versions/fba4e3f40f05_initial_schema.py
Normal file
30
backend/alembic/versions/fba4e3f40f05_initial_schema.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: fba4e3f40f05
|
||||
Revises:
|
||||
Create Date: 2026-02-12 11:42:34.613474
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'fba4e3f40f05'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
222
backend/app/api/deps.py
Normal file
222
backend/app/api/deps.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
API 依賴項
|
||||
用於依賴注入
|
||||
"""
|
||||
from typing import Generator, Optional, Dict, Any
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.base import PaginationParams
|
||||
from app.services.keycloak_service import keycloak_service
|
||||
from app.models import Tenant
|
||||
|
||||
# OAuth2 Bearer Token Scheme
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_pagination_params(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> PaginationParams:
|
||||
"""
|
||||
獲取分頁參數
|
||||
|
||||
Args:
|
||||
page: 頁碼 (從 1 開始)
|
||||
page_size: 每頁數量
|
||||
|
||||
Returns:
|
||||
PaginationParams: 分頁參數
|
||||
|
||||
Raises:
|
||||
HTTPException: 參數驗證失敗
|
||||
"""
|
||||
if page < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Page must be greater than 0"
|
||||
)
|
||||
|
||||
if page_size < 1 or page_size > 100:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Page size must be between 1 and 100"
|
||||
)
|
||||
|
||||
return PaginationParams(page=page, page_size=page_size)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
獲取當前登入用戶 (從 JWT Token)
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer Token
|
||||
|
||||
Returns:
|
||||
dict: 用戶資訊 (包含 username, email, sub 等)
|
||||
None: 未提供 Token 或 Token 無效時
|
||||
|
||||
Raises:
|
||||
HTTPException: Token 無效時拋出 401
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
# 驗證 Token
|
||||
user_info = keycloak_service.get_user_info_from_token(token)
|
||||
|
||||
if not user_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
def require_auth(
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
要求必須認證
|
||||
|
||||
Args:
|
||||
current_user: 當前用戶資訊
|
||||
|
||||
Returns:
|
||||
dict: 用戶資訊
|
||||
|
||||
Raises:
|
||||
HTTPException: 未認證時拋出 401
|
||||
"""
|
||||
if not current_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
def check_permission(required_roles: list = None):
|
||||
"""
|
||||
檢查用戶權限 (基於角色)
|
||||
|
||||
Args:
|
||||
required_roles: 需要的角色列表 (例如: ["admin", "hr_manager"])
|
||||
|
||||
Returns:
|
||||
function: 權限檢查函數
|
||||
|
||||
使用範例:
|
||||
@router.get("/admin-only", dependencies=[Depends(check_permission(["admin"]))])
|
||||
"""
|
||||
if required_roles is None:
|
||||
required_roles = []
|
||||
|
||||
def permission_checker(
|
||||
current_user: Dict[str, Any] = Depends(require_auth)
|
||||
) -> Dict[str, Any]:
|
||||
"""檢查用戶是否有所需權限"""
|
||||
# TODO: 從 Keycloak Token 解析用戶角色
|
||||
# 目前暫時允許所有已認證用戶
|
||||
user_roles = current_user.get("realm_access", {}).get("roles", [])
|
||||
|
||||
if required_roles:
|
||||
has_permission = any(role in user_roles for role in required_roles)
|
||||
if not has_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Required roles: {', '.join(required_roles)}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return permission_checker
|
||||
|
||||
|
||||
def get_current_tenant(
|
||||
current_user: Dict[str, Any] = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Tenant:
|
||||
"""
|
||||
獲取當前租戶 (從 JWT Token 的 realm)
|
||||
|
||||
多租戶架構:每個租戶對應一個 Keycloak Realm
|
||||
- JWT Token 來自哪個 Realm,就屬於哪個租戶
|
||||
- 透過 iss (Issuer) 欄位解析 Realm 名稱
|
||||
- 查詢 tenants 表找到對應租戶
|
||||
|
||||
Args:
|
||||
current_user: 當前用戶資訊 (含 iss)
|
||||
db: 資料庫 Session
|
||||
|
||||
Returns:
|
||||
Tenant: 租戶物件
|
||||
|
||||
Raises:
|
||||
HTTPException: 租戶不存在或未啟用時拋出 403
|
||||
|
||||
範例:
|
||||
iss: "https://auth.lab.taipei/realms/porscheworld"
|
||||
→ realm_name: "porscheworld"
|
||||
→ tenant.keycloak_realm: "porscheworld"
|
||||
"""
|
||||
# 從 JWT iss 欄位解析 Realm 名稱
|
||||
# iss 格式: "https://{domain}/realms/{realm_name}"
|
||||
iss = current_user.get("iss", "")
|
||||
|
||||
if not iss:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token: missing issuer"
|
||||
)
|
||||
|
||||
# 解析 realm_name
|
||||
try:
|
||||
realm_name = iss.split("/realms/")[-1]
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token: cannot parse realm"
|
||||
)
|
||||
|
||||
# 查詢租戶
|
||||
tenant = db.query(Tenant).filter(
|
||||
Tenant.keycloak_realm == realm_name,
|
||||
Tenant.is_active == True
|
||||
).first()
|
||||
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Tenant not found or inactive: {realm_name}"
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
def get_tenant_id(
|
||||
tenant: Tenant = Depends(get_current_tenant)
|
||||
) -> int:
|
||||
"""
|
||||
獲取當前租戶 ID (簡化版)
|
||||
|
||||
用於只需要 tenant_id 的場景
|
||||
|
||||
Args:
|
||||
tenant: 租戶物件
|
||||
|
||||
Returns:
|
||||
int: 租戶 ID
|
||||
"""
|
||||
return tenant.id
|
||||
3
backend/app/api/v1/__init__.py
Normal file
3
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API v1 模組
|
||||
"""
|
||||
209
backend/app/api/v1/audit_logs.py
Normal file
209
backend/app/api/v1/audit_logs.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
審計日誌 API
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.schemas.audit_log import (
|
||||
AuditLogResponse,
|
||||
AuditLogListItem,
|
||||
AuditLogFilter,
|
||||
)
|
||||
from app.schemas.base import PaginationParams, PaginatedResponse
|
||||
from app.api.deps import get_pagination_params
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse)
|
||||
def get_audit_logs(
|
||||
db: Session = Depends(get_db),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
action: Optional[str] = Query(None, description="操作類型篩選"),
|
||||
resource_type: Optional[str] = Query(None, description="資源類型篩選"),
|
||||
resource_id: Optional[int] = Query(None, description="資源 ID 篩選"),
|
||||
performed_by: Optional[str] = Query(None, description="操作者篩選"),
|
||||
start_date: Optional[datetime] = Query(None, description="開始日期"),
|
||||
end_date: Optional[datetime] = Query(None, description="結束日期"),
|
||||
):
|
||||
"""
|
||||
獲取審計日誌列表
|
||||
|
||||
支援:
|
||||
- 分頁
|
||||
- 多種篩選條件
|
||||
- 時間範圍篩選
|
||||
"""
|
||||
query = db.query(AuditLog)
|
||||
|
||||
# 操作類型篩選
|
||||
if action:
|
||||
query = query.filter(AuditLog.action == action)
|
||||
|
||||
# 資源類型篩選
|
||||
if resource_type:
|
||||
query = query.filter(AuditLog.resource_type == resource_type)
|
||||
|
||||
# 資源 ID 篩選
|
||||
if resource_id is not None:
|
||||
query = query.filter(AuditLog.resource_id == resource_id)
|
||||
|
||||
# 操作者篩選
|
||||
if performed_by:
|
||||
query = query.filter(AuditLog.performed_by.ilike(f"%{performed_by}%"))
|
||||
|
||||
# 時間範圍篩選
|
||||
if start_date:
|
||||
query = query.filter(AuditLog.performed_at >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(AuditLog.performed_at <= end_date)
|
||||
|
||||
# 總數
|
||||
total = query.count()
|
||||
|
||||
# 分頁 (按時間倒序)
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
audit_logs = query.order_by(
|
||||
AuditLog.performed_at.desc()
|
||||
).offset(offset).limit(pagination.page_size).all()
|
||||
|
||||
# 計算總頁數
|
||||
total_pages = (total + pagination.page_size - 1) // pagination.page_size
|
||||
|
||||
return PaginatedResponse(
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
total_pages=total_pages,
|
||||
items=[AuditLogListItem.model_validate(log) for log in audit_logs],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{audit_log_id}", response_model=AuditLogResponse)
|
||||
def get_audit_log(
|
||||
audit_log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取審計日誌詳情
|
||||
"""
|
||||
audit_log = db.query(AuditLog).filter(
|
||||
AuditLog.id == audit_log_id
|
||||
).first()
|
||||
|
||||
if not audit_log:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Audit log with id {audit_log_id} not found"
|
||||
)
|
||||
|
||||
return AuditLogResponse.model_validate(audit_log)
|
||||
|
||||
|
||||
@router.get("/resource/{resource_type}/{resource_id}", response_model=List[AuditLogListItem])
|
||||
def get_resource_audit_logs(
|
||||
resource_type: str,
|
||||
resource_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取特定資源的所有審計日誌
|
||||
|
||||
Args:
|
||||
resource_type: 資源類型 (employee, identity, department, etc.)
|
||||
resource_id: 資源 ID
|
||||
|
||||
Returns:
|
||||
該資源的所有操作記錄 (按時間倒序)
|
||||
"""
|
||||
audit_logs = db.query(AuditLog).filter(
|
||||
AuditLog.resource_type == resource_type,
|
||||
AuditLog.resource_id == resource_id
|
||||
).order_by(AuditLog.performed_at.desc()).all()
|
||||
|
||||
return [AuditLogListItem.model_validate(log) for log in audit_logs]
|
||||
|
||||
|
||||
@router.get("/user/{username}", response_model=List[AuditLogListItem])
|
||||
def get_user_audit_logs(
|
||||
username: str,
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = Query(100, le=1000, description="限制返回數量"),
|
||||
):
|
||||
"""
|
||||
獲取特定用戶的操作記錄
|
||||
|
||||
Args:
|
||||
username: 操作者 SSO 帳號
|
||||
limit: 限制返回數量 (預設 100,最大 1000)
|
||||
|
||||
Returns:
|
||||
該用戶的操作記錄 (按時間倒序)
|
||||
"""
|
||||
audit_logs = db.query(AuditLog).filter(
|
||||
AuditLog.performed_by == username
|
||||
).order_by(AuditLog.performed_at.desc()).limit(limit).all()
|
||||
|
||||
return [AuditLogListItem.model_validate(log) for log in audit_logs]
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
def get_audit_stats(
|
||||
db: Session = Depends(get_db),
|
||||
start_date: Optional[datetime] = Query(None, description="開始日期"),
|
||||
end_date: Optional[datetime] = Query(None, description="結束日期"),
|
||||
):
|
||||
"""
|
||||
獲取審計日誌統計
|
||||
|
||||
返回:
|
||||
- 按操作類型分組的統計
|
||||
- 按資源類型分組的統計
|
||||
- 操作頻率最高的用戶
|
||||
"""
|
||||
query = db.query(AuditLog)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(AuditLog.performed_at >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.filter(AuditLog.performed_at <= end_date)
|
||||
|
||||
# 總操作數
|
||||
total_operations = query.count()
|
||||
|
||||
# 按操作類型統計
|
||||
from sqlalchemy import func
|
||||
action_stats = db.query(
|
||||
AuditLog.action,
|
||||
func.count(AuditLog.id).label('count')
|
||||
).group_by(AuditLog.action).all()
|
||||
|
||||
# 按資源類型統計
|
||||
resource_stats = db.query(
|
||||
AuditLog.resource_type,
|
||||
func.count(AuditLog.id).label('count')
|
||||
).group_by(AuditLog.resource_type).all()
|
||||
|
||||
# 操作最多的用戶 (Top 10)
|
||||
top_users = db.query(
|
||||
AuditLog.performed_by,
|
||||
func.count(AuditLog.id).label('count')
|
||||
).group_by(AuditLog.performed_by).order_by(
|
||||
func.count(AuditLog.id).desc()
|
||||
).limit(10).all()
|
||||
|
||||
return {
|
||||
"total_operations": total_operations,
|
||||
"by_action": {action: count for action, count in action_stats},
|
||||
"by_resource_type": {resource: count for resource, count in resource_stats},
|
||||
"top_users": [
|
||||
{"username": user, "operations": count}
|
||||
for user, count in top_users
|
||||
]
|
||||
}
|
||||
362
backend/app/api/v1/auth.py
Normal file
362
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
認證 API
|
||||
處理登入、登出、Token 管理
|
||||
"""
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo
|
||||
from app.schemas.response import MessageResponse
|
||||
from app.api.deps import get_current_user, require_auth
|
||||
from app.services.keycloak_service import keycloak_service
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
用戶登入
|
||||
|
||||
使用 Keycloak Direct Access Grant (Resource Owner Password Credentials)
|
||||
獲取 Access Token 和 Refresh Token
|
||||
|
||||
Args:
|
||||
login_data: 登入憑證 (username, password)
|
||||
request: HTTP Request (用於獲取 IP)
|
||||
db: 資料庫 Session
|
||||
|
||||
Returns:
|
||||
TokenResponse: Access Token 和 Refresh Token
|
||||
|
||||
Raises:
|
||||
HTTPException: 登入失敗時拋出 401
|
||||
"""
|
||||
try:
|
||||
# 使用 Keycloak 進行認證
|
||||
token_response = keycloak_service.openid.token(
|
||||
login_data.username,
|
||||
login_data.password
|
||||
)
|
||||
|
||||
# 記錄登入成功的審計日誌
|
||||
audit_service.log_login(
|
||||
db=db,
|
||||
username=login_data.username,
|
||||
ip_address=audit_service.get_client_ip(request),
|
||||
success=True
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=token_response["access_token"],
|
||||
token_type=token_response["token_type"],
|
||||
expires_in=token_response["expires_in"],
|
||||
refresh_token=token_response.get("refresh_token")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# 記錄登入失敗的審計日誌
|
||||
audit_service.log_login(
|
||||
db=db,
|
||||
username=login_data.username,
|
||||
ip_address=audit_service.get_client_ip(request),
|
||||
success=False
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=MessageResponse)
|
||||
def logout(
|
||||
request: Request,
|
||||
current_user: Dict[str, Any] = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
用戶登出
|
||||
|
||||
記錄登出審計日誌
|
||||
|
||||
Args:
|
||||
request: HTTP Request
|
||||
current_user: 當前用戶資訊
|
||||
db: 資料庫 Session
|
||||
|
||||
Returns:
|
||||
MessageResponse: 登出成功訊息
|
||||
"""
|
||||
# 記錄登出審計日誌
|
||||
audit_service.log_logout(
|
||||
db=db,
|
||||
username=current_user["username"],
|
||||
ip_address=audit_service.get_client_ip(request)
|
||||
)
|
||||
|
||||
# TODO: 可選擇在 Keycloak 端撤銷 Token
|
||||
# keycloak_service.openid.logout(refresh_token)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"User {current_user['username']} logged out successfully"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh_token(
|
||||
refresh_token: str,
|
||||
):
|
||||
"""
|
||||
刷新 Access Token
|
||||
|
||||
使用 Refresh Token 獲取新的 Access Token
|
||||
|
||||
Args:
|
||||
refresh_token: Refresh Token
|
||||
|
||||
Returns:
|
||||
TokenResponse: 新的 Access Token 和 Refresh Token
|
||||
|
||||
Raises:
|
||||
HTTPException: Refresh Token 無效時拋出 401
|
||||
"""
|
||||
try:
|
||||
# 使用 Refresh Token 獲取新的 Access Token
|
||||
token_response = keycloak_service.openid.refresh_token(refresh_token)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=token_response["access_token"],
|
||||
token_type=token_response["token_type"],
|
||||
expires_in=token_response["expires_in"],
|
||||
refresh_token=token_response.get("refresh_token")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserInfo)
|
||||
def get_current_user_info(
|
||||
current_user: Dict[str, Any] = Depends(require_auth),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
獲取當前用戶資訊
|
||||
|
||||
從 JWT Token 解析用戶資訊,並查詢租戶資訊
|
||||
|
||||
Args:
|
||||
current_user: 當前用戶資訊 (從 Token 解析)
|
||||
db: 資料庫 Session
|
||||
|
||||
Returns:
|
||||
UserInfo: 用戶詳細資訊(包含租戶資訊)
|
||||
"""
|
||||
# 查詢用戶所屬租戶
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.employee import Employee
|
||||
|
||||
tenant_info = None
|
||||
|
||||
# 從 email 查詢員工,取得租戶資訊
|
||||
email = current_user.get("email")
|
||||
if email:
|
||||
employee = db.query(Employee).filter(Employee.email == email).first()
|
||||
if employee and employee.tenant_id:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == employee.tenant_id).first()
|
||||
if tenant:
|
||||
tenant_info = {
|
||||
"id": tenant.id,
|
||||
"code": tenant.code,
|
||||
"name": tenant.name,
|
||||
"is_sysmana": tenant.is_sysmana
|
||||
}
|
||||
|
||||
return UserInfo(
|
||||
sub=current_user.get("sub", ""),
|
||||
username=current_user.get("username", ""),
|
||||
email=current_user.get("email", ""),
|
||||
first_name=current_user.get("first_name"),
|
||||
last_name=current_user.get("last_name"),
|
||||
email_verified=current_user.get("email_verified", False),
|
||||
tenant=tenant_info
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=MessageResponse)
|
||||
def change_password(
|
||||
old_password: str,
|
||||
new_password: str,
|
||||
current_user: Dict[str, Any] = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
request: Request = None,
|
||||
):
|
||||
"""
|
||||
修改密碼
|
||||
|
||||
用戶修改自己的密碼
|
||||
|
||||
Args:
|
||||
old_password: 舊密碼
|
||||
new_password: 新密碼
|
||||
current_user: 當前用戶資訊
|
||||
db: 資料庫 Session
|
||||
request: HTTP Request
|
||||
|
||||
Returns:
|
||||
MessageResponse: 成功訊息
|
||||
|
||||
Raises:
|
||||
HTTPException: 舊密碼錯誤或修改失敗時拋出錯誤
|
||||
"""
|
||||
username = current_user["username"]
|
||||
|
||||
try:
|
||||
# 1. 驗證舊密碼
|
||||
try:
|
||||
keycloak_service.openid.token(username, old_password)
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Old password is incorrect"
|
||||
)
|
||||
|
||||
# 2. 獲取用戶 Keycloak ID
|
||||
user = keycloak_service.get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found in Keycloak"
|
||||
)
|
||||
|
||||
user_id = user["id"]
|
||||
|
||||
# 3. 重設密碼 (非臨時密碼)
|
||||
success = keycloak_service.reset_password(
|
||||
user_id=user_id,
|
||||
new_password=new_password,
|
||||
temporary=False
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to change password"
|
||||
)
|
||||
|
||||
# 4. 記錄審計日誌
|
||||
audit_service.log(
|
||||
db=db,
|
||||
action="change_password",
|
||||
resource_type="authentication",
|
||||
performed_by=username,
|
||||
details={"success": True},
|
||||
ip_address=audit_service.get_client_ip(request) if request else None
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message="Password changed successfully"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to change password: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset-password/{username}", response_model=MessageResponse)
|
||||
def reset_user_password(
|
||||
username: str,
|
||||
new_password: str,
|
||||
temporary: bool = True,
|
||||
current_user: Dict[str, Any] = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
request: Request = None,
|
||||
):
|
||||
"""
|
||||
重設用戶密碼 (管理員功能)
|
||||
|
||||
管理員為其他用戶重設密碼
|
||||
|
||||
Args:
|
||||
username: 目標用戶名稱
|
||||
new_password: 新密碼
|
||||
temporary: 是否為臨時密碼 (用戶首次登入需修改)
|
||||
current_user: 當前用戶資訊 (管理員)
|
||||
db: 資料庫 Session
|
||||
request: HTTP Request
|
||||
|
||||
Returns:
|
||||
MessageResponse: 成功訊息
|
||||
|
||||
Raises:
|
||||
HTTPException: 權限不足或重設失敗時拋出錯誤
|
||||
"""
|
||||
# TODO: 檢查是否為管理員
|
||||
# 目前暫時允許所有已認證用戶
|
||||
|
||||
try:
|
||||
# 1. 獲取目標用戶
|
||||
user = keycloak_service.get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {username} not found"
|
||||
)
|
||||
|
||||
user_id = user["id"]
|
||||
|
||||
# 2. 重設密碼
|
||||
success = keycloak_service.reset_password(
|
||||
user_id=user_id,
|
||||
new_password=new_password,
|
||||
temporary=temporary
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to reset password"
|
||||
)
|
||||
|
||||
# 3. 記錄審計日誌
|
||||
audit_service.log(
|
||||
db=db,
|
||||
action="reset_password",
|
||||
resource_type="authentication",
|
||||
performed_by=current_user["username"],
|
||||
details={
|
||||
"target_user": username,
|
||||
"temporary": temporary,
|
||||
"success": True
|
||||
},
|
||||
ip_address=audit_service.get_client_ip(request) if request else None
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Password reset for user {username}"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to reset password: {str(e)}"
|
||||
)
|
||||
213
backend/app/api/v1/business_units.py
Normal file
213
backend/app/api/v1/business_units.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
事業部管理 API
|
||||
"""
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.business_unit import BusinessUnit
|
||||
from app.schemas.business_unit import (
|
||||
BusinessUnitCreate,
|
||||
BusinessUnitUpdate,
|
||||
BusinessUnitResponse,
|
||||
BusinessUnitListItem,
|
||||
)
|
||||
from app.schemas.department import DepartmentListItem
|
||||
from app.schemas.response import MessageResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[BusinessUnitListItem])
|
||||
def get_business_units(
|
||||
db: Session = Depends(get_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
獲取事業部列表
|
||||
|
||||
Args:
|
||||
include_inactive: 是否包含停用的事業部
|
||||
"""
|
||||
query = db.query(BusinessUnit)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(BusinessUnit.is_active == True)
|
||||
|
||||
business_units = query.order_by(BusinessUnit.id).all()
|
||||
|
||||
return [BusinessUnitListItem.model_validate(bu) for bu in business_units]
|
||||
|
||||
|
||||
@router.get("/{business_unit_id}", response_model=BusinessUnitResponse)
|
||||
def get_business_unit(
|
||||
business_unit_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取事業部詳情
|
||||
"""
|
||||
business_unit = db.query(BusinessUnit).filter(
|
||||
BusinessUnit.id == business_unit_id
|
||||
).first()
|
||||
|
||||
if not business_unit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Business unit with id {business_unit_id} not found"
|
||||
)
|
||||
|
||||
response = BusinessUnitResponse.model_validate(business_unit)
|
||||
response.departments_count = len(business_unit.departments)
|
||||
response.employees_count = business_unit.employee_identities.count()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/", response_model=BusinessUnitResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_business_unit(
|
||||
business_unit_data: BusinessUnitCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
創建事業部
|
||||
|
||||
檢查:
|
||||
- code 唯一性
|
||||
- email_domain 唯一性
|
||||
"""
|
||||
# 檢查 code 是否已存在
|
||||
existing_code = db.query(BusinessUnit).filter(
|
||||
BusinessUnit.code == business_unit_data.code
|
||||
).first()
|
||||
|
||||
if existing_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Business unit code '{business_unit_data.code}' already exists"
|
||||
)
|
||||
|
||||
# 檢查 email_domain 是否已存在
|
||||
existing_domain = db.query(BusinessUnit).filter(
|
||||
BusinessUnit.email_domain == business_unit_data.email_domain
|
||||
).first()
|
||||
|
||||
if existing_domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Email domain '{business_unit_data.email_domain}' already exists"
|
||||
)
|
||||
|
||||
# 創建事業部
|
||||
business_unit = BusinessUnit(**business_unit_data.model_dump())
|
||||
|
||||
db.add(business_unit)
|
||||
db.commit()
|
||||
db.refresh(business_unit)
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
|
||||
response = BusinessUnitResponse.model_validate(business_unit)
|
||||
response.departments_count = 0
|
||||
response.employees_count = 0
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{business_unit_id}", response_model=BusinessUnitResponse)
|
||||
def update_business_unit(
|
||||
business_unit_id: int,
|
||||
business_unit_data: BusinessUnitUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新事業部
|
||||
|
||||
注意: code 和 email_domain 不可修改 (在 Schema 中已限制)
|
||||
"""
|
||||
business_unit = db.query(BusinessUnit).filter(
|
||||
BusinessUnit.id == business_unit_id
|
||||
).first()
|
||||
|
||||
if not business_unit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Business unit with id {business_unit_id} not found"
|
||||
)
|
||||
|
||||
# 更新欄位
|
||||
update_data = business_unit_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(business_unit, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(business_unit)
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
|
||||
response = BusinessUnitResponse.model_validate(business_unit)
|
||||
response.departments_count = len(business_unit.departments)
|
||||
response.employees_count = business_unit.employee_identities.count()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{business_unit_id}", response_model=MessageResponse)
|
||||
def delete_business_unit(
|
||||
business_unit_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
停用事業部
|
||||
|
||||
注意: 這是軟刪除,只將 is_active 設為 False
|
||||
"""
|
||||
business_unit = db.query(BusinessUnit).filter(
|
||||
BusinessUnit.id == business_unit_id
|
||||
).first()
|
||||
|
||||
if not business_unit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Business unit with id {business_unit_id} not found"
|
||||
)
|
||||
|
||||
# 檢查是否有活躍的員工
|
||||
active_employees = business_unit.employee_identities.filter_by(is_active=True).count()
|
||||
if active_employees > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot deactivate business unit with {active_employees} active employees"
|
||||
)
|
||||
|
||||
business_unit.is_active = False
|
||||
|
||||
db.commit()
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Business unit '{business_unit.name}' has been deactivated"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{business_unit_id}/departments", response_model=List[DepartmentListItem])
|
||||
def get_business_unit_departments(
|
||||
business_unit_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取事業部的所有部門
|
||||
"""
|
||||
business_unit = db.query(BusinessUnit).filter(
|
||||
BusinessUnit.id == business_unit_id
|
||||
).first()
|
||||
|
||||
if not business_unit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Business unit with id {business_unit_id} not found"
|
||||
)
|
||||
|
||||
return [DepartmentListItem.model_validate(dept) for dept in business_unit.departments]
|
||||
226
backend/app/api/v1/department_members.py
Normal file
226
backend/app/api/v1/department_members.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
部門成員管理 API
|
||||
記錄員工與部門的多對多關係 (純粹組織歸屬,與 RBAC 權限無關)
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.department_member import DepartmentMember
|
||||
from app.models.employee import Employee
|
||||
from app.models.department import Department
|
||||
from app.schemas.response import MessageResponse
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_tenant_id() -> int:
|
||||
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token realm 取得)"""
|
||||
return 1
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_department_members(
|
||||
db: Session = Depends(get_db),
|
||||
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
|
||||
department_id: Optional[int] = Query(None, description="部門 ID 篩選"),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
取得部門成員列表
|
||||
|
||||
可依員工 ID 或部門 ID 篩選
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
query = db.query(DepartmentMember).filter(DepartmentMember.tenant_id == tenant_id)
|
||||
|
||||
if employee_id:
|
||||
query = query.filter(DepartmentMember.employee_id == employee_id)
|
||||
|
||||
if department_id:
|
||||
query = query.filter(DepartmentMember.department_id == department_id)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(DepartmentMember.is_active == True)
|
||||
|
||||
members = query.all()
|
||||
|
||||
result = []
|
||||
for m in members:
|
||||
dept = m.department
|
||||
emp = m.employee
|
||||
result.append({
|
||||
"id": m.id,
|
||||
"employee_id": m.employee_id,
|
||||
"employee_name": emp.legal_name if emp else None,
|
||||
"employee_number": emp.employee_id if emp else None,
|
||||
"department_id": m.department_id,
|
||||
"department_name": dept.name if dept else None,
|
||||
"department_code": dept.code if dept else None,
|
||||
"department_depth": dept.depth if dept else None,
|
||||
"position": m.position,
|
||||
"membership_type": m.membership_type,
|
||||
"is_active": m.is_active,
|
||||
"joined_at": m.joined_at,
|
||||
"ended_at": m.ended_at,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||
def add_employee_to_department(
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
將員工加入部門
|
||||
|
||||
Body:
|
||||
{
|
||||
"employee_id": 1,
|
||||
"department_id": 3,
|
||||
"position": "資深工程師",
|
||||
"membership_type": "permanent"
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
employee_id = data.get("employee_id")
|
||||
department_id = data.get("department_id")
|
||||
position = data.get("position")
|
||||
membership_type = data.get("membership_type", "permanent")
|
||||
|
||||
if not employee_id or not department_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="employee_id and department_id are required"
|
||||
)
|
||||
|
||||
# 驗證員工存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found"
|
||||
)
|
||||
|
||||
# 驗證部門存在
|
||||
department = db.query(Department).filter(
|
||||
Department.id == department_id,
|
||||
Department.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department with id {department_id} not found"
|
||||
)
|
||||
|
||||
# 檢查是否已存在
|
||||
existing = db.query(DepartmentMember).filter(
|
||||
DepartmentMember.employee_id == employee_id,
|
||||
DepartmentMember.department_id == department_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if existing.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Employee {employee_id} is already a member of department {department_id}"
|
||||
)
|
||||
else:
|
||||
# 重新啟用
|
||||
existing.is_active = True
|
||||
existing.position = position
|
||||
existing.membership_type = membership_type
|
||||
existing.ended_at = None
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return {
|
||||
"id": existing.id,
|
||||
"employee_id": existing.employee_id,
|
||||
"department_id": existing.department_id,
|
||||
"position": existing.position,
|
||||
"membership_type": existing.membership_type,
|
||||
"is_active": existing.is_active,
|
||||
}
|
||||
|
||||
member = DepartmentMember(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=employee_id,
|
||||
department_id=department_id,
|
||||
position=position,
|
||||
membership_type=membership_type,
|
||||
)
|
||||
db.add(member)
|
||||
db.commit()
|
||||
db.refresh(member)
|
||||
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="add_department_member",
|
||||
resource_type="department_member",
|
||||
resource_id=member.id,
|
||||
details={
|
||||
"employee_id": employee_id,
|
||||
"department_id": department_id,
|
||||
"position": position,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": member.id,
|
||||
"employee_id": member.employee_id,
|
||||
"department_id": member.department_id,
|
||||
"position": member.position,
|
||||
"membership_type": member.membership_type,
|
||||
"is_active": member.is_active,
|
||||
"joined_at": member.joined_at,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{member_id}", response_model=MessageResponse)
|
||||
def remove_employee_from_department(
|
||||
member_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""將員工從部門移除 (軟刪除)"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
member = db.query(DepartmentMember).filter(
|
||||
DepartmentMember.id == member_id,
|
||||
DepartmentMember.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department member with id {member_id} not found"
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
member.is_active = False
|
||||
member.ended_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="remove_department_member",
|
||||
resource_type="department_member",
|
||||
resource_id=member_id,
|
||||
details={
|
||||
"employee_id": member.employee_id,
|
||||
"department_id": member.department_id,
|
||||
},
|
||||
)
|
||||
|
||||
return MessageResponse(message=f"Employee removed from department successfully")
|
||||
373
backend/app/api/v1/departments.py
Normal file
373
backend/app/api/v1/departments.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
部門管理 API (統一樹狀結構)
|
||||
|
||||
設計原則:
|
||||
- depth=0, parent_id=NULL: 第一層部門 (原事業部),可設定 email_domain
|
||||
- depth>=1: 子部門,email_domain 繼承第一層祖先
|
||||
- 取代舊的 business_units API
|
||||
"""
|
||||
from typing import List, Optional, Any, Dict
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_tenant_id, get_current_tenant
|
||||
from app.models.department import Department
|
||||
from app.models.department_member import DepartmentMember
|
||||
from app.schemas.department import (
|
||||
DepartmentCreate,
|
||||
DepartmentUpdate,
|
||||
DepartmentResponse,
|
||||
DepartmentListItem,
|
||||
DepartmentTreeNode,
|
||||
)
|
||||
from app.schemas.response import MessageResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_effective_email_domain(department: Department, db: Session) -> str | None:
|
||||
"""取得部門的有效郵件網域 (第一層自身,子層向上追溯)"""
|
||||
if department.depth == 0:
|
||||
return department.email_domain
|
||||
if department.parent_id:
|
||||
parent = db.query(Department).filter(Department.id == department.parent_id).first()
|
||||
if parent:
|
||||
return get_effective_email_domain(parent, db)
|
||||
return None
|
||||
|
||||
|
||||
def build_tree(departments: List[Department], parent_id: int | None, db: Session) -> List[Dict]:
|
||||
"""遞迴建立部門樹狀結構"""
|
||||
nodes = []
|
||||
for dept in departments:
|
||||
if dept.parent_id == parent_id:
|
||||
children = build_tree(departments, dept.id, db)
|
||||
node = {
|
||||
"id": dept.id,
|
||||
"code": dept.code,
|
||||
"name": dept.name,
|
||||
"name_en": dept.name_en,
|
||||
"depth": dept.depth,
|
||||
"parent_id": dept.parent_id,
|
||||
"email_domain": dept.email_domain,
|
||||
"effective_email_domain": get_effective_email_domain(dept, db),
|
||||
"email_address": dept.email_address,
|
||||
"email_quota_mb": dept.email_quota_mb,
|
||||
"description": dept.description,
|
||||
"is_active": dept.is_active,
|
||||
"is_top_level": dept.depth == 0 and dept.parent_id is None,
|
||||
"member_count": dept.members.filter_by(is_active=True).count(),
|
||||
"children": children,
|
||||
}
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
def get_departments_tree(
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
取得完整部門樹狀結構
|
||||
|
||||
回傳格式:
|
||||
[
|
||||
{
|
||||
"id": 1, "code": "BD", "name": "業務發展部", "depth": 0,
|
||||
"email_domain": "ease.taipei",
|
||||
"children": [
|
||||
{"id": 4, "code": "WIND", "name": "玄鐵風能部", "depth": 1, ...}
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
query = db.query(Department).filter(Department.tenant_id == tenant_id)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(Department.is_active == True)
|
||||
|
||||
all_departments = query.order_by(Department.depth, Department.id).all()
|
||||
tree = build_tree(all_departments, None, db)
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DepartmentListItem])
|
||||
def get_departments(
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
parent_id: Optional[int] = Query(None, description="上層部門 ID 篩選 (0=取得第一層)"),
|
||||
depth: Optional[int] = Query(None, description="層次深度篩選 (0=第一層,1=第二層)"),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
獲取部門列表
|
||||
|
||||
Args:
|
||||
parent_id: 上層部門 ID 篩選
|
||||
depth: 層次深度篩選 (0=第一層即原事業部,1=第二層子部門)
|
||||
include_inactive: 是否包含停用的部門
|
||||
"""
|
||||
query = db.query(Department).filter(Department.tenant_id == tenant_id)
|
||||
|
||||
if depth is not None:
|
||||
query = query.filter(Department.depth == depth)
|
||||
|
||||
if parent_id is not None:
|
||||
if parent_id == 0:
|
||||
query = query.filter(Department.parent_id == None)
|
||||
else:
|
||||
query = query.filter(Department.parent_id == parent_id)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(Department.is_active == True)
|
||||
|
||||
departments = query.order_by(Department.depth, Department.id).all()
|
||||
|
||||
result = []
|
||||
for dept in departments:
|
||||
item = DepartmentListItem.model_validate(dept)
|
||||
item.effective_email_domain = get_effective_email_domain(dept, db)
|
||||
item.member_count = dept.members.filter_by(is_active=True).count()
|
||||
result.append(item)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{department_id}", response_model=DepartmentResponse)
|
||||
def get_department(
|
||||
department_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""取得部門詳情"""
|
||||
department = db.query(Department).filter(
|
||||
Department.id == department_id,
|
||||
Department.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department with id {department_id} not found"
|
||||
)
|
||||
|
||||
response = DepartmentResponse.model_validate(department)
|
||||
response.effective_email_domain = get_effective_email_domain(department, db)
|
||||
response.member_count = department.members.filter_by(is_active=True).count()
|
||||
if department.parent:
|
||||
response.parent_name = department.parent.name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/", response_model=DepartmentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_department(
|
||||
department_data: DepartmentCreate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""
|
||||
創建部門
|
||||
|
||||
規則:
|
||||
- parent_id=NULL: 建立第一層部門 (depth=0),可設定 email_domain
|
||||
- parent_id=有值: 建立子部門 (depth=parent.depth+1),不可設定 email_domain (繼承)
|
||||
"""
|
||||
depth = 0
|
||||
parent = None
|
||||
|
||||
if department_data.parent_id:
|
||||
# 檢查上層部門是否存在
|
||||
parent = db.query(Department).filter(
|
||||
Department.id == department_data.parent_id,
|
||||
Department.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not parent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Parent department with id {department_data.parent_id} not found"
|
||||
)
|
||||
|
||||
depth = parent.depth + 1
|
||||
|
||||
# 子部門不可設定 email_domain
|
||||
if hasattr(department_data, 'email_domain') and department_data.email_domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="email_domain can only be set on top-level departments (parent_id=NULL)"
|
||||
)
|
||||
|
||||
# 檢查同層內 code 是否已存在
|
||||
existing = db.query(Department).filter(
|
||||
Department.tenant_id == tenant_id,
|
||||
Department.parent_id == department_data.parent_id,
|
||||
Department.code == department_data.code,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Department code '{department_data.code}' already exists at this level"
|
||||
)
|
||||
|
||||
data = department_data.model_dump()
|
||||
data['tenant_id'] = tenant_id
|
||||
data['depth'] = depth
|
||||
|
||||
department = Department(**data)
|
||||
db.add(department)
|
||||
db.commit()
|
||||
db.refresh(department)
|
||||
|
||||
response = DepartmentResponse.model_validate(department)
|
||||
response.effective_email_domain = get_effective_email_domain(department, db)
|
||||
response.member_count = 0
|
||||
if parent:
|
||||
response.parent_name = parent.name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{department_id}", response_model=DepartmentResponse)
|
||||
def update_department(
|
||||
department_id: int,
|
||||
department_data: DepartmentUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""
|
||||
更新部門
|
||||
|
||||
注意: code 和 parent_id 建立後不可修改
|
||||
第一層部門可更新 email_domain,子部門不可更新 email_domain
|
||||
"""
|
||||
department = db.query(Department).filter(
|
||||
Department.id == department_id,
|
||||
Department.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department with id {department_id} not found"
|
||||
)
|
||||
|
||||
update_data = department_data.model_dump(exclude_unset=True)
|
||||
|
||||
# 子部門不可更新 email_domain
|
||||
if 'email_domain' in update_data and department.depth > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="email_domain can only be set on top-level departments (depth=0)"
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(department, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(department)
|
||||
|
||||
response = DepartmentResponse.model_validate(department)
|
||||
response.effective_email_domain = get_effective_email_domain(department, db)
|
||||
response.member_count = department.members.filter_by(is_active=True).count()
|
||||
if department.parent:
|
||||
response.parent_name = department.parent.name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{department_id}", response_model=MessageResponse)
|
||||
def delete_department(
|
||||
department_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""
|
||||
停用部門 (軟刪除)
|
||||
|
||||
注意: 有活躍成員的部門不可停用
|
||||
"""
|
||||
department = db.query(Department).filter(
|
||||
Department.id == department_id,
|
||||
Department.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department with id {department_id} not found"
|
||||
)
|
||||
|
||||
# 檢查是否有活躍的成員
|
||||
active_members = department.members.filter_by(is_active=True).count()
|
||||
if active_members > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot deactivate department with {active_members} active members"
|
||||
)
|
||||
|
||||
# 檢查是否有活躍的子部門
|
||||
active_children = db.query(Department).filter(
|
||||
Department.parent_id == department_id,
|
||||
Department.is_active == True,
|
||||
).count()
|
||||
if active_children > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot deactivate department with {active_children} active sub-departments"
|
||||
)
|
||||
|
||||
department.is_active = False
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Department '{department.name}' has been deactivated"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{department_id}/children")
|
||||
def get_department_children(
|
||||
department_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""取得部門的直接子部門列表"""
|
||||
|
||||
# 確認父部門存在
|
||||
parent = db.query(Department).filter(
|
||||
Department.id == department_id,
|
||||
Department.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not parent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department with id {department_id} not found"
|
||||
)
|
||||
|
||||
query = db.query(Department).filter(
|
||||
Department.parent_id == department_id,
|
||||
Department.tenant_id == tenant_id,
|
||||
)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(Department.is_active == True)
|
||||
|
||||
children = query.order_by(Department.id).all()
|
||||
|
||||
effective_domain = get_effective_email_domain(parent, db)
|
||||
result = []
|
||||
for dept in children:
|
||||
item = DepartmentListItem.model_validate(dept)
|
||||
item.effective_email_domain = effective_domain
|
||||
item.member_count = dept.members.filter_by(is_active=True).count()
|
||||
result.append(item)
|
||||
|
||||
return result
|
||||
445
backend/app/api/v1/email_accounts.py
Normal file
445
backend/app/api/v1/email_accounts.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
郵件帳號管理 API
|
||||
符合 WebMail 設計規範 - 員工只能使用 HR Portal 授權的郵件帳號
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.models.email_account import EmailAccount
|
||||
from app.schemas.email_account import (
|
||||
EmailAccountCreate,
|
||||
EmailAccountUpdate,
|
||||
EmailAccountResponse,
|
||||
EmailAccountListItem,
|
||||
EmailAccountQuotaUpdate,
|
||||
)
|
||||
from app.schemas.base import PaginationParams, PaginatedResponse
|
||||
from app.schemas.response import SuccessResponse, MessageResponse
|
||||
from app.api.deps import get_pagination_params
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_tenant_id() -> int:
|
||||
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token 取得)"""
|
||||
return 1
|
||||
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse)
|
||||
def get_email_accounts(
|
||||
db: Session = Depends(get_db),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
|
||||
is_active: Optional[bool] = Query(None, description="狀態篩選"),
|
||||
search: Optional[str] = Query(None, description="搜尋郵件地址"),
|
||||
):
|
||||
"""
|
||||
獲取郵件帳號列表
|
||||
|
||||
支援:
|
||||
- 分頁
|
||||
- 員工篩選
|
||||
- 狀態篩選
|
||||
- 郵件地址搜尋
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
query = db.query(EmailAccount).filter(EmailAccount.tenant_id == tenant_id)
|
||||
|
||||
# 員工篩選
|
||||
if employee_id:
|
||||
query = query.filter(EmailAccount.employee_id == employee_id)
|
||||
|
||||
# 狀態篩選
|
||||
if is_active is not None:
|
||||
query = query.filter(EmailAccount.is_active == is_active)
|
||||
|
||||
# 搜尋
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(EmailAccount.email_address.ilike(search_pattern))
|
||||
|
||||
# 總數
|
||||
total = query.count()
|
||||
|
||||
# 分頁
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
email_accounts = (
|
||||
query.options(joinedload(EmailAccount.employee))
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 計算總頁數
|
||||
total_pages = (total + pagination.page_size - 1) // pagination.page_size
|
||||
|
||||
# 組裝回應資料
|
||||
items = []
|
||||
for account in email_accounts:
|
||||
item = EmailAccountListItem.model_validate(account)
|
||||
item.employee_name = account.employee.legal_name if account.employee else None
|
||||
item.employee_number = account.employee.employee_id if account.employee else None
|
||||
items.append(item)
|
||||
|
||||
return PaginatedResponse(
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
total_pages=total_pages,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{email_account_id}", response_model=EmailAccountResponse)
|
||||
def get_email_account(
|
||||
email_account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取郵件帳號詳情
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
account = (
|
||||
db.query(EmailAccount)
|
||||
.options(joinedload(EmailAccount.employee))
|
||||
.filter(
|
||||
EmailAccount.id == email_account_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Email account with id {email_account_id} not found",
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = EmailAccountResponse.model_validate(account)
|
||||
response.employee_name = account.employee.legal_name if account.employee else None
|
||||
response.employee_number = account.employee.employee_id if account.employee else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/", response_model=EmailAccountResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_email_account(
|
||||
account_data: EmailAccountCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
創建郵件帳號
|
||||
|
||||
注意:
|
||||
- 郵件地址必須唯一
|
||||
- 員工必須存在
|
||||
- 配額範圍: 1GB - 100GB
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == account_data.employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {account_data.employee_id} not found",
|
||||
)
|
||||
|
||||
# 檢查郵件地址是否已存在
|
||||
existing = db.query(EmailAccount).filter(
|
||||
EmailAccount.email_address == account_data.email_address
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Email address '{account_data.email_address}' already exists",
|
||||
)
|
||||
|
||||
# 創建郵件帳號
|
||||
account = EmailAccount(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=account_data.employee_id,
|
||||
email_address=account_data.email_address,
|
||||
quota_mb=account_data.quota_mb,
|
||||
forward_to=account_data.forward_to,
|
||||
auto_reply=account_data.auto_reply,
|
||||
is_active=account_data.is_active,
|
||||
)
|
||||
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="create_email_account",
|
||||
resource_type="email_account",
|
||||
resource_id=account.id,
|
||||
details={
|
||||
"email_address": account.email_address,
|
||||
"employee_id": account.employee_id,
|
||||
"quota_mb": account.quota_mb,
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = EmailAccountResponse.model_validate(account)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_number = employee.employee_id
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{email_account_id}", response_model=EmailAccountResponse)
|
||||
def update_email_account(
|
||||
email_account_id: int,
|
||||
account_data: EmailAccountUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新郵件帳號
|
||||
|
||||
可更新:
|
||||
- 配額
|
||||
- 轉寄地址
|
||||
- 自動回覆
|
||||
- 啟用狀態
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
account = (
|
||||
db.query(EmailAccount)
|
||||
.options(joinedload(EmailAccount.employee))
|
||||
.filter(
|
||||
EmailAccount.id == email_account_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Email account with id {email_account_id} not found",
|
||||
)
|
||||
|
||||
# 記錄變更前的值
|
||||
changes = {}
|
||||
|
||||
# 更新欄位
|
||||
update_data = account_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
old_value = getattr(account, field)
|
||||
if old_value != value:
|
||||
changes[field] = {"from": old_value, "to": value}
|
||||
setattr(account, field, value)
|
||||
|
||||
if changes:
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="update_email_account",
|
||||
resource_type="email_account",
|
||||
resource_id=account.id,
|
||||
details={
|
||||
"email_address": account.email_address,
|
||||
"changes": changes,
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = EmailAccountResponse.model_validate(account)
|
||||
response.employee_name = account.employee.legal_name if account.employee else None
|
||||
response.employee_number = account.employee.employee_id if account.employee else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.patch("/{email_account_id}/quota", response_model=EmailAccountResponse)
|
||||
def update_email_quota(
|
||||
email_account_id: int,
|
||||
quota_data: EmailAccountQuotaUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新郵件配額
|
||||
|
||||
快速更新配額的端點
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
account = (
|
||||
db.query(EmailAccount)
|
||||
.options(joinedload(EmailAccount.employee))
|
||||
.filter(
|
||||
EmailAccount.id == email_account_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Email account with id {email_account_id} not found",
|
||||
)
|
||||
|
||||
old_quota = account.quota_mb
|
||||
account.quota_mb = quota_data.quota_mb
|
||||
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="update_email_quota",
|
||||
resource_type="email_account",
|
||||
resource_id=account.id,
|
||||
details={
|
||||
"email_address": account.email_address,
|
||||
"old_quota_mb": old_quota,
|
||||
"new_quota_mb": quota_data.quota_mb,
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = EmailAccountResponse.model_validate(account)
|
||||
response.employee_name = account.employee.legal_name if account.employee else None
|
||||
response.employee_number = account.employee.employee_id if account.employee else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{email_account_id}", response_model=MessageResponse)
|
||||
def delete_email_account(
|
||||
email_account_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
刪除郵件帳號
|
||||
|
||||
注意:
|
||||
- 軟刪除: 設為停用
|
||||
- 需要記錄審計日誌
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
account = db.query(EmailAccount).filter(
|
||||
EmailAccount.id == email_account_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Email account with id {email_account_id} not found",
|
||||
)
|
||||
|
||||
# 軟刪除: 設為停用
|
||||
account.is_active = False
|
||||
db.commit()
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="delete_email_account",
|
||||
resource_type="email_account",
|
||||
resource_id=account.id,
|
||||
details={
|
||||
"email_address": account.email_address,
|
||||
"employee_id": account.employee_id,
|
||||
},
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Email account {account.email_address} has been deactivated"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}/email-accounts")
|
||||
def get_employee_email_accounts(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
取得員工授權的郵件帳號列表
|
||||
|
||||
符合 WebMail 設計規範 (HR Portal設計文件 §2):
|
||||
- 員工不可自行新增郵件帳號
|
||||
- 只能使用 HR Portal 授予的帳號
|
||||
- 支援多帳號切換 (ISO 帳號管理流程)
|
||||
|
||||
回傳格式:
|
||||
{
|
||||
"user_id": "porsche.chen",
|
||||
"email_accounts": [
|
||||
{
|
||||
"email": "porsche.chen@porscheworld.tw",
|
||||
"quota_mb": 5120,
|
||||
"status": "active",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found",
|
||||
)
|
||||
|
||||
# 查詢員工的所有啟用郵件帳號
|
||||
accounts = (
|
||||
db.query(EmailAccount)
|
||||
.filter(
|
||||
EmailAccount.employee_id == employee_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
EmailAccount.is_active == True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 組裝符合 WebMail 設計規範的回應格式
|
||||
email_accounts = []
|
||||
for account in accounts:
|
||||
email_accounts.append({
|
||||
"email": account.email_address,
|
||||
"quota_mb": account.quota_mb,
|
||||
"status": "active" if account.is_active else "inactive",
|
||||
"forward_to": account.forward_to,
|
||||
"auto_reply": account.auto_reply,
|
||||
})
|
||||
|
||||
return {
|
||||
"user_id": employee.username_base,
|
||||
"email_accounts": email_accounts,
|
||||
}
|
||||
468
backend/app/api/v1/emp_onboarding.py
Normal file
468
backend/app/api/v1/emp_onboarding.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""
|
||||
員工到職流程 API (v3.1 多租戶架構)
|
||||
使用關聯表方式管理部門、角色、服務
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.emp_resume import EmpResume
|
||||
from app.models.emp_setting import EmpSetting
|
||||
from app.models.department_member import DepartmentMember
|
||||
from app.models.role import UserRoleAssignment
|
||||
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
|
||||
from app.models.personal_service import PersonalService
|
||||
from app.schemas.response import MessageResponse
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_tenant_id() -> int:
|
||||
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token realm 取得)"""
|
||||
return 1
|
||||
|
||||
|
||||
def get_current_user_id() -> str:
|
||||
"""取得當前使用者 ID (暫時寫死, 未來從 JWT token sub 取得)"""
|
||||
return "system-admin"
|
||||
|
||||
|
||||
# ==================== Schemas ====================
|
||||
|
||||
class DepartmentAssignment(BaseModel):
|
||||
"""部門分配"""
|
||||
department_id: int
|
||||
position: Optional[str] = None
|
||||
membership_type: str = "permanent" # permanent/temporary/project
|
||||
|
||||
|
||||
class OnboardingRequest(BaseModel):
|
||||
"""到職請求"""
|
||||
# 人員基本資料
|
||||
resume_id: int # 已存在的 tenant_emp_resumes.id
|
||||
|
||||
# SSO 帳號資訊
|
||||
keycloak_user_id: str # Keycloak UUID
|
||||
keycloak_username: str # 登入帳號
|
||||
|
||||
# 任用資訊
|
||||
hire_date: date
|
||||
|
||||
# 部門分配
|
||||
departments: List[DepartmentAssignment]
|
||||
|
||||
# 角色分配
|
||||
role_ids: List[int]
|
||||
|
||||
# 配額設定
|
||||
storage_quota_gb: int = 20
|
||||
email_quota_mb: int = 5120
|
||||
|
||||
|
||||
# ==================== API Endpoints ====================
|
||||
|
||||
@router.post("/onboard", status_code=status.HTTP_201_CREATED)
|
||||
def onboard_employee(
|
||||
data: OnboardingRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
完整員工到職流程
|
||||
|
||||
執行項目:
|
||||
1. 建立員工任用設定 (tenant_emp_settings)
|
||||
2. 分配部門歸屬 (tenant_dept_members)
|
||||
3. 分配使用者角色 (tenant_user_role_assignments)
|
||||
4. 啟用所有個人化服務 (tenant_emp_personal_service_settings)
|
||||
|
||||
範例:
|
||||
{
|
||||
"resume_id": 1,
|
||||
"keycloak_user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"keycloak_username": "wang.ming",
|
||||
"hire_date": "2026-02-20",
|
||||
"departments": [
|
||||
{"department_id": 9, "position": "資深工程師", "membership_type": "permanent"},
|
||||
{"department_id": 12, "position": "專案經理", "membership_type": "project"}
|
||||
],
|
||||
"role_ids": [1, 2],
|
||||
"storage_quota_gb": 20,
|
||||
"email_quota_mb": 5120
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
# Step 1: 檢查 resume 是否存在
|
||||
resume = db.query(EmpResume).filter(
|
||||
EmpResume.id == data.resume_id,
|
||||
EmpResume.tenant_id == tenant_id
|
||||
).first()
|
||||
|
||||
if not resume:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Resume ID {data.resume_id} not found"
|
||||
)
|
||||
|
||||
# 檢查是否已有任用設定
|
||||
existing_setting = db.query(EmpSetting).filter(
|
||||
EmpSetting.tenant_id == tenant_id,
|
||||
EmpSetting.tenant_resume_id == data.resume_id,
|
||||
EmpSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if existing_setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Employee already onboarded (emp_code: {existing_setting.tenant_emp_code})"
|
||||
)
|
||||
|
||||
# Step 2: 建立員工任用設定 (seq_no 由觸發器自動生成)
|
||||
emp_setting = EmpSetting(
|
||||
tenant_id=tenant_id,
|
||||
# seq_no 會由觸發器自動生成
|
||||
tenant_resume_id=data.resume_id,
|
||||
# tenant_emp_code 會由觸發器自動生成
|
||||
tenant_keycloak_user_id=data.keycloak_user_id,
|
||||
tenant_keycloak_username=data.keycloak_username,
|
||||
hire_at=data.hire_date,
|
||||
storage_quota_gb=data.storage_quota_gb,
|
||||
email_quota_mb=data.email_quota_mb,
|
||||
employment_status="active",
|
||||
is_active=True,
|
||||
edit_by=current_user
|
||||
)
|
||||
|
||||
db.add(emp_setting)
|
||||
db.flush() # 取得自動生成的 seq_no 和 tenant_emp_code
|
||||
|
||||
# Step 3: 分配部門歸屬
|
||||
dept_count = 0
|
||||
for dept_assignment in data.departments:
|
||||
dept_member = DepartmentMember(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=data.resume_id, # 使用 resume_id 作為 employee_id
|
||||
department_id=dept_assignment.department_id,
|
||||
position=dept_assignment.position,
|
||||
membership_type=dept_assignment.membership_type,
|
||||
joined_at=datetime.utcnow(),
|
||||
assigned_by=current_user,
|
||||
is_active=True,
|
||||
edit_by=current_user
|
||||
)
|
||||
db.add(dept_member)
|
||||
dept_count += 1
|
||||
|
||||
# Step 4: 分配使用者角色
|
||||
role_count = 0
|
||||
for role_id in data.role_ids:
|
||||
role_assignment = UserRoleAssignment(
|
||||
tenant_id=tenant_id,
|
||||
keycloak_user_id=data.keycloak_user_id,
|
||||
role_id=role_id,
|
||||
assigned_at=datetime.utcnow(),
|
||||
assigned_by=current_user,
|
||||
is_active=True,
|
||||
edit_by=current_user
|
||||
)
|
||||
db.add(role_assignment)
|
||||
role_count += 1
|
||||
|
||||
# Step 5: 啟用所有個人化服務
|
||||
all_services = db.query(PersonalService).filter(
|
||||
PersonalService.is_active == True
|
||||
).all()
|
||||
|
||||
service_count = 0
|
||||
for service in all_services:
|
||||
# 根據服務類型設定配額
|
||||
quota_gb = data.storage_quota_gb if service.service_code == "Drive" else None
|
||||
quota_mb = data.email_quota_mb if service.service_code == "Email" else None
|
||||
|
||||
service_setting = EmpPersonalServiceSetting(
|
||||
tenant_id=tenant_id,
|
||||
tenant_keycloak_user_id=data.keycloak_user_id,
|
||||
service_id=service.id,
|
||||
quota_gb=quota_gb,
|
||||
quota_mb=quota_mb,
|
||||
enabled_at=datetime.utcnow(),
|
||||
enabled_by=current_user,
|
||||
is_active=True,
|
||||
edit_by=current_user
|
||||
)
|
||||
db.add(service_setting)
|
||||
service_count += 1
|
||||
|
||||
db.commit()
|
||||
db.refresh(emp_setting)
|
||||
|
||||
# 審計日誌
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="employee_onboard",
|
||||
resource_type="tenant_emp_settings",
|
||||
resource_id=f"{tenant_id}-{emp_setting.seq_no}",
|
||||
details=f"Onboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw}): "
|
||||
f"{dept_count} departments, {role_count} roles, {service_count} services",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Employee onboarded successfully",
|
||||
"employee": {
|
||||
"tenant_id": emp_setting.tenant_id,
|
||||
"seq_no": emp_setting.seq_no,
|
||||
"tenant_emp_code": emp_setting.tenant_emp_code,
|
||||
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
|
||||
"keycloak_username": emp_setting.tenant_keycloak_username,
|
||||
"name": resume.name_tw,
|
||||
"hire_date": emp_setting.hire_at.isoformat(),
|
||||
},
|
||||
"summary": {
|
||||
"departments_assigned": dept_count,
|
||||
"roles_assigned": role_count,
|
||||
"services_enabled": service_count,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{tenant_id}/{seq_no}/offboard")
|
||||
def offboard_employee(
|
||||
tenant_id: int,
|
||||
seq_no: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
員工離職流程
|
||||
|
||||
執行項目:
|
||||
1. 軟刪除所有部門歸屬
|
||||
2. 撤銷所有使用者角色
|
||||
3. 停用所有個人化服務
|
||||
4. 設定員工狀態為 resigned
|
||||
"""
|
||||
current_tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
# 檢查租戶權限
|
||||
if tenant_id != current_tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No permission to access this tenant"
|
||||
)
|
||||
|
||||
# 查詢員工任用設定
|
||||
emp_setting = db.query(EmpSetting).filter(
|
||||
EmpSetting.tenant_id == tenant_id,
|
||||
EmpSetting.seq_no == seq_no
|
||||
).first()
|
||||
|
||||
if not emp_setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
|
||||
)
|
||||
|
||||
if emp_setting.employment_status == "resigned":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Employee already resigned"
|
||||
)
|
||||
|
||||
keycloak_user_id = emp_setting.tenant_keycloak_user_id
|
||||
resume_id = emp_setting.tenant_resume_id
|
||||
|
||||
# Step 1: 軟刪除所有部門歸屬
|
||||
dept_members = db.query(DepartmentMember).filter(
|
||||
DepartmentMember.tenant_id == tenant_id,
|
||||
DepartmentMember.employee_id == resume_id,
|
||||
DepartmentMember.is_active == True
|
||||
).all()
|
||||
|
||||
for dm in dept_members:
|
||||
dm.is_active = False
|
||||
dm.ended_at = datetime.utcnow()
|
||||
dm.removed_by = current_user
|
||||
dm.edit_by = current_user
|
||||
|
||||
# Step 2: 撤銷所有使用者角色
|
||||
role_assignments = db.query(UserRoleAssignment).filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
|
||||
UserRoleAssignment.is_active == True
|
||||
).all()
|
||||
|
||||
for ra in role_assignments:
|
||||
ra.is_active = False
|
||||
ra.revoked_at = datetime.utcnow()
|
||||
ra.revoked_by = current_user
|
||||
ra.edit_by = current_user
|
||||
|
||||
# Step 3: 停用所有個人化服務
|
||||
service_settings = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).all()
|
||||
|
||||
for ss in service_settings:
|
||||
ss.is_active = False
|
||||
ss.disabled_at = datetime.utcnow()
|
||||
ss.disabled_by = current_user
|
||||
ss.edit_by = current_user
|
||||
|
||||
# Step 4: 設定離職日期和狀態
|
||||
emp_setting.resign_date = date.today()
|
||||
emp_setting.employment_status = "resigned"
|
||||
emp_setting.is_active = False
|
||||
emp_setting.edit_by = current_user
|
||||
|
||||
db.commit()
|
||||
|
||||
# 審計日誌
|
||||
resume = emp_setting.resume
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="employee_offboard",
|
||||
resource_type="tenant_emp_settings",
|
||||
resource_id=f"{tenant_id}-{seq_no}",
|
||||
details=f"Offboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw if resume else 'Unknown'}): "
|
||||
f"{len(dept_members)} departments removed, {len(role_assignments)} roles revoked, "
|
||||
f"{len(service_settings)} services disabled",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Employee offboarded successfully",
|
||||
"employee": {
|
||||
"tenant_emp_code": emp_setting.tenant_emp_code,
|
||||
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
|
||||
},
|
||||
"summary": {
|
||||
"departments_removed": len(dept_members),
|
||||
"roles_revoked": len(role_assignments),
|
||||
"services_disabled": len(service_settings),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{tenant_id}/{seq_no}/status")
|
||||
def get_employee_onboarding_status(
|
||||
tenant_id: int,
|
||||
seq_no: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
查詢員工完整的到職狀態
|
||||
|
||||
回傳:
|
||||
- 員工基本資訊
|
||||
- 部門歸屬列表
|
||||
- 角色分配列表
|
||||
- 個人化服務列表
|
||||
"""
|
||||
current_tenant_id = get_current_tenant_id()
|
||||
|
||||
if tenant_id != current_tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No permission to access this tenant"
|
||||
)
|
||||
|
||||
emp_setting = db.query(EmpSetting).filter(
|
||||
EmpSetting.tenant_id == tenant_id,
|
||||
EmpSetting.seq_no == seq_no
|
||||
).first()
|
||||
|
||||
if not emp_setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
|
||||
)
|
||||
|
||||
resume = emp_setting.resume
|
||||
keycloak_user_id = emp_setting.tenant_keycloak_user_id
|
||||
|
||||
# 查詢部門歸屬
|
||||
dept_members = db.query(DepartmentMember).filter(
|
||||
DepartmentMember.tenant_id == tenant_id,
|
||||
DepartmentMember.employee_id == emp_setting.tenant_resume_id,
|
||||
DepartmentMember.is_active == True
|
||||
).all()
|
||||
|
||||
departments = [
|
||||
{
|
||||
"department_id": dm.department_id,
|
||||
"department_name": dm.department.name if dm.department else None,
|
||||
"position": dm.position,
|
||||
"membership_type": dm.membership_type,
|
||||
"joined_at": dm.joined_at.isoformat() if dm.joined_at else None,
|
||||
}
|
||||
for dm in dept_members
|
||||
]
|
||||
|
||||
# 查詢角色分配
|
||||
role_assignments = db.query(UserRoleAssignment).filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
|
||||
UserRoleAssignment.is_active == True
|
||||
).all()
|
||||
|
||||
roles = [
|
||||
{
|
||||
"role_id": ra.role_id,
|
||||
"role_name": ra.role.role_name if ra.role else None,
|
||||
"role_code": ra.role.role_code if ra.role else None,
|
||||
"assigned_at": ra.assigned_at.isoformat() if ra.assigned_at else None,
|
||||
}
|
||||
for ra in role_assignments
|
||||
]
|
||||
|
||||
# 查詢個人化服務
|
||||
service_settings = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).all()
|
||||
|
||||
services = [
|
||||
{
|
||||
"service_id": ss.service_id,
|
||||
"service_name": ss.service.service_name if ss.service else None,
|
||||
"service_code": ss.service.service_code if ss.service else None,
|
||||
"quota_gb": ss.quota_gb,
|
||||
"quota_mb": ss.quota_mb,
|
||||
"enabled_at": ss.enabled_at.isoformat() if ss.enabled_at else None,
|
||||
}
|
||||
for ss in service_settings
|
||||
]
|
||||
|
||||
return {
|
||||
"employee": {
|
||||
"tenant_id": emp_setting.tenant_id,
|
||||
"seq_no": emp_setting.seq_no,
|
||||
"tenant_emp_code": emp_setting.tenant_emp_code,
|
||||
"name": resume.name_tw if resume else None,
|
||||
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
|
||||
"keycloak_username": emp_setting.tenant_keycloak_username,
|
||||
"hire_date": emp_setting.hire_at.isoformat() if emp_setting.hire_at else None,
|
||||
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
|
||||
"employment_status": emp_setting.employment_status,
|
||||
"storage_quota_gb": emp_setting.storage_quota_gb,
|
||||
"email_quota_mb": emp_setting.email_quota_mb,
|
||||
},
|
||||
"departments": departments,
|
||||
"roles": roles,
|
||||
"services": services,
|
||||
}
|
||||
381
backend/app/api/v1/employees.py
Normal file
381
backend/app/api/v1/employees.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
員工管理 API
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee, EmployeeStatus
|
||||
from app.models.emp_setting import EmpSetting
|
||||
from app.models.emp_resume import EmpResume
|
||||
from app.models.department import Department
|
||||
from app.models.department_member import DepartmentMember
|
||||
from app.schemas.employee import (
|
||||
EmployeeCreate,
|
||||
EmployeeUpdate,
|
||||
EmployeeResponse,
|
||||
EmployeeListItem,
|
||||
)
|
||||
from app.schemas.base import PaginationParams, PaginatedResponse
|
||||
from app.schemas.response import SuccessResponse, MessageResponse
|
||||
from app.api.deps import get_pagination_params
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse)
|
||||
def get_employees(
|
||||
db: Session = Depends(get_db),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
status_filter: Optional[EmployeeStatus] = Query(None, description="員工狀態篩選"),
|
||||
search: Optional[str] = Query(None, description="搜尋關鍵字 (姓名或帳號)"),
|
||||
):
|
||||
"""
|
||||
獲取員工列表
|
||||
|
||||
支援:
|
||||
- 分頁
|
||||
- 狀態篩選
|
||||
- 關鍵字搜尋 (姓名、帳號)
|
||||
"""
|
||||
# ⚠️ 暫時改為查詢 EmpSetting (因為 Employee model 對應的 tenant_employees 表不存在)
|
||||
query = db.query(EmpSetting).join(EmpResume, EmpSetting.tenant_resume_id == EmpResume.id)
|
||||
|
||||
# 狀態篩選
|
||||
if status_filter:
|
||||
query = query.filter(EmpSetting.employment_status == status_filter)
|
||||
|
||||
# 搜尋 (在 EmpResume 中搜尋)
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
EmpResume.legal_name.ilike(search_pattern),
|
||||
EmpResume.english_name.ilike(search_pattern),
|
||||
EmpSetting.tenant_emp_code.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
# 總數
|
||||
total = query.count()
|
||||
|
||||
# 分頁
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
emp_settings = query.offset(offset).limit(pagination.page_size).all()
|
||||
|
||||
# 計算總頁數
|
||||
total_pages = (total + pagination.page_size - 1) // pagination.page_size
|
||||
|
||||
# 轉換為回應格式 (暫時簡化,不使用 EmployeeListItem)
|
||||
items = []
|
||||
for emp_setting in emp_settings:
|
||||
resume = emp_setting.resume
|
||||
items.append({
|
||||
"id": emp_setting.id if hasattr(emp_setting, 'id') else emp_setting.tenant_id * 10000 + emp_setting.seq_no,
|
||||
"employee_id": emp_setting.tenant_emp_code,
|
||||
"legal_name": resume.legal_name if resume else "",
|
||||
"english_name": resume.english_name if resume else "",
|
||||
"status": emp_setting.employment_status,
|
||||
"hire_date": emp_setting.hire_at.isoformat() if emp_setting.hire_at else None,
|
||||
"is_active": emp_setting.is_active,
|
||||
})
|
||||
|
||||
return PaginatedResponse(
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
total_pages=total_pages,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{employee_id}", response_model=EmployeeResponse)
|
||||
def get_employee(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取員工詳情 (Phase 2.4: 包含主要身份完整資訊)
|
||||
"""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found"
|
||||
)
|
||||
|
||||
# 組建回應
|
||||
response = EmployeeResponse.model_validate(employee)
|
||||
response.has_network_drive = employee.network_drive is not None
|
||||
response.department_count = sum(1 for m in employee.department_memberships if m.is_active)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/", response_model=EmployeeResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_employee(
|
||||
employee_data: EmployeeCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
創建員工 (Phase 2.3: 同時創建第一個員工身份)
|
||||
|
||||
自動生成員工編號 (EMP001, EMP002, ...)
|
||||
同時創建第一個 employee_identity 記錄 (主要身份)
|
||||
"""
|
||||
# 檢查 username_base 是否已存在
|
||||
existing = db.query(Employee).filter(
|
||||
Employee.username_base == employee_data.username_base
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Username '{employee_data.username_base}' already exists"
|
||||
)
|
||||
|
||||
# 檢查部門是否存在 (如果有提供)
|
||||
department = None
|
||||
if employee_data.department_id:
|
||||
department = db.query(Department).filter(
|
||||
Department.id == employee_data.department_id,
|
||||
Department.is_active == True
|
||||
).first()
|
||||
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department with id {employee_data.department_id} not found or inactive"
|
||||
)
|
||||
|
||||
# 生成員工編號
|
||||
last_employee = db.query(Employee).order_by(Employee.id.desc()).first()
|
||||
if last_employee and last_employee.employee_id.startswith("EMP"):
|
||||
try:
|
||||
last_number = int(last_employee.employee_id[3:])
|
||||
new_number = last_number + 1
|
||||
except ValueError:
|
||||
new_number = 1
|
||||
else:
|
||||
new_number = 1
|
||||
|
||||
employee_id = f"EMP{new_number:03d}"
|
||||
|
||||
# 創建員工
|
||||
# TODO: 從 JWT token 或 session 獲取 tenant_id,目前暫時使用預設值 1
|
||||
tenant_id = 1 # 預設租戶 ID
|
||||
|
||||
employee = Employee(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=employee_id,
|
||||
username_base=employee_data.username_base,
|
||||
legal_name=employee_data.legal_name,
|
||||
english_name=employee_data.english_name,
|
||||
phone=employee_data.phone,
|
||||
mobile=employee_data.mobile,
|
||||
hire_date=employee_data.hire_date,
|
||||
status=EmployeeStatus.ACTIVE,
|
||||
)
|
||||
|
||||
db.add(employee)
|
||||
db.flush() # 先 flush 以取得 employee.id
|
||||
|
||||
# 若有指定部門,建立 department_member 紀錄
|
||||
if department:
|
||||
membership = DepartmentMember(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=employee.id,
|
||||
department_id=department.id,
|
||||
position=employee_data.job_title,
|
||||
membership_type="permanent",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(membership)
|
||||
|
||||
db.commit()
|
||||
db.refresh(employee)
|
||||
|
||||
# 創建審計日誌
|
||||
audit_service.log_create(
|
||||
db=db,
|
||||
resource_type="employee",
|
||||
resource_id=employee.id,
|
||||
performed_by="system@porscheworld.tw", # TODO: 從 JWT Token 獲取
|
||||
details={
|
||||
"employee_id": employee.employee_id,
|
||||
"username_base": employee.username_base,
|
||||
"legal_name": employee.legal_name,
|
||||
"department_id": employee_data.department_id,
|
||||
"job_title": employee_data.job_title,
|
||||
},
|
||||
ip_address=audit_service.get_client_ip(request),
|
||||
)
|
||||
|
||||
response = EmployeeResponse.model_validate(employee)
|
||||
response.has_network_drive = False
|
||||
response.department_count = 1 if department else 0
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{employee_id}", response_model=EmployeeResponse)
|
||||
def update_employee(
|
||||
employee_id: int,
|
||||
employee_data: EmployeeUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新員工資料
|
||||
"""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found"
|
||||
)
|
||||
|
||||
# 記錄舊值 (用於審計日誌)
|
||||
old_values = audit_service.model_to_dict(employee)
|
||||
|
||||
# 更新欄位 (只更新提供的欄位)
|
||||
update_data = employee_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(employee, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(employee)
|
||||
|
||||
# 創建審計日誌
|
||||
if update_data: # 只有實際有更新時才記錄
|
||||
audit_service.log_update(
|
||||
db=db,
|
||||
resource_type="employee",
|
||||
resource_id=employee.id,
|
||||
performed_by="system@porscheworld.tw", # TODO: 從 JWT Token 獲取
|
||||
old_values={k: old_values[k] for k in update_data.keys() if k in old_values},
|
||||
new_values=update_data,
|
||||
ip_address=audit_service.get_client_ip(request),
|
||||
)
|
||||
|
||||
response = EmployeeResponse.model_validate(employee)
|
||||
response.has_network_drive = employee.network_drive is not None
|
||||
response.department_count = sum(1 for m in employee.department_memberships if m.is_active)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{employee_id}", response_model=MessageResponse)
|
||||
def delete_employee(
|
||||
employee_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
停用員工
|
||||
|
||||
注意: 這是軟刪除,只將狀態設為 terminated
|
||||
"""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found"
|
||||
)
|
||||
|
||||
# 軟刪除
|
||||
employee.status = EmployeeStatus.TERMINATED
|
||||
|
||||
# 停用所有部門成員資格
|
||||
from datetime import datetime
|
||||
memberships_deactivated = 0
|
||||
for membership in employee.department_memberships:
|
||||
if membership.is_active:
|
||||
membership.is_active = False
|
||||
membership.ended_at = datetime.utcnow()
|
||||
memberships_deactivated += 1
|
||||
|
||||
# 停用 NAS 帳號
|
||||
has_nas = employee.network_drive is not None
|
||||
if employee.network_drive:
|
||||
employee.network_drive.is_active = False
|
||||
|
||||
db.commit()
|
||||
|
||||
# 創建審計日誌
|
||||
audit_service.log_delete(
|
||||
db=db,
|
||||
resource_type="employee",
|
||||
resource_id=employee.id,
|
||||
performed_by="system@porscheworld.tw", # TODO: 從 JWT Token 獲取
|
||||
details={
|
||||
"employee_id": employee.employee_id,
|
||||
"username_base": employee.username_base,
|
||||
"legal_name": employee.legal_name,
|
||||
"memberships_deactivated": memberships_deactivated,
|
||||
"nas_deactivated": has_nas,
|
||||
},
|
||||
ip_address=audit_service.get_client_ip(request),
|
||||
)
|
||||
|
||||
# TODO: 停用 Keycloak 帳號
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Employee {employee.employee_id} ({employee.legal_name}) has been terminated"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{employee_id}/activate", response_model=MessageResponse)
|
||||
def activate_employee(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
重新啟用員工
|
||||
"""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found"
|
||||
)
|
||||
|
||||
employee.status = EmployeeStatus.ACTIVE
|
||||
|
||||
db.commit()
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Employee {employee.employee_id} ({employee.legal_name}) has been activated"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{employee_id}/identities", response_model=List, deprecated=True)
|
||||
def get_employee_identities(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
[已廢棄] 獲取員工的所有身份
|
||||
|
||||
此端點已廢棄,請使用 GET /department-members/?employee_id={id} 取代
|
||||
"""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found"
|
||||
)
|
||||
|
||||
# 廢棄端點: 回傳空列表,請改用 /department-members/?employee_id={id}
|
||||
return []
|
||||
937
backend/app/api/v1/endpoints/installation.py
Normal file
937
backend/app/api/v1/endpoints/installation.py
Normal file
@@ -0,0 +1,937 @@
|
||||
"""
|
||||
初始化系統 API Endpoints
|
||||
"""
|
||||
import os
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api import deps
|
||||
from app.services.environment_checker import EnvironmentChecker
|
||||
from app.services.installation_service import InstallationService
|
||||
from app.models import Tenant, InstallationSession
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ==================== Pydantic Schemas ====================
|
||||
|
||||
class DatabaseConfig(BaseModel):
|
||||
"""資料庫連線設定"""
|
||||
host: str = Field(..., description="主機位址")
|
||||
port: int = Field(5432, description="Port")
|
||||
database: str = Field(..., description="資料庫名稱")
|
||||
user: str = Field(..., description="使用者帳號")
|
||||
password: str = Field(..., description="密碼")
|
||||
|
||||
|
||||
class RedisConfig(BaseModel):
|
||||
"""Redis 連線設定"""
|
||||
host: str = Field(..., description="主機位址")
|
||||
port: int = Field(6379, description="Port")
|
||||
password: Optional[str] = Field(None, description="密碼")
|
||||
db: int = Field(0, description="資料庫編號")
|
||||
|
||||
|
||||
class KeycloakConfig(BaseModel):
|
||||
"""Keycloak 連線設定"""
|
||||
url: str = Field(..., description="Keycloak URL")
|
||||
realm: str = Field(..., description="Realm 名稱")
|
||||
admin_username: str = Field(..., description="Admin 帳號")
|
||||
admin_password: str = Field(..., description="Admin 密碼")
|
||||
|
||||
|
||||
class TenantInfoInput(BaseModel):
|
||||
"""公司資訊輸入"""
|
||||
company_name: str
|
||||
company_name_en: Optional[str] = None
|
||||
tenant_code: str # Keycloak Realm 名稱
|
||||
tenant_prefix: str # 員工編號前綴
|
||||
tax_id: Optional[str] = None
|
||||
tel: Optional[str] = None
|
||||
add: Optional[str] = None
|
||||
domain_set: int = 2 # 郵件網域條件:1=組織網域,2=部門網域
|
||||
domain: Optional[str] = None # 組織網域(domain_set=1 時使用)
|
||||
|
||||
|
||||
class AdminSetupInput(BaseModel):
|
||||
"""管理員設定輸入"""
|
||||
admin_legal_name: str
|
||||
admin_english_name: str
|
||||
admin_email: str
|
||||
admin_phone: Optional[str] = None
|
||||
password_method: str = Field("auto", description="auto 或 manual")
|
||||
manual_password: Optional[str] = None
|
||||
|
||||
|
||||
class DepartmentSetupInput(BaseModel):
|
||||
"""部門設定輸入"""
|
||||
department_code: str
|
||||
department_name: str
|
||||
department_name_en: Optional[str] = None
|
||||
email_domain: str
|
||||
depth: int = 0
|
||||
|
||||
|
||||
# ==================== Phase 0: 系統狀態檢查 ====================
|
||||
|
||||
@router.get("/check-status")
|
||||
async def check_system_status(db: Session = Depends(deps.get_db)):
|
||||
"""
|
||||
檢查系統狀態(三階段:Initialization/Operational/Transition)
|
||||
|
||||
Returns:
|
||||
current_phase: initialization | operational | transition
|
||||
is_initialized: True/False
|
||||
next_action: 建議的下一步操作
|
||||
"""
|
||||
from app.models.installation import InstallationSystemStatus, InstallationEnvironmentConfig
|
||||
|
||||
try:
|
||||
# 取得系統狀態記錄(應該只有一筆)
|
||||
system_status = db.query(InstallationSystemStatus).first()
|
||||
|
||||
if not system_status:
|
||||
# 如果沒有記錄,建立一個初始狀態
|
||||
system_status = InstallationSystemStatus(
|
||||
current_phase="initialization",
|
||||
initialization_completed=False,
|
||||
is_locked=False
|
||||
)
|
||||
db.add(system_status)
|
||||
db.commit()
|
||||
db.refresh(system_status)
|
||||
|
||||
# 檢查環境配置完成度
|
||||
required_categories = ["redis", "database", "keycloak"]
|
||||
configured_categories = db.query(InstallationEnvironmentConfig.config_category).filter(
|
||||
InstallationEnvironmentConfig.is_configured == True
|
||||
).distinct().all()
|
||||
configured_categories = [cat[0] for cat in configured_categories]
|
||||
|
||||
# 只計算必要類別中已完成的數量
|
||||
configured_required_count = sum(1 for cat in required_categories if cat in configured_categories)
|
||||
all_required_configured = all(cat in configured_categories for cat in required_categories)
|
||||
|
||||
result = {
|
||||
"current_phase": system_status.current_phase,
|
||||
"is_initialized": system_status.initialization_completed and all_required_configured,
|
||||
"initialization_completed": system_status.initialization_completed,
|
||||
"configured_count": configured_required_count,
|
||||
"configured_categories": configured_categories,
|
||||
"missing_categories": [cat for cat in required_categories if cat not in configured_categories],
|
||||
"is_locked": system_status.is_locked,
|
||||
}
|
||||
|
||||
# 根據當前階段決定 next_action
|
||||
if system_status.current_phase == "initialization":
|
||||
if all_required_configured:
|
||||
result["next_action"] = "complete_initialization"
|
||||
result["message"] = "環境配置完成,請繼續完成初始化流程"
|
||||
else:
|
||||
result["next_action"] = "continue_setup"
|
||||
result["message"] = "請繼續設定環境"
|
||||
|
||||
elif system_status.current_phase == "operational":
|
||||
result["next_action"] = "health_check"
|
||||
result["message"] = "系統運作中,可進行健康檢查"
|
||||
result["last_health_check_at"] = system_status.last_health_check_at.isoformat() if system_status.last_health_check_at else None
|
||||
result["health_check_status"] = system_status.health_check_status
|
||||
|
||||
elif system_status.current_phase == "transition":
|
||||
result["next_action"] = "consistency_check"
|
||||
result["message"] = "系統處於移轉階段,需進行一致性檢查"
|
||||
result["env_db_consistent"] = system_status.env_db_consistent
|
||||
result["inconsistencies"] = system_status.inconsistencies
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 如果無法連接資料庫或表不存在,視為未初始化
|
||||
import traceback
|
||||
return {
|
||||
"current_phase": "initialization",
|
||||
"is_initialized": False,
|
||||
"initialization_completed": False,
|
||||
"configured_count": 0,
|
||||
"configured_categories": [],
|
||||
"missing_categories": ["redis", "database", "keycloak"],
|
||||
"next_action": "start_initialization",
|
||||
"message": f"資料庫檢查失敗,請開始初始化: {str(e)}",
|
||||
"traceback": traceback.format_exc()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health-check")
|
||||
async def health_check():
|
||||
"""
|
||||
完整的健康檢查(已初始化系統使用)
|
||||
|
||||
Returns:
|
||||
所有環境組件的檢測結果
|
||||
"""
|
||||
checker = EnvironmentChecker()
|
||||
report = checker.check_all()
|
||||
|
||||
# 計算整體狀態
|
||||
statuses = [comp["status"] for comp in report["components"].values()]
|
||||
|
||||
if all(s == "ok" for s in statuses):
|
||||
report["overall_status"] = "healthy"
|
||||
elif any(s == "error" for s in statuses):
|
||||
report["overall_status"] = "unhealthy"
|
||||
else:
|
||||
report["overall_status"] = "degraded"
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# ==================== Phase 1: Redis 設定 ====================
|
||||
|
||||
@router.post("/test-redis")
|
||||
async def test_redis_connection(config: RedisConfig):
|
||||
"""
|
||||
測試 Redis 連線
|
||||
|
||||
- 測試連線是否成功
|
||||
- 測試 PING 命令
|
||||
"""
|
||||
checker = EnvironmentChecker()
|
||||
|
||||
result = checker.test_redis_connection(
|
||||
host=config.host,
|
||||
port=config.port,
|
||||
password=config.password,
|
||||
db=config.db
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["error"]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/get-config/{category}")
|
||||
async def get_saved_config(category: str, db: Session = Depends(deps.get_db)):
|
||||
"""
|
||||
讀取已儲存的環境配置
|
||||
|
||||
- category: redis, database, keycloak
|
||||
- 回傳: 已儲存的配置資料 (敏感欄位會遮罩)
|
||||
"""
|
||||
from app.models.installation import InstallationEnvironmentConfig
|
||||
|
||||
configs = db.query(InstallationEnvironmentConfig).filter(
|
||||
InstallationEnvironmentConfig.config_category == category,
|
||||
InstallationEnvironmentConfig.is_configured == True
|
||||
).all()
|
||||
|
||||
if not configs:
|
||||
return {
|
||||
"configured": False,
|
||||
"config": {}
|
||||
}
|
||||
|
||||
# 將配置轉換為字典
|
||||
config_dict = {}
|
||||
for cfg in configs:
|
||||
# 移除前綴 (例如 REDIS_HOST → host)
|
||||
# 先移除前綴,再轉小寫
|
||||
key = cfg.config_key.replace(f"{category.upper()}_", "").lower()
|
||||
|
||||
# 敏感欄位不回傳實際值
|
||||
if cfg.is_sensitive:
|
||||
config_dict[key] = "****" if cfg.config_value else ""
|
||||
else:
|
||||
config_dict[key] = cfg.config_value
|
||||
|
||||
return {
|
||||
"configured": True,
|
||||
"config": config_dict
|
||||
}
|
||||
|
||||
|
||||
@router.post("/setup-redis")
|
||||
async def setup_redis(config: RedisConfig, db: Session = Depends(deps.get_db)):
|
||||
"""
|
||||
設定 Redis
|
||||
|
||||
1. 測試連線
|
||||
2. 寫入 .env
|
||||
3. 寫入資料庫 (installation_environment_config)
|
||||
"""
|
||||
from app.models.installation import InstallationEnvironmentConfig
|
||||
from datetime import datetime
|
||||
|
||||
checker = EnvironmentChecker()
|
||||
|
||||
# 1. 測試連線
|
||||
test_result = checker.test_redis_connection(
|
||||
host=config.host,
|
||||
port=config.port,
|
||||
password=config.password,
|
||||
db=config.db
|
||||
)
|
||||
|
||||
if not test_result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=test_result["error"]
|
||||
)
|
||||
|
||||
# 2. 寫入 .env
|
||||
update_env_file("REDIS_HOST", config.host)
|
||||
update_env_file("REDIS_PORT", str(config.port))
|
||||
if config.password:
|
||||
update_env_file("REDIS_PASSWORD", config.password)
|
||||
update_env_file("REDIS_DB", str(config.db))
|
||||
|
||||
# 3. 寫入資料庫
|
||||
configs_to_save = [
|
||||
{"key": "REDIS_HOST", "value": config.host, "sensitive": False},
|
||||
{"key": "REDIS_PORT", "value": str(config.port), "sensitive": False},
|
||||
{"key": "REDIS_PASSWORD", "value": config.password or "", "sensitive": True},
|
||||
{"key": "REDIS_DB", "value": str(config.db), "sensitive": False},
|
||||
]
|
||||
|
||||
for cfg in configs_to_save:
|
||||
existing = db.query(InstallationEnvironmentConfig).filter(
|
||||
InstallationEnvironmentConfig.config_key == cfg["key"]
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.config_value = cfg["value"]
|
||||
existing.is_sensitive = cfg["sensitive"]
|
||||
existing.is_configured = True
|
||||
existing.configured_at = datetime.now()
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
new_config = InstallationEnvironmentConfig(
|
||||
config_key=cfg["key"],
|
||||
config_value=cfg["value"],
|
||||
config_category="redis",
|
||||
is_sensitive=cfg["sensitive"],
|
||||
is_configured=True,
|
||||
configured_at=datetime.now()
|
||||
)
|
||||
db.add(new_config)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 重新載入環境變數
|
||||
os.environ["REDIS_HOST"] = config.host
|
||||
os.environ["REDIS_PORT"] = str(config.port)
|
||||
if config.password:
|
||||
os.environ["REDIS_PASSWORD"] = config.password
|
||||
os.environ["REDIS_DB"] = str(config.db)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Redis 設定完成並已記錄至資料庫",
|
||||
"next_step": "setup_database"
|
||||
}
|
||||
|
||||
|
||||
# ==================== Phase 2: 資料庫設定 ====================
|
||||
|
||||
@router.post("/test-database")
|
||||
async def test_database_connection(config: DatabaseConfig):
|
||||
"""
|
||||
測試資料庫連線
|
||||
|
||||
- 測試連線是否成功
|
||||
- 不寫入任何設定
|
||||
"""
|
||||
checker = EnvironmentChecker()
|
||||
|
||||
result = checker.test_database_connection(
|
||||
host=config.host,
|
||||
port=config.port,
|
||||
database=config.database,
|
||||
user=config.user,
|
||||
password=config.password
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["error"]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/setup-database")
|
||||
async def setup_database(config: DatabaseConfig):
|
||||
"""
|
||||
設定資料庫並執行初始化
|
||||
|
||||
1. 測試連線
|
||||
2. 寫入 .env
|
||||
3. 執行 migrations
|
||||
4. 建立預設租戶
|
||||
"""
|
||||
checker = EnvironmentChecker()
|
||||
|
||||
# 1. 測試連線
|
||||
test_result = checker.test_database_connection(
|
||||
host=config.host,
|
||||
port=config.port,
|
||||
database=config.database,
|
||||
user=config.user,
|
||||
password=config.password
|
||||
)
|
||||
|
||||
if not test_result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=test_result["error"]
|
||||
)
|
||||
|
||||
# 2. 建立連線字串
|
||||
connection_string = (
|
||||
f"postgresql+psycopg2://{config.user}:{config.password}"
|
||||
f"@{config.host}:{config.port}/{config.database}"
|
||||
)
|
||||
|
||||
# 3. 寫入 .env
|
||||
update_env_file("DATABASE_URL", connection_string)
|
||||
|
||||
# 重新載入環境變數
|
||||
os.environ["DATABASE_URL"] = connection_string
|
||||
|
||||
# 4. 執行 migrations
|
||||
try:
|
||||
run_alembic_migrations()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"資料表建立失敗: {str(e)}"
|
||||
)
|
||||
|
||||
# 5. 建立預設租戶(未初始化狀態)
|
||||
from app.db.session import get_session_local
|
||||
from app.models.installation import InstallationEnvironmentConfig
|
||||
from datetime import datetime
|
||||
|
||||
SessionLocal = get_session_local()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing_tenant = db.query(Tenant).first()
|
||||
if not existing_tenant:
|
||||
tenant = Tenant(
|
||||
code='temp',
|
||||
name='待設定',
|
||||
keycloak_realm='temp',
|
||||
is_initialized=False
|
||||
)
|
||||
db.add(tenant)
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
tenant_id = tenant.id
|
||||
else:
|
||||
tenant_id = existing_tenant.id
|
||||
|
||||
# 6. 寫入資料庫配置記錄
|
||||
configs_to_save = [
|
||||
{"key": "DATABASE_URL", "value": connection_string, "sensitive": True},
|
||||
{"key": "DATABASE_HOST", "value": config.host, "sensitive": False},
|
||||
{"key": "DATABASE_PORT", "value": str(config.port), "sensitive": False},
|
||||
{"key": "DATABASE_NAME", "value": config.database, "sensitive": False},
|
||||
{"key": "DATABASE_USER", "value": config.user, "sensitive": False},
|
||||
]
|
||||
|
||||
for cfg in configs_to_save:
|
||||
existing = db.query(InstallationEnvironmentConfig).filter(
|
||||
InstallationEnvironmentConfig.config_key == cfg["key"]
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.config_value = cfg["value"]
|
||||
existing.is_sensitive = cfg["sensitive"]
|
||||
existing.is_configured = True
|
||||
existing.configured_at = datetime.now()
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
new_config = InstallationEnvironmentConfig(
|
||||
config_key=cfg["key"],
|
||||
config_value=cfg["value"],
|
||||
config_category="database",
|
||||
is_sensitive=cfg["sensitive"],
|
||||
is_configured=True,
|
||||
configured_at=datetime.now()
|
||||
)
|
||||
db.add(new_config)
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "資料庫設定完成並已記錄",
|
||||
"tenant_id": tenant_id,
|
||||
"next_step": "setup_keycloak"
|
||||
}
|
||||
|
||||
|
||||
# ==================== Phase 3: Keycloak 設定 ====================
|
||||
|
||||
@router.post("/test-keycloak")
|
||||
async def test_keycloak_connection(config: KeycloakConfig):
|
||||
"""
|
||||
測試 Keycloak 連線
|
||||
|
||||
- 測試服務是否運行
|
||||
- 驗證管理員權限
|
||||
- 檢查 Realm 是否存在
|
||||
"""
|
||||
checker = EnvironmentChecker()
|
||||
|
||||
result = checker.test_keycloak_connection(
|
||||
url=config.url,
|
||||
realm=config.realm,
|
||||
admin_username=config.admin_username,
|
||||
admin_password=config.admin_password
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["error"]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/setup-keycloak")
|
||||
async def setup_keycloak(config: KeycloakConfig, db: Session = Depends(deps.get_db)):
|
||||
"""
|
||||
設定 Keycloak
|
||||
|
||||
1. 測試連線
|
||||
2. 寫入 .env
|
||||
3. 寫入資料庫
|
||||
4. 建立 Realm (如果不存在)
|
||||
5. 建立 Clients
|
||||
"""
|
||||
from app.models.installation import InstallationEnvironmentConfig
|
||||
from datetime import datetime
|
||||
|
||||
checker = EnvironmentChecker()
|
||||
|
||||
# 1. 測試連線
|
||||
test_result = checker.test_keycloak_connection(
|
||||
url=config.url,
|
||||
realm=config.realm,
|
||||
admin_username=config.admin_username,
|
||||
admin_password=config.admin_password
|
||||
)
|
||||
|
||||
if not test_result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=test_result["error"]
|
||||
)
|
||||
|
||||
# 2. 寫入 .env
|
||||
update_env_file("KEYCLOAK_URL", config.url)
|
||||
update_env_file("KEYCLOAK_REALM", config.realm)
|
||||
update_env_file("KEYCLOAK_ADMIN_USERNAME", config.admin_username)
|
||||
update_env_file("KEYCLOAK_ADMIN_PASSWORD", config.admin_password)
|
||||
|
||||
# 3. 寫入資料庫
|
||||
configs_to_save = [
|
||||
{"key": "KEYCLOAK_URL", "value": config.url, "sensitive": False},
|
||||
{"key": "KEYCLOAK_REALM", "value": config.realm, "sensitive": False},
|
||||
{"key": "KEYCLOAK_ADMIN_USERNAME", "value": config.admin_username, "sensitive": False},
|
||||
{"key": "KEYCLOAK_ADMIN_PASSWORD", "value": config.admin_password, "sensitive": True},
|
||||
]
|
||||
|
||||
for cfg in configs_to_save:
|
||||
existing = db.query(InstallationEnvironmentConfig).filter(
|
||||
InstallationEnvironmentConfig.config_key == cfg["key"]
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.config_value = cfg["value"]
|
||||
existing.is_sensitive = cfg["sensitive"]
|
||||
existing.is_configured = True
|
||||
existing.configured_at = datetime.now()
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
new_config = InstallationEnvironmentConfig(
|
||||
config_key=cfg["key"],
|
||||
config_value=cfg["value"],
|
||||
config_category="keycloak",
|
||||
is_sensitive=cfg["sensitive"],
|
||||
is_configured=True,
|
||||
configured_at=datetime.now()
|
||||
)
|
||||
db.add(new_config)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 重新載入環境變數
|
||||
os.environ["KEYCLOAK_URL"] = config.url
|
||||
os.environ["KEYCLOAK_REALM"] = config.realm
|
||||
|
||||
# 4. 建立/驗證 Realm 和 Clients
|
||||
from app.services.keycloak_service import KeycloakService
|
||||
kc_service = KeycloakService()
|
||||
|
||||
try:
|
||||
# 這裡可以加入自動建立 Realm 和 Clients 的邏輯
|
||||
# 目前先假設 Keycloak 已手動設定
|
||||
pass
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Keycloak 設定失敗: {str(e)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Keycloak 設定完成並已記錄",
|
||||
"realm_exists": test_result["realm_exists"],
|
||||
"next_step": "setup_company_info"
|
||||
}
|
||||
|
||||
|
||||
# ==================== Phase 4: 公司資訊設定 ====================
|
||||
|
||||
@router.post("/sessions")
|
||||
async def create_installation_session(
|
||||
environment: str = "production",
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""
|
||||
建立安裝會話
|
||||
|
||||
- 開始初始化流程前必須先建立會話
|
||||
- 初始化時租戶尚未建立,所以 tenant_id 為 None
|
||||
"""
|
||||
service = InstallationService(db)
|
||||
session = service.create_session(
|
||||
tenant_id=None, # 初始化時還沒有租戶
|
||||
environment=environment,
|
||||
executed_by='installer'
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"tenant_id": session.tenant_id,
|
||||
"status": session.status
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/tenant-info")
|
||||
async def save_tenant_info(
|
||||
session_id: int,
|
||||
data: TenantInfoInput,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""
|
||||
儲存公司資訊
|
||||
|
||||
- 填寫完畢後即時儲存
|
||||
- 可重複呼叫更新
|
||||
"""
|
||||
service = InstallationService(db)
|
||||
|
||||
try:
|
||||
tenant_info = service.save_tenant_info(
|
||||
session_id=session_id,
|
||||
tenant_info_data=data.dict()
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "公司資訊已儲存",
|
||||
"next_step": "setup_admin"
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# ==================== Phase 5: 管理員設定 ====================
|
||||
|
||||
@router.post("/sessions/{session_id}/admin-setup")
|
||||
async def setup_admin_credentials(
|
||||
session_id: int,
|
||||
data: AdminSetupInput,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""
|
||||
設定系統管理員並產生初始密碼
|
||||
|
||||
- 產生臨時密碼
|
||||
- 返回明文密碼(僅此一次)
|
||||
"""
|
||||
service = InstallationService(db)
|
||||
|
||||
try:
|
||||
# 預設資訊
|
||||
admin_data = {
|
||||
"admin_employee_id": "ADMIN001",
|
||||
"admin_username": "admin",
|
||||
"admin_legal_name": data.admin_legal_name,
|
||||
"admin_english_name": data.admin_english_name,
|
||||
"admin_email": data.admin_email,
|
||||
"admin_phone": data.admin_phone
|
||||
}
|
||||
|
||||
tenant_info, initial_password = service.setup_admin_credentials(
|
||||
session_id=session_id,
|
||||
admin_data=admin_data,
|
||||
password_method=data.password_method,
|
||||
manual_password=data.manual_password
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "管理員已設定",
|
||||
"username": "admin",
|
||||
"email": data.admin_email,
|
||||
"initial_password": initial_password, # ⚠️ 僅返回一次
|
||||
"password_method": data.password_method,
|
||||
"next_step": "setup_departments"
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# ==================== Phase 6: 部門設定 ====================
|
||||
|
||||
@router.post("/sessions/{session_id}/departments")
|
||||
async def setup_departments(
|
||||
session_id: int,
|
||||
departments: list[DepartmentSetupInput],
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""
|
||||
設定部門架構
|
||||
|
||||
- 可一次設定多個部門
|
||||
"""
|
||||
service = InstallationService(db)
|
||||
|
||||
try:
|
||||
dept_setups = service.setup_departments(
|
||||
session_id=session_id,
|
||||
departments_data=[d.dict() for d in departments]
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已設定 {len(dept_setups)} 個部門",
|
||||
"next_step": "execute_initialization"
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# ==================== Phase 7: 執行初始化 ====================
|
||||
|
||||
@router.post("/sessions/{session_id}/execute")
|
||||
async def execute_initialization(
|
||||
session_id: int,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""
|
||||
執行完整的初始化流程
|
||||
|
||||
1. 更新租戶資料
|
||||
2. 建立部門
|
||||
3. 建立管理員員工
|
||||
4. 建立 Keycloak 用戶
|
||||
5. 分配系統管理員角色
|
||||
6. 標記完成並鎖定
|
||||
"""
|
||||
service = InstallationService(db)
|
||||
|
||||
try:
|
||||
results = service.execute_initialization(session_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "初始化完成",
|
||||
"results": results,
|
||||
"next_step": "redirect_to_login"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
# ==================== 查詢與管理 ====================
|
||||
|
||||
@router.get("/sessions/{session_id}")
|
||||
async def get_installation_session(
|
||||
session_id: int,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""
|
||||
取得安裝會話詳細資訊
|
||||
|
||||
- 如果已鎖定,敏感資訊將被隱藏
|
||||
"""
|
||||
service = InstallationService(db)
|
||||
|
||||
try:
|
||||
details = service.get_session_details(
|
||||
session_id=session_id,
|
||||
include_sensitive=False # 預設不包含密碼
|
||||
)
|
||||
return details
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/clear-password")
|
||||
async def clear_plain_password(
|
||||
session_id: int,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""
|
||||
清除臨時密碼的明文
|
||||
|
||||
- 使用者確認已複製密碼後呼叫
|
||||
"""
|
||||
service = InstallationService(db)
|
||||
|
||||
cleared = service.clear_plain_password(
|
||||
session_id=session_id,
|
||||
reason='user_confirmed'
|
||||
)
|
||||
|
||||
return {
|
||||
"success": cleared,
|
||||
"message": "明文密碼已清除" if cleared else "找不到需要清除的密碼"
|
||||
}
|
||||
|
||||
|
||||
# ==================== 輔助函數 ====================
|
||||
|
||||
def update_env_file(key: str, value: str):
|
||||
"""
|
||||
更新 .env 檔案
|
||||
|
||||
- 如果 key 已存在,更新值
|
||||
- 如果不存在,新增一行
|
||||
"""
|
||||
env_path = os.path.join(os.getcwd(), '.env')
|
||||
|
||||
# 讀取現有內容
|
||||
lines = []
|
||||
key_found = False
|
||||
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 更新現有 key
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith(f"{key}="):
|
||||
lines[i] = f"{key}={value}\n"
|
||||
key_found = True
|
||||
break
|
||||
|
||||
# 如果 key 不存在,新增
|
||||
if not key_found:
|
||||
lines.append(f"{key}={value}\n")
|
||||
|
||||
# 寫回檔案
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
|
||||
def run_alembic_migrations():
|
||||
"""
|
||||
執行 Alembic migrations
|
||||
|
||||
- 使用 subprocess 呼叫 alembic upgrade head
|
||||
- Windows 環境下使用 Python 模組調用方式
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'alembic', 'upgrade', 'head'],
|
||||
cwd=os.getcwd(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"Alembic 執行失敗: {result.stderr}")
|
||||
|
||||
return result.stdout
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Exception("Alembic 執行逾時")
|
||||
except Exception as e:
|
||||
raise Exception(f"Alembic 執行錯誤: {str(e)}")
|
||||
|
||||
|
||||
# ==================== 開發測試工具 ====================
|
||||
|
||||
@router.delete("/reset-config/{category}")
|
||||
async def reset_environment_config(
|
||||
category: str,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""
|
||||
重置環境配置(開發測試用)
|
||||
|
||||
- category: redis, database, keycloak, 或 all
|
||||
- 刪除對應的配置記錄
|
||||
"""
|
||||
from app.models.installation import InstallationEnvironmentConfig
|
||||
|
||||
if category == "all":
|
||||
# 刪除所有配置
|
||||
db.query(InstallationEnvironmentConfig).delete()
|
||||
db.commit()
|
||||
return {"success": True, "message": "已重置所有環境配置"}
|
||||
else:
|
||||
# 刪除特定分類的配置
|
||||
deleted = db.query(InstallationEnvironmentConfig).filter(
|
||||
InstallationEnvironmentConfig.config_category == category
|
||||
).delete()
|
||||
db.commit()
|
||||
|
||||
if deleted > 0:
|
||||
return {"success": True, "message": f"已重置 {category} 配置 ({deleted} 筆記錄)"}
|
||||
else:
|
||||
return {"success": False, "message": f"找不到 {category} 的配置記錄"}
|
||||
|
||||
|
||||
# ==================== 系統階段轉換 ====================
|
||||
|
||||
290
backend/app/api/v1/endpoints/installation_phases.py
Normal file
290
backend/app/api/v1/endpoints/installation_phases.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
系統階段轉換 API
|
||||
處理三階段狀態轉換:Initialization → Operational ↔ Transition
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
|
||||
from app.api import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/complete-initialization")
|
||||
async def complete_initialization(db: Session = Depends(deps.get_db)):
|
||||
"""
|
||||
完成初始化,將系統狀態從 Initialization 轉換到 Operational
|
||||
|
||||
條件檢查:
|
||||
1. 必須已完成 Redis, Database, Keycloak 設定
|
||||
2. 必須已建立公司資訊
|
||||
3. 必須已建立管理員帳號
|
||||
|
||||
執行操作:
|
||||
1. 更新 installation_system_status
|
||||
2. 將 current_phase 從 'initialization' 改為 'operational'
|
||||
3. 設定 initialization_completed = True
|
||||
4. 記錄 initialized_at, operational_since
|
||||
"""
|
||||
from app.models.installation import InstallationSystemStatus, InstallationEnvironmentConfig, InstallationTenantInfo
|
||||
|
||||
try:
|
||||
# 1. 取得系統狀態
|
||||
system_status = db.query(InstallationSystemStatus).first()
|
||||
|
||||
if not system_status:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="系統狀態記錄不存在"
|
||||
)
|
||||
|
||||
if system_status.current_phase != "initialization":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"系統當前階段為 {system_status.current_phase},無法執行此操作"
|
||||
)
|
||||
|
||||
# 2. 檢查必要配置是否完成
|
||||
required_categories = ["redis", "database", "keycloak"]
|
||||
configured_categories = db.query(InstallationEnvironmentConfig.config_category).filter(
|
||||
InstallationEnvironmentConfig.is_configured == True
|
||||
).distinct().all()
|
||||
configured_categories = [cat[0] for cat in configured_categories]
|
||||
|
||||
missing = [cat for cat in required_categories if cat not in configured_categories]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"尚未完成環境配置: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
# 3. 檢查是否已建立租戶資訊
|
||||
tenant_info = db.query(InstallationTenantInfo).first()
|
||||
if not tenant_info or not tenant_info.is_completed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="尚未完成公司資訊設定"
|
||||
)
|
||||
|
||||
# 4. 更新系統狀態
|
||||
now = datetime.now()
|
||||
system_status.previous_phase = system_status.current_phase
|
||||
system_status.current_phase = "operational"
|
||||
system_status.phase_changed_at = now
|
||||
system_status.phase_change_reason = "初始化完成,進入營運階段"
|
||||
system_status.initialization_completed = True
|
||||
system_status.initialized_at = now
|
||||
system_status.operational_since = now
|
||||
system_status.updated_at = now
|
||||
|
||||
db.commit()
|
||||
db.refresh(system_status)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "系統初始化完成,已進入營運階段",
|
||||
"current_phase": system_status.current_phase,
|
||||
"operational_since": system_status.operational_since.isoformat(),
|
||||
"next_action": "redirect_to_login"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"完成初始化失敗: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/switch-phase")
|
||||
async def switch_phase(
|
||||
target_phase: str,
|
||||
reason: str = None,
|
||||
db: Session = Depends(deps.get_db)
|
||||
):
|
||||
"""
|
||||
切換系統階段(Operational ↔ Transition)
|
||||
|
||||
Args:
|
||||
target_phase: operational | transition
|
||||
reason: 切換原因
|
||||
|
||||
Rules:
|
||||
- operational → transition: 需進行系統遷移時
|
||||
- transition → operational: 完成遷移並通過一致性檢查後
|
||||
"""
|
||||
from app.models.installation import InstallationSystemStatus
|
||||
|
||||
if target_phase not in ["operational", "transition"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="target_phase 必須為 'operational' 或 'transition'"
|
||||
)
|
||||
|
||||
try:
|
||||
system_status = db.query(InstallationSystemStatus).first()
|
||||
|
||||
if not system_status:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="系統狀態記錄不存在"
|
||||
)
|
||||
|
||||
# 不允許從 initialization 直接切換
|
||||
if system_status.current_phase == "initialization":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="初始化階段無法直接切換,請先完成初始化"
|
||||
)
|
||||
|
||||
# 檢查是否已是目標階段
|
||||
if system_status.current_phase == target_phase:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"系統已處於 {target_phase} 階段",
|
||||
"current_phase": target_phase
|
||||
}
|
||||
|
||||
# 特殊檢查:從 transition 回到 operational 必須通過一致性檢查
|
||||
if system_status.current_phase == "transition" and target_phase == "operational":
|
||||
if not system_status.env_db_consistent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="環境與資料庫不一致,無法切換回營運階段"
|
||||
)
|
||||
|
||||
# 執行切換
|
||||
now = datetime.now()
|
||||
system_status.previous_phase = system_status.current_phase
|
||||
system_status.current_phase = target_phase
|
||||
system_status.phase_changed_at = now
|
||||
system_status.phase_change_reason = reason or f"手動切換至 {target_phase} 階段"
|
||||
|
||||
# 根據目標階段更新相關欄位
|
||||
if target_phase == "transition":
|
||||
system_status.transition_started_at = now
|
||||
system_status.env_db_consistent = None # 重置一致性狀態
|
||||
system_status.inconsistencies = None
|
||||
elif target_phase == "operational":
|
||||
system_status.operational_since = now
|
||||
|
||||
system_status.updated_at = now
|
||||
|
||||
db.commit()
|
||||
db.refresh(system_status)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已切換至 {target_phase} 階段",
|
||||
"current_phase": system_status.current_phase,
|
||||
"previous_phase": system_status.previous_phase,
|
||||
"phase_changed_at": system_status.phase_changed_at.isoformat()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"階段切換失敗: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check-consistency")
|
||||
async def check_env_db_consistency(db: Session = Depends(deps.get_db)):
|
||||
"""
|
||||
檢查 .env 檔案與資料庫配置的一致性(Transition 階段使用)
|
||||
|
||||
比對項目:
|
||||
- REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB
|
||||
- DATABASE_URL, DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER
|
||||
- KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_ADMIN_USERNAME
|
||||
|
||||
Returns:
|
||||
is_consistent: True/False
|
||||
inconsistencies: 不一致項目列表
|
||||
"""
|
||||
from app.models.installation import InstallationSystemStatus, InstallationEnvironmentConfig
|
||||
|
||||
try:
|
||||
system_status = db.query(InstallationSystemStatus).first()
|
||||
|
||||
if not system_status:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="系統狀態記錄不存在"
|
||||
)
|
||||
|
||||
# 從資料庫讀取配置
|
||||
db_configs = {}
|
||||
config_records = db.query(InstallationEnvironmentConfig).filter(
|
||||
InstallationEnvironmentConfig.is_configured == True
|
||||
).all()
|
||||
|
||||
for record in config_records:
|
||||
db_configs[record.config_key] = record.config_value
|
||||
|
||||
# 從 .env 讀取配置
|
||||
env_configs = {}
|
||||
env_file_path = os.path.join(os.getcwd(), '.env')
|
||||
|
||||
if os.path.exists(env_file_path):
|
||||
with open(env_file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
env_configs[key.strip()] = value.strip()
|
||||
|
||||
# 比對差異(排除敏感資訊的顯示)
|
||||
inconsistencies = []
|
||||
checked_keys = set(db_configs.keys()) | set(env_configs.keys())
|
||||
|
||||
for key in checked_keys:
|
||||
db_value = db_configs.get(key, "[NOT SET]")
|
||||
env_value = env_configs.get(key, "[NOT SET]")
|
||||
|
||||
if db_value != env_value:
|
||||
# 檢查是否為敏感資訊
|
||||
is_sensitive = any(sensitive in key.lower() for sensitive in ['password', 'secret', 'key'])
|
||||
|
||||
inconsistencies.append({
|
||||
"config_key": key,
|
||||
"db_value": "[HIDDEN]" if is_sensitive else db_value,
|
||||
"env_value": "[HIDDEN]" if is_sensitive else env_value,
|
||||
"is_sensitive": is_sensitive
|
||||
})
|
||||
|
||||
is_consistent = len(inconsistencies) == 0
|
||||
|
||||
# 更新系統狀態
|
||||
now = datetime.now()
|
||||
system_status.env_db_consistent = is_consistent
|
||||
system_status.consistency_checked_at = now
|
||||
system_status.inconsistencies = json.dumps(inconsistencies, ensure_ascii=False) if inconsistencies else None
|
||||
system_status.updated_at = now
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"is_consistent": is_consistent,
|
||||
"checked_at": now.isoformat(),
|
||||
"total_configs": len(checked_keys),
|
||||
"inconsistency_count": len(inconsistencies),
|
||||
"inconsistencies": inconsistencies,
|
||||
"message": "環境配置一致" if is_consistent else f"發現 {len(inconsistencies)} 項不一致"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"一致性檢查失敗: {str(e)}"
|
||||
)
|
||||
364
backend/app/api/v1/identities.py
Normal file
364
backend/app/api/v1/identities.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
員工身份管理 API
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.models.employee_identity import EmployeeIdentity
|
||||
from app.models.business_unit import BusinessUnit
|
||||
from app.models.department import Department
|
||||
from app.schemas.employee_identity import (
|
||||
EmployeeIdentityCreate,
|
||||
EmployeeIdentityUpdate,
|
||||
EmployeeIdentityResponse,
|
||||
EmployeeIdentityListItem,
|
||||
)
|
||||
from app.schemas.response import MessageResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[EmployeeIdentityListItem])
|
||||
def get_identities(
|
||||
db: Session = Depends(get_db),
|
||||
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
|
||||
business_unit_id: Optional[int] = Query(None, description="事業部 ID 篩選"),
|
||||
department_id: Optional[int] = Query(None, description="部門 ID 篩選"),
|
||||
is_active: Optional[bool] = Query(None, description="是否活躍"),
|
||||
):
|
||||
"""
|
||||
獲取員工身份列表
|
||||
|
||||
支援多種篩選條件
|
||||
"""
|
||||
query = db.query(EmployeeIdentity)
|
||||
|
||||
if employee_id:
|
||||
query = query.filter(EmployeeIdentity.employee_id == employee_id)
|
||||
|
||||
if business_unit_id:
|
||||
query = query.filter(EmployeeIdentity.business_unit_id == business_unit_id)
|
||||
|
||||
if department_id:
|
||||
query = query.filter(EmployeeIdentity.department_id == department_id)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(EmployeeIdentity.is_active == is_active)
|
||||
|
||||
identities = query.order_by(
|
||||
EmployeeIdentity.employee_id,
|
||||
EmployeeIdentity.is_primary.desc()
|
||||
).all()
|
||||
|
||||
return [EmployeeIdentityListItem.model_validate(identity) for identity in identities]
|
||||
|
||||
|
||||
@router.get("/{identity_id}", response_model=EmployeeIdentityResponse)
|
||||
def get_identity(
|
||||
identity_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取員工身份詳情
|
||||
"""
|
||||
identity = db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.id == identity_id
|
||||
).first()
|
||||
|
||||
if not identity:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Identity with id {identity_id} not found"
|
||||
)
|
||||
|
||||
response = EmployeeIdentityResponse.model_validate(identity)
|
||||
response.employee_name = identity.employee.legal_name
|
||||
response.business_unit_name = identity.business_unit.name
|
||||
response.email_domain = identity.business_unit.email_domain
|
||||
if identity.department:
|
||||
response.department_name = identity.department.name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/", response_model=EmployeeIdentityResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_identity(
|
||||
identity_data: EmployeeIdentityCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
創建員工身份
|
||||
|
||||
自動生成 SSO 帳號:
|
||||
- 格式: {username_base}@{email_domain}
|
||||
- 需要生成 Keycloak UUID (TODO)
|
||||
|
||||
檢查:
|
||||
- 員工是否存在
|
||||
- 事業部是否存在
|
||||
- 部門是否存在 (如果指定)
|
||||
- 同一員工在同一事業部只能有一個身份
|
||||
"""
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == identity_data.employee_id
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {identity_data.employee_id} not found"
|
||||
)
|
||||
|
||||
# 檢查事業部是否存在
|
||||
business_unit = db.query(BusinessUnit).filter(
|
||||
BusinessUnit.id == identity_data.business_unit_id
|
||||
).first()
|
||||
|
||||
if not business_unit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Business unit with id {identity_data.business_unit_id} not found"
|
||||
)
|
||||
|
||||
# 檢查部門是否存在 (如果指定)
|
||||
if identity_data.department_id:
|
||||
department = db.query(Department).filter(
|
||||
Department.id == identity_data.department_id,
|
||||
Department.business_unit_id == identity_data.business_unit_id
|
||||
).first()
|
||||
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department with id {identity_data.department_id} not found in this business unit"
|
||||
)
|
||||
|
||||
# 檢查同一員工在同一事業部是否已有身份
|
||||
existing = db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.employee_id == identity_data.employee_id,
|
||||
EmployeeIdentity.business_unit_id == identity_data.business_unit_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Employee already has an identity in this business unit"
|
||||
)
|
||||
|
||||
# 生成 SSO 帳號
|
||||
username = f"{employee.username_base}@{business_unit.email_domain}"
|
||||
|
||||
# 檢查 SSO 帳號是否已存在
|
||||
existing_username = db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.username == username
|
||||
).first()
|
||||
|
||||
if existing_username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Username '{username}' already exists"
|
||||
)
|
||||
|
||||
# TODO: 從 Keycloak 創建帳號並獲取 UUID
|
||||
keycloak_id = f"temp-uuid-{employee.id}-{business_unit.id}"
|
||||
|
||||
# 創建身份
|
||||
identity = EmployeeIdentity(
|
||||
employee_id=identity_data.employee_id,
|
||||
username=username,
|
||||
keycloak_id=keycloak_id,
|
||||
business_unit_id=identity_data.business_unit_id,
|
||||
department_id=identity_data.department_id,
|
||||
job_title=identity_data.job_title,
|
||||
job_level=identity_data.job_level,
|
||||
is_primary=identity_data.is_primary,
|
||||
email_quota_mb=identity_data.email_quota_mb,
|
||||
started_at=identity_data.started_at,
|
||||
)
|
||||
|
||||
# 如果設為主要身份,取消其他主要身份
|
||||
if identity_data.is_primary:
|
||||
db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.employee_id == identity_data.employee_id
|
||||
).update({"is_primary": False})
|
||||
|
||||
db.add(identity)
|
||||
db.commit()
|
||||
db.refresh(identity)
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
# TODO: 創建 Keycloak 帳號
|
||||
# TODO: 創建郵件帳號
|
||||
|
||||
response = EmployeeIdentityResponse.model_validate(identity)
|
||||
response.employee_name = employee.legal_name
|
||||
response.business_unit_name = business_unit.name
|
||||
response.email_domain = business_unit.email_domain
|
||||
if identity.department:
|
||||
response.department_name = identity.department.name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{identity_id}", response_model=EmployeeIdentityResponse)
|
||||
def update_identity(
|
||||
identity_id: int,
|
||||
identity_data: EmployeeIdentityUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新員工身份
|
||||
"""
|
||||
identity = db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.id == identity_id
|
||||
).first()
|
||||
|
||||
if not identity:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Identity with id {identity_id} not found"
|
||||
)
|
||||
|
||||
# 檢查部門是否屬於同一事業部 (如果更新部門)
|
||||
if identity_data.department_id:
|
||||
department = db.query(Department).filter(
|
||||
Department.id == identity_data.department_id,
|
||||
Department.business_unit_id == identity.business_unit_id
|
||||
).first()
|
||||
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Department does not belong to this business unit"
|
||||
)
|
||||
|
||||
# 更新欄位
|
||||
update_data = identity_data.model_dump(exclude_unset=True)
|
||||
|
||||
# 如果設為主要身份,取消其他主要身份
|
||||
if update_data.get("is_primary"):
|
||||
db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.employee_id == identity.employee_id,
|
||||
EmployeeIdentity.id != identity_id
|
||||
).update({"is_primary": False})
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(identity, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(identity)
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
# TODO: 更新 NAS 配額 (如果職級變更)
|
||||
|
||||
response = EmployeeIdentityResponse.model_validate(identity)
|
||||
response.employee_name = identity.employee.legal_name
|
||||
response.business_unit_name = identity.business_unit.name
|
||||
response.email_domain = identity.business_unit.email_domain
|
||||
if identity.department:
|
||||
response.department_name = identity.department.name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{identity_id}", response_model=MessageResponse)
|
||||
def delete_identity(
|
||||
identity_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
刪除員工身份
|
||||
|
||||
注意:
|
||||
- 如果是員工的最後一個身份,無法刪除
|
||||
- 刪除後會停用對應的 Keycloak 和郵件帳號
|
||||
"""
|
||||
identity = db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.id == identity_id
|
||||
).first()
|
||||
|
||||
if not identity:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Identity with id {identity_id} not found"
|
||||
)
|
||||
|
||||
# 檢查是否為員工的最後一個身份
|
||||
total_identities = db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.employee_id == identity.employee_id
|
||||
).count()
|
||||
|
||||
if total_identities == 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete employee's last identity. Please terminate the employee instead."
|
||||
)
|
||||
|
||||
# 軟刪除 (停用)
|
||||
identity.is_active = False
|
||||
identity.ended_at = db.func.current_date()
|
||||
|
||||
db.commit()
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
# TODO: 停用 Keycloak 帳號
|
||||
# TODO: 停用郵件帳號
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Identity '{identity.username}' has been deactivated"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{identity_id}/set-primary", response_model=EmployeeIdentityResponse)
|
||||
def set_primary_identity(
|
||||
identity_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
設定為主要身份
|
||||
|
||||
將指定的身份設為員工的主要身份,並取消其他身份的主要狀態
|
||||
"""
|
||||
identity = db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.id == identity_id
|
||||
).first()
|
||||
|
||||
if not identity:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Identity with id {identity_id} not found"
|
||||
)
|
||||
|
||||
# 檢查身份是否已停用
|
||||
if not identity.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot set inactive identity as primary"
|
||||
)
|
||||
|
||||
# 取消同一員工的其他主要身份
|
||||
db.query(EmployeeIdentity).filter(
|
||||
EmployeeIdentity.employee_id == identity.employee_id,
|
||||
EmployeeIdentity.id != identity_id
|
||||
).update({"is_primary": False})
|
||||
|
||||
# 設為主要身份
|
||||
identity.is_primary = True
|
||||
|
||||
db.commit()
|
||||
db.refresh(identity)
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
|
||||
response = EmployeeIdentityResponse.model_validate(identity)
|
||||
response.employee_name = identity.employee.legal_name
|
||||
response.business_unit_name = identity.business_unit.name
|
||||
response.email_domain = identity.business_unit.email_domain
|
||||
if identity.department:
|
||||
response.department_name = identity.department.name
|
||||
|
||||
return response
|
||||
176
backend/app/api/v1/lifecycle.py
Normal file
176
backend/app/api/v1/lifecycle.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
員工生命週期管理 API
|
||||
觸發員工到職、離職自動化流程
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.services.employee_lifecycle import get_employee_lifecycle_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/employees/{employee_id}/onboard")
|
||||
async def onboard_employee(
|
||||
employee_id: int,
|
||||
create_keycloak: bool = True,
|
||||
create_email: bool = True,
|
||||
create_drive: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
觸發員工到職流程
|
||||
|
||||
自動執行:
|
||||
- 建立 Keycloak SSO 帳號
|
||||
- 建立主要郵件帳號
|
||||
- 建立雲端硬碟帳號 (Drive Service,非致命)
|
||||
|
||||
參數:
|
||||
- create_keycloak: 是否建立 Keycloak 帳號 (預設: True)
|
||||
- create_email: 是否建立郵件帳號 (預設: True)
|
||||
- create_drive: 是否建立雲端硬碟帳號 (預設: True,Drive Service 未上線時自動跳過)
|
||||
"""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"員工 ID {employee_id} 不存在"
|
||||
)
|
||||
|
||||
if employee.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"只能為在職員工執行到職流程 (目前狀態: {employee.status})"
|
||||
)
|
||||
|
||||
lifecycle_service = get_employee_lifecycle_service()
|
||||
results = await lifecycle_service.onboard_employee(
|
||||
db=db,
|
||||
employee=employee,
|
||||
create_keycloak=create_keycloak,
|
||||
create_email=create_email,
|
||||
create_drive=create_drive,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "員工到職流程已觸發",
|
||||
"employee": {
|
||||
"id": employee.id,
|
||||
"employee_id": employee.employee_id,
|
||||
"legal_name": employee.legal_name,
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/employees/{employee_id}/offboard")
|
||||
async def offboard_employee(
|
||||
employee_id: int,
|
||||
disable_keycloak: bool = True,
|
||||
email_handling: str = "forward", # "forward" 或 "disable"
|
||||
disable_drive: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
觸發員工離職流程
|
||||
|
||||
自動執行:
|
||||
- 停用 Keycloak SSO 帳號
|
||||
- 處理郵件帳號 (轉發或停用)
|
||||
- 停用雲端硬碟帳號 (Drive Service,非致命)
|
||||
|
||||
參數:
|
||||
- disable_keycloak: 是否停用 Keycloak 帳號 (預設: True)
|
||||
- email_handling: 郵件處理方式 "forward" 或 "disable" (預設: forward)
|
||||
- disable_drive: 是否停用雲端硬碟帳號 (預設: True,Drive Service 未上線時自動跳過)
|
||||
"""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"員工 ID {employee_id} 不存在"
|
||||
)
|
||||
|
||||
if email_handling not in ["forward", "disable"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="email_handling 必須是 'forward' 或 'disable'"
|
||||
)
|
||||
|
||||
lifecycle_service = get_employee_lifecycle_service()
|
||||
results = await lifecycle_service.offboard_employee(
|
||||
db=db,
|
||||
employee=employee,
|
||||
disable_keycloak=disable_keycloak,
|
||||
handle_email=email_handling,
|
||||
disable_drive=disable_drive,
|
||||
)
|
||||
|
||||
# 將員工狀態設為離職
|
||||
employee.status = "terminated"
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "員工離職流程已觸發",
|
||||
"employee": {
|
||||
"id": employee.id,
|
||||
"employee_id": employee.employee_id,
|
||||
"legal_name": employee.legal_name,
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}/lifecycle-status")
|
||||
async def get_lifecycle_status(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
查詢員工的生命週期狀態
|
||||
|
||||
回傳:
|
||||
- Keycloak 帳號狀態
|
||||
- 郵件帳號狀態
|
||||
- 雲端硬碟帳號狀態 (Drive Service)
|
||||
"""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"員工 ID {employee_id} 不存在"
|
||||
)
|
||||
|
||||
# TODO: 實際查詢各系統的帳號狀態
|
||||
|
||||
return {
|
||||
"employee": {
|
||||
"id": employee.id,
|
||||
"employee_id": employee.employee_id,
|
||||
"legal_name": employee.legal_name,
|
||||
"status": employee.status,
|
||||
},
|
||||
"systems": {
|
||||
"keycloak": {
|
||||
"has_account": False,
|
||||
"is_enabled": False,
|
||||
"message": "尚未整合 Keycloak API",
|
||||
},
|
||||
"email": {
|
||||
"has_account": False,
|
||||
"email_address": f"{employee.username_base}@porscheworld.tw",
|
||||
"message": "尚未整合 MailPlus API",
|
||||
},
|
||||
"drive": {
|
||||
"has_account": False,
|
||||
"drive_url": "https://drive.ease.taipei",
|
||||
"message": "Drive Service 尚未上線",
|
||||
},
|
||||
},
|
||||
}
|
||||
262
backend/app/api/v1/network_drives.py
Normal file
262
backend/app/api/v1/network_drives.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
網路硬碟管理 API
|
||||
"""
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.models.network_drive import NetworkDrive
|
||||
from app.schemas.network_drive import (
|
||||
NetworkDriveCreate,
|
||||
NetworkDriveUpdate,
|
||||
NetworkDriveResponse,
|
||||
NetworkDriveListItem,
|
||||
NetworkDriveQuotaUpdate,
|
||||
)
|
||||
from app.schemas.response import MessageResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[NetworkDriveListItem])
|
||||
def get_network_drives(
|
||||
db: Session = Depends(get_db),
|
||||
is_active: bool = True,
|
||||
):
|
||||
"""
|
||||
獲取網路硬碟列表
|
||||
"""
|
||||
query = db.query(NetworkDrive)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(NetworkDrive.is_active == is_active)
|
||||
|
||||
network_drives = query.order_by(NetworkDrive.drive_name).all()
|
||||
|
||||
return [NetworkDriveListItem.model_validate(nd) for nd in network_drives]
|
||||
|
||||
|
||||
@router.get("/{network_drive_id}", response_model=NetworkDriveResponse)
|
||||
def get_network_drive(
|
||||
network_drive_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取網路硬碟詳情
|
||||
"""
|
||||
network_drive = db.query(NetworkDrive).filter(
|
||||
NetworkDrive.id == network_drive_id
|
||||
).first()
|
||||
|
||||
if not network_drive:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Network drive with id {network_drive_id} not found"
|
||||
)
|
||||
|
||||
response = NetworkDriveResponse.model_validate(network_drive)
|
||||
response.employee_name = network_drive.employee.legal_name
|
||||
response.employee_username = network_drive.employee.username_base
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/", response_model=NetworkDriveResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_network_drive(
|
||||
network_drive_data: NetworkDriveCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
創建網路硬碟
|
||||
|
||||
檢查:
|
||||
- 員工是否存在
|
||||
- 員工是否已有 NAS 帳號
|
||||
- drive_name 唯一性
|
||||
"""
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == network_drive_data.employee_id
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {network_drive_data.employee_id} not found"
|
||||
)
|
||||
|
||||
# 檢查員工是否已有 NAS 帳號
|
||||
existing = db.query(NetworkDrive).filter(
|
||||
NetworkDrive.employee_id == network_drive_data.employee_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Employee already has a network drive"
|
||||
)
|
||||
|
||||
# 檢查 drive_name 是否已存在
|
||||
existing_name = db.query(NetworkDrive).filter(
|
||||
NetworkDrive.drive_name == network_drive_data.drive_name
|
||||
).first()
|
||||
|
||||
if existing_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Drive name '{network_drive_data.drive_name}' already exists"
|
||||
)
|
||||
|
||||
# 創建網路硬碟
|
||||
network_drive = NetworkDrive(**network_drive_data.model_dump())
|
||||
|
||||
db.add(network_drive)
|
||||
db.commit()
|
||||
db.refresh(network_drive)
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
# TODO: 在 NAS 上創建實際帳號
|
||||
|
||||
response = NetworkDriveResponse.model_validate(network_drive)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_username = employee.username_base
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{network_drive_id}", response_model=NetworkDriveResponse)
|
||||
def update_network_drive(
|
||||
network_drive_id: int,
|
||||
network_drive_data: NetworkDriveUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新網路硬碟
|
||||
|
||||
可更新: quota_gb, webdav_url, smb_url, is_active
|
||||
"""
|
||||
network_drive = db.query(NetworkDrive).filter(
|
||||
NetworkDrive.id == network_drive_id
|
||||
).first()
|
||||
|
||||
if not network_drive:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Network drive with id {network_drive_id} not found"
|
||||
)
|
||||
|
||||
# 更新欄位
|
||||
update_data = network_drive_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(network_drive, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(network_drive)
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
# TODO: 更新 NAS 配額
|
||||
|
||||
response = NetworkDriveResponse.model_validate(network_drive)
|
||||
response.employee_name = network_drive.employee.legal_name
|
||||
response.employee_username = network_drive.employee.username_base
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.patch("/{network_drive_id}/quota", response_model=NetworkDriveResponse)
|
||||
def update_network_drive_quota(
|
||||
network_drive_id: int,
|
||||
quota_data: NetworkDriveQuotaUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新網路硬碟配額
|
||||
|
||||
專用端點,僅更新配額
|
||||
"""
|
||||
network_drive = db.query(NetworkDrive).filter(
|
||||
NetworkDrive.id == network_drive_id
|
||||
).first()
|
||||
|
||||
if not network_drive:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Network drive with id {network_drive_id} not found"
|
||||
)
|
||||
|
||||
network_drive.quota_gb = quota_data.quota_gb
|
||||
|
||||
db.commit()
|
||||
db.refresh(network_drive)
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
# TODO: 更新 NAS 配額
|
||||
|
||||
response = NetworkDriveResponse.model_validate(network_drive)
|
||||
response.employee_name = network_drive.employee.legal_name
|
||||
response.employee_username = network_drive.employee.username_base
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{network_drive_id}", response_model=MessageResponse)
|
||||
def delete_network_drive(
|
||||
network_drive_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
停用網路硬碟
|
||||
|
||||
注意: 這是軟刪除,只將 is_active 設為 False
|
||||
"""
|
||||
network_drive = db.query(NetworkDrive).filter(
|
||||
NetworkDrive.id == network_drive_id
|
||||
).first()
|
||||
|
||||
if not network_drive:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Network drive with id {network_drive_id} not found"
|
||||
)
|
||||
|
||||
network_drive.is_active = False
|
||||
|
||||
db.commit()
|
||||
|
||||
# TODO: 創建審計日誌
|
||||
# TODO: 停用 NAS 帳號 (但保留資料)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Network drive '{network_drive.drive_name}' has been deactivated"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-employee/{employee_id}", response_model=NetworkDriveResponse)
|
||||
def get_network_drive_by_employee(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
根據員工 ID 獲取網路硬碟
|
||||
"""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found"
|
||||
)
|
||||
|
||||
if not employee.network_drive:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee does not have a network drive"
|
||||
)
|
||||
|
||||
response = NetworkDriveResponse.model_validate(employee.network_drive)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_username = employee.username_base
|
||||
|
||||
return response
|
||||
542
backend/app/api/v1/permissions.py
Normal file
542
backend/app/api/v1/permissions.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""
|
||||
系統權限管理 API
|
||||
管理員工對各系統 (Gitea, Portainer, Traefik, Keycloak) 的存取權限
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.models.permission import Permission
|
||||
from app.schemas.permission import (
|
||||
PermissionCreate,
|
||||
PermissionUpdate,
|
||||
PermissionResponse,
|
||||
PermissionListItem,
|
||||
PermissionBatchCreate,
|
||||
PermissionFilter,
|
||||
VALID_SYSTEMS,
|
||||
VALID_ACCESS_LEVELS,
|
||||
)
|
||||
from app.schemas.base import PaginationParams, PaginatedResponse
|
||||
from app.schemas.response import SuccessResponse, MessageResponse
|
||||
from app.api.deps import get_pagination_params
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_tenant_id() -> int:
|
||||
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token 取得)"""
|
||||
return 1
|
||||
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse)
|
||||
def get_permissions(
|
||||
db: Session = Depends(get_db),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
filter_params: PermissionFilter = Depends(),
|
||||
):
|
||||
"""
|
||||
獲取權限列表
|
||||
|
||||
支援:
|
||||
- 分頁
|
||||
- 員工篩選
|
||||
- 系統名稱篩選
|
||||
- 存取層級篩選
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
query = db.query(Permission).filter(Permission.tenant_id == tenant_id)
|
||||
|
||||
# 員工篩選
|
||||
if filter_params.employee_id:
|
||||
query = query.filter(Permission.employee_id == filter_params.employee_id)
|
||||
|
||||
# 系統名稱篩選
|
||||
if filter_params.system_name:
|
||||
query = query.filter(Permission.system_name == filter_params.system_name)
|
||||
|
||||
# 存取層級篩選
|
||||
if filter_params.access_level:
|
||||
query = query.filter(Permission.access_level == filter_params.access_level)
|
||||
|
||||
# 總數
|
||||
total = query.count()
|
||||
|
||||
# 分頁
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
permissions = (
|
||||
query.options(joinedload(Permission.employee))
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 計算總頁數
|
||||
total_pages = (total + pagination.page_size - 1) // pagination.page_size
|
||||
|
||||
# 組裝回應資料
|
||||
items = []
|
||||
for perm in permissions:
|
||||
item = PermissionListItem.model_validate(perm)
|
||||
item.employee_name = perm.employee.legal_name if perm.employee else None
|
||||
item.employee_number = perm.employee.employee_id if perm.employee else None
|
||||
items.append(item)
|
||||
|
||||
return PaginatedResponse(
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
total_pages=total_pages,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/systems", response_model=dict)
|
||||
def get_available_systems_route():
|
||||
"""
|
||||
取得所有可授權的系統列表 (必須在 /{permission_id} 之前定義)
|
||||
"""
|
||||
return {
|
||||
"systems": VALID_SYSTEMS,
|
||||
"access_levels": VALID_ACCESS_LEVELS,
|
||||
"system_descriptions": {
|
||||
"gitea": "Git 程式碼託管系統",
|
||||
"portainer": "Docker 容器管理系統",
|
||||
"traefik": "反向代理與路由系統",
|
||||
"keycloak": "SSO 身份認證系統",
|
||||
},
|
||||
"access_level_descriptions": {
|
||||
"admin": "完整管理權限",
|
||||
"user": "一般使用者權限",
|
||||
"readonly": "唯讀權限",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{permission_id}", response_model=PermissionResponse)
|
||||
def get_permission(
|
||||
permission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取權限詳情
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
permission = (
|
||||
db.query(Permission)
|
||||
.options(
|
||||
joinedload(Permission.employee),
|
||||
joinedload(Permission.granter),
|
||||
)
|
||||
.filter(
|
||||
Permission.id == permission_id,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Permission with id {permission_id} not found",
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = PermissionResponse.model_validate(permission)
|
||||
response.employee_name = permission.employee.legal_name if permission.employee else None
|
||||
response.employee_number = permission.employee.employee_id if permission.employee else None
|
||||
response.granted_by_name = permission.granter.legal_name if permission.granter else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/", response_model=PermissionResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_permission(
|
||||
permission_data: PermissionCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
創建權限
|
||||
|
||||
注意:
|
||||
- 員工必須存在
|
||||
- 每個員工對每個系統只能有一個權限 (unique constraint)
|
||||
- 系統名稱: gitea, portainer, traefik, keycloak
|
||||
- 存取層級: admin, user, readonly
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == permission_data.employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {permission_data.employee_id} not found",
|
||||
)
|
||||
|
||||
# 檢查是否已有該系統的權限
|
||||
existing = db.query(Permission).filter(
|
||||
and_(
|
||||
Permission.employee_id == permission_data.employee_id,
|
||||
Permission.system_name == permission_data.system_name,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Permission for system '{permission_data.system_name}' already exists for this employee",
|
||||
)
|
||||
|
||||
# 檢查授予人是否存在 (如果有提供)
|
||||
if permission_data.granted_by:
|
||||
granter = db.query(Employee).filter(
|
||||
Employee.id == permission_data.granted_by,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not granter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Granter with id {permission_data.granted_by} not found",
|
||||
)
|
||||
|
||||
# 創建權限
|
||||
permission = Permission(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=permission_data.employee_id,
|
||||
system_name=permission_data.system_name,
|
||||
access_level=permission_data.access_level,
|
||||
granted_by=permission_data.granted_by,
|
||||
)
|
||||
|
||||
db.add(permission)
|
||||
db.commit()
|
||||
db.refresh(permission)
|
||||
|
||||
# 重新載入關聯資料
|
||||
db.refresh(permission)
|
||||
permission = (
|
||||
db.query(Permission)
|
||||
.options(
|
||||
joinedload(Permission.employee),
|
||||
joinedload(Permission.granter),
|
||||
)
|
||||
.filter(Permission.id == permission.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="create_permission",
|
||||
resource_type="permission",
|
||||
resource_id=permission.id,
|
||||
details={
|
||||
"employee_id": permission.employee_id,
|
||||
"system_name": permission.system_name,
|
||||
"access_level": permission.access_level,
|
||||
"granted_by": permission.granted_by,
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = PermissionResponse.model_validate(permission)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_number = employee.employee_id
|
||||
response.granted_by_name = permission.granter.legal_name if permission.granter else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{permission_id}", response_model=PermissionResponse)
|
||||
def update_permission(
|
||||
permission_id: int,
|
||||
permission_data: PermissionUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新權限
|
||||
|
||||
可更新:
|
||||
- 存取層級 (admin, user, readonly)
|
||||
- 授予人
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
permission = (
|
||||
db.query(Permission)
|
||||
.options(
|
||||
joinedload(Permission.employee),
|
||||
joinedload(Permission.granter),
|
||||
)
|
||||
.filter(
|
||||
Permission.id == permission_id,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Permission with id {permission_id} not found",
|
||||
)
|
||||
|
||||
# 檢查授予人是否存在 (如果有提供)
|
||||
if permission_data.granted_by:
|
||||
granter = db.query(Employee).filter(
|
||||
Employee.id == permission_data.granted_by,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not granter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Granter with id {permission_data.granted_by} not found",
|
||||
)
|
||||
|
||||
# 記錄變更前的值
|
||||
old_access_level = permission.access_level
|
||||
old_granted_by = permission.granted_by
|
||||
|
||||
# 更新欄位
|
||||
permission.access_level = permission_data.access_level
|
||||
if permission_data.granted_by is not None:
|
||||
permission.granted_by = permission_data.granted_by
|
||||
|
||||
db.commit()
|
||||
db.refresh(permission)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="update_permission",
|
||||
resource_type="permission",
|
||||
resource_id=permission.id,
|
||||
details={
|
||||
"employee_id": permission.employee_id,
|
||||
"system_name": permission.system_name,
|
||||
"changes": {
|
||||
"access_level": {"from": old_access_level, "to": permission.access_level},
|
||||
"granted_by": {"from": old_granted_by, "to": permission.granted_by},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = PermissionResponse.model_validate(permission)
|
||||
response.employee_name = permission.employee.legal_name if permission.employee else None
|
||||
response.employee_number = permission.employee.employee_id if permission.employee else None
|
||||
response.granted_by_name = permission.granter.legal_name if permission.granter else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{permission_id}", response_model=MessageResponse)
|
||||
def delete_permission(
|
||||
permission_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
刪除權限
|
||||
|
||||
撤銷員工對某系統的存取權限
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
permission = db.query(Permission).filter(
|
||||
Permission.id == permission_id,
|
||||
Permission.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Permission with id {permission_id} not found",
|
||||
)
|
||||
|
||||
# 記錄審計日誌 (在刪除前)
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="delete_permission",
|
||||
resource_type="permission",
|
||||
resource_id=permission.id,
|
||||
details={
|
||||
"employee_id": permission.employee_id,
|
||||
"system_name": permission.system_name,
|
||||
"access_level": permission.access_level,
|
||||
},
|
||||
)
|
||||
|
||||
# 刪除權限
|
||||
db.delete(permission)
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Permission for system {permission.system_name} has been revoked"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}/permissions", response_model=List[PermissionResponse])
|
||||
def get_employee_permissions(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
取得員工的所有系統權限
|
||||
|
||||
回傳該員工可以存取的所有系統及其權限層級
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found",
|
||||
)
|
||||
|
||||
# 查詢員工的所有權限
|
||||
permissions = (
|
||||
db.query(Permission)
|
||||
.options(joinedload(Permission.granter))
|
||||
.filter(
|
||||
Permission.employee_id == employee_id,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
result = []
|
||||
for perm in permissions:
|
||||
response = PermissionResponse.model_validate(perm)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_number = employee.employee_id
|
||||
response.granted_by_name = perm.granter.legal_name if perm.granter else None
|
||||
result.append(response)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[PermissionResponse], status_code=status.HTTP_201_CREATED)
|
||||
def create_permissions_batch(
|
||||
batch_data: PermissionBatchCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批量創建權限
|
||||
|
||||
一次為一個員工授予多個系統的權限
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == batch_data.employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {batch_data.employee_id} not found",
|
||||
)
|
||||
|
||||
# 檢查授予人是否存在 (如果有提供)
|
||||
granter = None
|
||||
if batch_data.granted_by:
|
||||
granter = db.query(Employee).filter(
|
||||
Employee.id == batch_data.granted_by,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not granter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Granter with id {batch_data.granted_by} not found",
|
||||
)
|
||||
|
||||
# 創建權限列表
|
||||
created_permissions = []
|
||||
for perm_data in batch_data.permissions:
|
||||
# 檢查是否已有該系統的權限
|
||||
existing = db.query(Permission).filter(
|
||||
and_(
|
||||
Permission.employee_id == batch_data.employee_id,
|
||||
Permission.system_name == perm_data.system_name,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# 跳過已存在的權限
|
||||
continue
|
||||
|
||||
# 創建權限
|
||||
permission = Permission(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=batch_data.employee_id,
|
||||
system_name=perm_data.system_name,
|
||||
access_level=perm_data.access_level,
|
||||
granted_by=batch_data.granted_by,
|
||||
)
|
||||
|
||||
db.add(permission)
|
||||
created_permissions.append(permission)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 刷新所有創建的權限
|
||||
for perm in created_permissions:
|
||||
db.refresh(perm)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="create_permissions_batch",
|
||||
resource_type="permission",
|
||||
resource_id=batch_data.employee_id,
|
||||
details={
|
||||
"employee_id": batch_data.employee_id,
|
||||
"granted_by": batch_data.granted_by,
|
||||
"permissions": [
|
||||
{
|
||||
"system_name": perm.system_name,
|
||||
"access_level": perm.access_level,
|
||||
}
|
||||
for perm in created_permissions
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
result = []
|
||||
for perm in created_permissions:
|
||||
response = PermissionResponse.model_validate(perm)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_number = employee.employee_id
|
||||
response.granted_by_name = granter.legal_name if granter else None
|
||||
result.append(response)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
326
backend/app/api/v1/personal_service_settings.py
Normal file
326
backend/app/api/v1/personal_service_settings.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
個人化服務設定 API
|
||||
記錄員工啟用的個人化服務(SSO, Email, Calendar, Drive, Office)
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
|
||||
from app.models.personal_service import PersonalService
|
||||
from app.schemas.response import MessageResponse
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_tenant_id() -> int:
|
||||
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token realm 取得)"""
|
||||
return 1
|
||||
|
||||
|
||||
def get_current_user_id() -> str:
|
||||
"""取得當前使用者 ID (暫時寫死, 未來從 JWT token sub 取得)"""
|
||||
return "system-admin"
|
||||
|
||||
|
||||
@router.get("/users/{keycloak_user_id}/services")
|
||||
def get_user_services(
|
||||
keycloak_user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
取得使用者已啟用的服務列表
|
||||
|
||||
Args:
|
||||
keycloak_user_id: Keycloak User UUID
|
||||
include_inactive: 是否包含已停用的服務
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
query = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id
|
||||
)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(
|
||||
EmpPersonalServiceSetting.is_active == True,
|
||||
EmpPersonalServiceSetting.disabled_at == None
|
||||
)
|
||||
|
||||
settings = query.all()
|
||||
|
||||
result = []
|
||||
for setting in settings:
|
||||
service = setting.service
|
||||
result.append({
|
||||
"id": setting.id,
|
||||
"service_id": setting.service_id,
|
||||
"service_name": service.service_name if service else None,
|
||||
"service_code": service.service_code if service else None,
|
||||
"quota_gb": setting.quota_gb,
|
||||
"quota_mb": setting.quota_mb,
|
||||
"enabled_at": setting.enabled_at,
|
||||
"enabled_by": setting.enabled_by,
|
||||
"disabled_at": setting.disabled_at,
|
||||
"disabled_by": setting.disabled_by,
|
||||
"is_active": setting.is_active,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/users/{keycloak_user_id}/services", status_code=status.HTTP_201_CREATED)
|
||||
def enable_service_for_user(
|
||||
keycloak_user_id: str,
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
為使用者啟用個人化服務
|
||||
|
||||
Body:
|
||||
{
|
||||
"service_id": 4, // 服務 ID (必填)
|
||||
"quota_gb": 20, // 儲存配額 (Drive 服務用)
|
||||
"quota_mb": 5120 // 郵件配額 (Email 服務用)
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
service_id = data.get("service_id")
|
||||
quota_gb = data.get("quota_gb")
|
||||
quota_mb = data.get("quota_mb")
|
||||
|
||||
if not service_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="service_id is required"
|
||||
)
|
||||
|
||||
# 檢查服務是否存在
|
||||
service = db.query(PersonalService).filter(
|
||||
PersonalService.id == service_id,
|
||||
PersonalService.is_active == True
|
||||
).first()
|
||||
|
||||
if not service:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Service with id {service_id} not found or inactive"
|
||||
)
|
||||
|
||||
# 檢查是否已經啟用
|
||||
existing = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.service_id == service_id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Service {service.service_name} already enabled for this user"
|
||||
)
|
||||
|
||||
# 建立服務設定
|
||||
setting = EmpPersonalServiceSetting(
|
||||
tenant_id=tenant_id,
|
||||
tenant_keycloak_user_id=keycloak_user_id,
|
||||
service_id=service_id,
|
||||
quota_gb=quota_gb,
|
||||
quota_mb=quota_mb,
|
||||
enabled_at=datetime.utcnow(),
|
||||
enabled_by=current_user,
|
||||
is_active=True,
|
||||
edit_by=current_user
|
||||
)
|
||||
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
# 審計日誌
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="enable_service",
|
||||
resource_type="emp_personal_service_setting",
|
||||
resource_id=setting.id,
|
||||
details=f"Enabled {service.service_name} for user {keycloak_user_id}",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return {
|
||||
"id": setting.id,
|
||||
"service_id": setting.service_id,
|
||||
"service_name": service.service_name,
|
||||
"enabled_at": setting.enabled_at,
|
||||
"quota_gb": setting.quota_gb,
|
||||
"quota_mb": setting.quota_mb,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/users/{keycloak_user_id}/services/{service_id}")
|
||||
def disable_service_for_user(
|
||||
keycloak_user_id: str,
|
||||
service_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
停用使用者的個人化服務(軟刪除)
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
# 查詢啟用中的服務設定
|
||||
setting = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.service_id == service_id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Service setting not found or already disabled"
|
||||
)
|
||||
|
||||
# 軟刪除
|
||||
setting.is_active = False
|
||||
setting.disabled_at = datetime.utcnow()
|
||||
setting.disabled_by = current_user
|
||||
setting.edit_by = current_user
|
||||
|
||||
db.commit()
|
||||
|
||||
# 審計日誌
|
||||
service = setting.service
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="disable_service",
|
||||
resource_type="emp_personal_service_setting",
|
||||
resource_id=setting.id,
|
||||
details=f"Disabled {service.service_name if service else service_id} for user {keycloak_user_id}",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return MessageResponse(message="Service disabled successfully")
|
||||
|
||||
|
||||
@router.post("/users/{keycloak_user_id}/services/batch-enable", status_code=status.HTTP_201_CREATED)
|
||||
def batch_enable_services(
|
||||
keycloak_user_id: str,
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批次啟用所有個人化服務(員工到職時使用)
|
||||
|
||||
Body:
|
||||
{
|
||||
"storage_quota_gb": 20,
|
||||
"email_quota_mb": 5120
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
storage_quota_gb = data.get("storage_quota_gb", 20)
|
||||
email_quota_mb = data.get("email_quota_mb", 5120)
|
||||
|
||||
# 取得所有啟用的服務
|
||||
all_services = db.query(PersonalService).filter(
|
||||
PersonalService.is_active == True
|
||||
).all()
|
||||
|
||||
enabled_services = []
|
||||
|
||||
for service in all_services:
|
||||
# 檢查是否已啟用
|
||||
existing = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.service_id == service.id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
continue # 已啟用,跳過
|
||||
|
||||
# 根據服務類型設定配額
|
||||
quota_gb = storage_quota_gb if service.service_code == "Drive" else None
|
||||
quota_mb = email_quota_mb if service.service_code == "Email" else None
|
||||
|
||||
setting = EmpPersonalServiceSetting(
|
||||
tenant_id=tenant_id,
|
||||
tenant_keycloak_user_id=keycloak_user_id,
|
||||
service_id=service.id,
|
||||
quota_gb=quota_gb,
|
||||
quota_mb=quota_mb,
|
||||
enabled_at=datetime.utcnow(),
|
||||
enabled_by=current_user,
|
||||
is_active=True,
|
||||
edit_by=current_user
|
||||
)
|
||||
|
||||
db.add(setting)
|
||||
enabled_services.append(service.service_name)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 審計日誌
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="batch_enable_services",
|
||||
resource_type="emp_personal_service_setting",
|
||||
resource_id=None,
|
||||
details=f"Batch enabled {len(enabled_services)} services for user {keycloak_user_id}: {', '.join(enabled_services)}",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled_count": len(enabled_services),
|
||||
"services": enabled_services
|
||||
}
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
def get_all_services(
|
||||
db: Session = Depends(get_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
取得所有可用的個人化服務列表
|
||||
"""
|
||||
query = db.query(PersonalService)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(PersonalService.is_active == True)
|
||||
|
||||
services = query.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"service_name": s.service_name,
|
||||
"service_code": s.service_code,
|
||||
"is_active": s.is_active,
|
||||
}
|
||||
for s in services
|
||||
]
|
||||
389
backend/app/api/v1/roles.py
Normal file
389
backend/app/api/v1/roles.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
角色管理 API (RBAC)
|
||||
- roles: 租戶層級角色 (不綁定部門)
|
||||
- role_rights: 角色對系統功能的 CRUD 權限
|
||||
- user_role_assignments: 使用者角色分配 (直接對人,跨部門有效)
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_tenant_id, get_current_tenant
|
||||
from app.models.role import UserRole, RoleRight, UserRoleAssignment
|
||||
from app.models.system_function_cache import SystemFunctionCache
|
||||
from app.schemas.response import MessageResponse
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ========================
|
||||
# 角色 CRUD
|
||||
# ========================
|
||||
|
||||
@router.get("/")
|
||||
def get_roles(
|
||||
db: Session = Depends(get_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""取得租戶的所有角色"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
query = db.query(Role).filter(Role.tenant_id == tenant_id)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(Role.is_active == True)
|
||||
|
||||
roles = query.order_by(Role.id).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"role_code": r.role_code,
|
||||
"role_name": r.role_name,
|
||||
"description": r.description,
|
||||
"is_active": r.is_active,
|
||||
"rights_count": len(r.rights),
|
||||
}
|
||||
for r in roles
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{role_id}")
|
||||
def get_role(
|
||||
role_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""取得角色詳情(含功能權限)"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
role = db.query(Role).filter(
|
||||
Role.id == role_id,
|
||||
Role.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role with id {role_id} not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": role.id,
|
||||
"role_code": role.role_code,
|
||||
"role_name": role.role_name,
|
||||
"description": role.description,
|
||||
"is_active": role.is_active,
|
||||
"rights": [
|
||||
{
|
||||
"function_id": r.function_id,
|
||||
"function_code": r.function.function_code if r.function else None,
|
||||
"function_name": r.function.function_name if r.function else None,
|
||||
"service_code": r.function.service_code if r.function else None,
|
||||
"can_read": r.can_read,
|
||||
"can_create": r.can_create,
|
||||
"can_update": r.can_update,
|
||||
"can_delete": r.can_delete,
|
||||
}
|
||||
for r in role.rights
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||
def create_role(
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
建立角色
|
||||
|
||||
Body: { "role_code": "WAREHOUSE_MANAGER", "role_name": "倉管角色", "description": "..." }
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
role_code = data.get("role_code", "").upper()
|
||||
role_name = data.get("role_name", "")
|
||||
|
||||
if not role_code or not role_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="role_code and role_name are required"
|
||||
)
|
||||
|
||||
existing = db.query(Role).filter(
|
||||
Role.tenant_id == tenant_id,
|
||||
Role.role_code == role_code,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Role code '{role_code}' already exists"
|
||||
)
|
||||
|
||||
role = Role(
|
||||
tenant_id=tenant_id,
|
||||
role_code=role_code,
|
||||
role_name=role_name,
|
||||
description=data.get("description"),
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
|
||||
audit_service.log_action(
|
||||
request=request, db=db,
|
||||
action="create_role", resource_type="role", resource_id=role.id,
|
||||
details={"role_code": role_code, "role_name": role_name},
|
||||
)
|
||||
|
||||
return {"id": role.id, "role_code": role.role_code, "role_name": role.role_name}
|
||||
|
||||
|
||||
@router.delete("/{role_id}", response_model=MessageResponse)
|
||||
def deactivate_role(
|
||||
role_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""停用角色"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
role = db.query(Role).filter(
|
||||
Role.id == role_id,
|
||||
Role.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role with id {role_id} not found"
|
||||
)
|
||||
|
||||
role.is_active = False
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(message=f"Role '{role.role_name}' has been deactivated")
|
||||
|
||||
|
||||
# ========================
|
||||
# 角色功能權限
|
||||
# ========================
|
||||
|
||||
@router.put("/{role_id}/rights")
|
||||
def set_role_rights(
|
||||
role_id: int,
|
||||
rights: list,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
設定角色的功能權限 (整體替換)
|
||||
|
||||
Body: [
|
||||
{"function_id": 1, "can_read": true, "can_create": false, "can_update": false, "can_delete": false},
|
||||
...
|
||||
]
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
role = db.query(Role).filter(
|
||||
Role.id == role_id,
|
||||
Role.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role with id {role_id} not found"
|
||||
)
|
||||
|
||||
# 刪除舊的權限
|
||||
db.query(RoleRight).filter(RoleRight.role_id == role_id).delete()
|
||||
|
||||
# 新增新的權限
|
||||
for r in rights:
|
||||
function_id = r.get("function_id")
|
||||
fn = db.query(SystemFunctionCache).filter(
|
||||
SystemFunctionCache.id == function_id
|
||||
).first()
|
||||
|
||||
if not fn:
|
||||
continue
|
||||
|
||||
right = RoleRight(
|
||||
role_id=role_id,
|
||||
function_id=function_id,
|
||||
can_read=r.get("can_read", False),
|
||||
can_create=r.get("can_create", False),
|
||||
can_update=r.get("can_update", False),
|
||||
can_delete=r.get("can_delete", False),
|
||||
)
|
||||
db.add(right)
|
||||
|
||||
db.commit()
|
||||
|
||||
audit_service.log_action(
|
||||
request=request, db=db,
|
||||
action="update_role_rights", resource_type="role", resource_id=role_id,
|
||||
details={"rights_count": len(rights)},
|
||||
)
|
||||
|
||||
return {"message": f"Role rights updated", "rights_count": len(rights)}
|
||||
|
||||
|
||||
# ========================
|
||||
# 使用者角色分配
|
||||
# ========================
|
||||
|
||||
@router.get("/user-assignments/")
|
||||
def get_user_role_assignments(
|
||||
db: Session = Depends(get_db),
|
||||
keycloak_user_id: Optional[str] = Query(None, description="Keycloak User UUID"),
|
||||
):
|
||||
"""取得使用者角色分配"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
query = db.query(UserRoleAssignment).filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.is_active == True,
|
||||
)
|
||||
|
||||
if keycloak_user_id:
|
||||
query = query.filter(UserRoleAssignment.keycloak_user_id == keycloak_user_id)
|
||||
|
||||
assignments = query.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"keycloak_user_id": a.keycloak_user_id,
|
||||
"role_id": a.role_id,
|
||||
"role_code": a.role.role_code if a.role else None,
|
||||
"role_name": a.role.role_name if a.role else None,
|
||||
"assigned_at": a.assigned_at,
|
||||
}
|
||||
for a in assignments
|
||||
]
|
||||
|
||||
|
||||
@router.post("/user-assignments/", status_code=status.HTTP_201_CREATED)
|
||||
def assign_role_to_user(
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
分配角色給使用者 (直接對人,跨部門有效)
|
||||
|
||||
Body: { "keycloak_user_id": "uuid", "role_id": 1 }
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
keycloak_user_id = data.get("keycloak_user_id")
|
||||
role_id = data.get("role_id")
|
||||
|
||||
if not keycloak_user_id or not role_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="keycloak_user_id and role_id are required"
|
||||
)
|
||||
|
||||
role = db.query(Role).filter(
|
||||
Role.id == role_id,
|
||||
Role.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role with id {role_id} not found"
|
||||
)
|
||||
|
||||
existing = db.query(UserRoleAssignment).filter(
|
||||
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
|
||||
UserRoleAssignment.role_id == role_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if existing.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User already has this role assigned"
|
||||
)
|
||||
existing.is_active = True
|
||||
db.commit()
|
||||
return {"message": "Role assignment reactivated", "id": existing.id}
|
||||
|
||||
assignment = UserRoleAssignment(
|
||||
tenant_id=tenant_id,
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
role_id=role_id,
|
||||
)
|
||||
db.add(assignment)
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
audit_service.log_action(
|
||||
request=request, db=db,
|
||||
action="assign_role", resource_type="user_role_assignment", resource_id=assignment.id,
|
||||
details={"keycloak_user_id": keycloak_user_id, "role_id": role_id, "role_code": role.role_code},
|
||||
)
|
||||
|
||||
return {"id": assignment.id, "keycloak_user_id": keycloak_user_id, "role_id": role_id}
|
||||
|
||||
|
||||
@router.delete("/user-assignments/{assignment_id}", response_model=MessageResponse)
|
||||
def revoke_role_from_user(
|
||||
assignment_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""撤銷使用者角色"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
assignment = db.query(UserRoleAssignment).filter(
|
||||
UserRoleAssignment.id == assignment_id,
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Assignment with id {assignment_id} not found"
|
||||
)
|
||||
|
||||
assignment.is_active = False
|
||||
db.commit()
|
||||
|
||||
audit_service.log_action(
|
||||
request=request, db=db,
|
||||
action="revoke_role", resource_type="user_role_assignment", resource_id=assignment_id,
|
||||
details={"keycloak_user_id": assignment.keycloak_user_id, "role_id": assignment.role_id},
|
||||
)
|
||||
|
||||
return MessageResponse(message="Role assignment revoked")
|
||||
|
||||
|
||||
# ========================
|
||||
# 系統功能查詢
|
||||
# ========================
|
||||
|
||||
@router.get("/system-functions/")
|
||||
def get_system_functions(
|
||||
db: Session = Depends(get_db),
|
||||
service_code: Optional[str] = Query(None, description="服務代碼篩選: hr/erp/mail/ai"),
|
||||
):
|
||||
"""取得系統功能清單 (從快取表)"""
|
||||
query = db.query(SystemFunctionCache).filter(SystemFunctionCache.is_active == True)
|
||||
|
||||
if service_code:
|
||||
query = query.filter(SystemFunctionCache.service_code == service_code)
|
||||
|
||||
functions = query.order_by(SystemFunctionCache.service_code, SystemFunctionCache.id).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": f.id,
|
||||
"service_code": f.service_code,
|
||||
"function_code": f.function_code,
|
||||
"function_name": f.function_name,
|
||||
"function_category": f.function_category,
|
||||
}
|
||||
for f in functions
|
||||
]
|
||||
144
backend/app/api/v1/router.py
Normal file
144
backend/app/api/v1/router.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
API v1 主路由
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
auth,
|
||||
tenants,
|
||||
employees,
|
||||
departments,
|
||||
department_members,
|
||||
roles,
|
||||
# identities, # Removed: EmployeeIdentity and BusinessUnit models have been deleted
|
||||
network_drives,
|
||||
audit_logs,
|
||||
email_accounts,
|
||||
permissions,
|
||||
lifecycle,
|
||||
personal_service_settings,
|
||||
emp_onboarding,
|
||||
system_functions,
|
||||
)
|
||||
from app.api.v1.endpoints import installation, installation_phases
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# 認證
|
||||
api_router.include_router(
|
||||
auth.router,
|
||||
prefix="/auth",
|
||||
tags=["Authentication"]
|
||||
)
|
||||
|
||||
# 租戶管理 (多租戶核心)
|
||||
api_router.include_router(
|
||||
tenants.router,
|
||||
prefix="/tenants",
|
||||
tags=["Tenants"]
|
||||
)
|
||||
|
||||
# 員工管理
|
||||
api_router.include_router(
|
||||
employees.router,
|
||||
prefix="/employees",
|
||||
tags=["Employees"]
|
||||
)
|
||||
|
||||
# 部門管理 (統一樹狀結構,取代原 business-units)
|
||||
api_router.include_router(
|
||||
departments.router,
|
||||
prefix="/departments",
|
||||
tags=["Departments"]
|
||||
)
|
||||
|
||||
# 部門成員管理 (員工多部門歸屬)
|
||||
api_router.include_router(
|
||||
department_members.router,
|
||||
prefix="/department-members",
|
||||
tags=["Department Members"]
|
||||
)
|
||||
|
||||
# 角色管理 (RBAC)
|
||||
api_router.include_router(
|
||||
roles.router,
|
||||
prefix="/roles",
|
||||
tags=["Roles & RBAC"]
|
||||
)
|
||||
|
||||
# 身份管理 (已廢棄 API,底層 model 已刪除)
|
||||
# api_router.include_router(
|
||||
# identities.router,
|
||||
# prefix="/identities",
|
||||
# tags=["Employee Identities (Deprecated)"]
|
||||
# )
|
||||
|
||||
# 網路硬碟管理
|
||||
api_router.include_router(
|
||||
network_drives.router,
|
||||
prefix="/network-drives",
|
||||
tags=["Network Drives"]
|
||||
)
|
||||
|
||||
# 審計日誌
|
||||
api_router.include_router(
|
||||
audit_logs.router,
|
||||
prefix="/audit-logs",
|
||||
tags=["Audit Logs"]
|
||||
)
|
||||
|
||||
# 郵件帳號管理
|
||||
api_router.include_router(
|
||||
email_accounts.router,
|
||||
prefix="/email-accounts",
|
||||
tags=["Email Accounts"]
|
||||
)
|
||||
|
||||
# 系統權限管理
|
||||
api_router.include_router(
|
||||
permissions.router,
|
||||
prefix="/permissions",
|
||||
tags=["Permissions"]
|
||||
)
|
||||
|
||||
# 員工生命週期管理
|
||||
api_router.include_router(
|
||||
lifecycle.router,
|
||||
prefix="",
|
||||
tags=["Employee Lifecycle"]
|
||||
)
|
||||
|
||||
# 個人化服務設定管理
|
||||
api_router.include_router(
|
||||
personal_service_settings.router,
|
||||
prefix="/personal-services",
|
||||
tags=["Personal Service Settings"]
|
||||
)
|
||||
|
||||
# 員工到職/離職流程 (v3.1 多租戶架構)
|
||||
api_router.include_router(
|
||||
emp_onboarding.router,
|
||||
prefix="/emp-lifecycle",
|
||||
tags=["Employee Onboarding (v3.1)"]
|
||||
)
|
||||
|
||||
# 系統初始化與健康檢查
|
||||
api_router.include_router(
|
||||
installation.router,
|
||||
prefix="/installation",
|
||||
tags=["Installation & Health Check"]
|
||||
)
|
||||
|
||||
# 系統階段轉換(Initialization/Operational/Transition)
|
||||
api_router.include_router(
|
||||
installation_phases.router,
|
||||
prefix="/installation",
|
||||
tags=["System Phase Management"]
|
||||
)
|
||||
|
||||
# 系統功能管理
|
||||
api_router.include_router(
|
||||
system_functions.router,
|
||||
prefix="/system-functions",
|
||||
tags=["System Functions"]
|
||||
)
|
||||
303
backend/app/api/v1/system_functions.py
Normal file
303
backend/app/api/v1/system_functions.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
System Functions API
|
||||
系統功能明細 CRUD API
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.system_function import SystemFunction
|
||||
from app.schemas.system_function import (
|
||||
SystemFunctionCreate,
|
||||
SystemFunctionUpdate,
|
||||
SystemFunctionResponse,
|
||||
SystemFunctionListResponse
|
||||
)
|
||||
from app.api.deps import get_pagination_params
|
||||
from app.schemas.base import PaginationParams
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=SystemFunctionListResponse)
|
||||
def get_system_functions(
|
||||
function_type: Optional[int] = Query(None, description="功能類型 (1:node, 2:function)"),
|
||||
upper_function_id: Optional[int] = Query(None, description="上層功能代碼"),
|
||||
is_mana: Optional[bool] = Query(None, description="系統管理"),
|
||||
is_active: Optional[bool] = Query(None, description="啟用(預設顯示全部)"),
|
||||
search: Optional[str] = Query(None, description="搜尋 (code or name)"),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
取得系統功能列表
|
||||
|
||||
- 支援分頁
|
||||
- 支援篩選 (function_type, upper_function_id, is_mana, is_active)
|
||||
- 支援搜尋 (code or name)
|
||||
"""
|
||||
query = db.query(SystemFunction)
|
||||
|
||||
# 篩選條件
|
||||
filters = []
|
||||
if function_type is not None:
|
||||
filters.append(SystemFunction.function_type == function_type)
|
||||
if upper_function_id is not None:
|
||||
filters.append(SystemFunction.upper_function_id == upper_function_id)
|
||||
if is_mana is not None:
|
||||
filters.append(SystemFunction.is_mana == is_mana)
|
||||
if is_active is not None:
|
||||
filters.append(SystemFunction.is_active == is_active)
|
||||
|
||||
# 搜尋
|
||||
if search:
|
||||
filters.append(
|
||||
or_(
|
||||
SystemFunction.code.ilike(f"%{search}%"),
|
||||
SystemFunction.name.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
if filters:
|
||||
query = query.filter(and_(*filters))
|
||||
|
||||
# 排序 (依照 order 排序)
|
||||
query = query.order_by(SystemFunction.order.asc())
|
||||
|
||||
# 計算總數
|
||||
total = query.count()
|
||||
|
||||
# 分頁
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
items = query.offset(offset).limit(pagination.page_size).all()
|
||||
|
||||
return SystemFunctionListResponse(
|
||||
total=total,
|
||||
items=items,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{function_id}", response_model=SystemFunctionResponse)
|
||||
def get_system_function(
|
||||
function_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
取得單一系統功能
|
||||
"""
|
||||
function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
|
||||
|
||||
if not function:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"System function not found: {function_id}"
|
||||
)
|
||||
|
||||
return function
|
||||
|
||||
|
||||
@router.post("", response_model=SystemFunctionResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_system_function(
|
||||
function_in: SystemFunctionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
建立系統功能
|
||||
|
||||
驗證規則:
|
||||
- function_type=1 (node) 時, module_code 不能輸入
|
||||
- function_type=2 (function) 時, module_code 和 module_functions 為必填
|
||||
- upper_function_id 必須是 function_type=1 且 is_active=1 的功能, 或 0 (初始層)
|
||||
"""
|
||||
# 驗證 upper_function_id
|
||||
if function_in.upper_function_id > 0:
|
||||
parent = db.query(SystemFunction).filter(
|
||||
SystemFunction.id == function_in.upper_function_id,
|
||||
SystemFunction.function_type == 1,
|
||||
SystemFunction.is_active == True
|
||||
).first()
|
||||
|
||||
if not parent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid upper_function_id: {function_in.upper_function_id} "
|
||||
"(must be function_type=1 and is_active=1)"
|
||||
)
|
||||
|
||||
# 檢查 code 是否重複
|
||||
existing = db.query(SystemFunction).filter(SystemFunction.code == function_in.code).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"System function code already exists: {function_in.code}"
|
||||
)
|
||||
|
||||
# 建立資料
|
||||
db_function = SystemFunction(**function_in.model_dump())
|
||||
db.add(db_function)
|
||||
db.commit()
|
||||
db.refresh(db_function)
|
||||
|
||||
return db_function
|
||||
|
||||
|
||||
@router.put("/{function_id}", response_model=SystemFunctionResponse)
|
||||
def update_system_function(
|
||||
function_id: int,
|
||||
function_in: SystemFunctionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新系統功能 (完整更新)
|
||||
"""
|
||||
# 查詢現有資料
|
||||
db_function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
|
||||
|
||||
if not db_function:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"System function not found: {function_id}"
|
||||
)
|
||||
|
||||
# 更新資料
|
||||
update_data = function_in.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(db_function, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_function)
|
||||
|
||||
return db_function
|
||||
|
||||
|
||||
@router.patch("/{function_id}", response_model=SystemFunctionResponse)
|
||||
def patch_system_function(
|
||||
function_id: int,
|
||||
function_in: SystemFunctionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新系統功能 (部分更新)
|
||||
"""
|
||||
return update_system_function(function_id, function_in, db)
|
||||
|
||||
|
||||
@router.delete("/{function_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_system_function(
|
||||
function_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
刪除系統功能 (實際上是軟刪除, 設定 is_active=False)
|
||||
"""
|
||||
db_function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
|
||||
|
||||
if not db_function:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"System function not found: {function_id}"
|
||||
)
|
||||
|
||||
# 軟刪除
|
||||
db_function.is_active = False
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.delete("/{function_id}/hard", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def hard_delete_system_function(
|
||||
function_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
永久刪除系統功能 (硬刪除)
|
||||
|
||||
⚠️ 警告: 此操作無法復原
|
||||
"""
|
||||
db_function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
|
||||
|
||||
if not db_function:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"System function not found: {function_id}"
|
||||
)
|
||||
|
||||
# 硬刪除
|
||||
db.delete(db_function)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/menu/tree", response_model=List[dict])
|
||||
def get_menu_tree(
|
||||
is_sysmana: bool = Query(False, description="是否為系統管理公司"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
取得功能列表樹狀結構 (用於前端選單顯示)
|
||||
|
||||
根據 is_sysmana 過濾功能:
|
||||
- is_sysmana=true: 返回所有功能 (包含 is_mana=true 的系統管理功能)
|
||||
- is_sysmana=false: 只返回 is_mana=false 的一般功能
|
||||
|
||||
返回格式:
|
||||
[
|
||||
{
|
||||
"id": 10,
|
||||
"code": "system_managements",
|
||||
"name": "系統管理後台",
|
||||
"function_type": 1,
|
||||
"order": 100,
|
||||
"function_icon": "",
|
||||
"module_code": null,
|
||||
"module_functions": [],
|
||||
"children": [
|
||||
{
|
||||
"id": 11,
|
||||
"code": "system_settings",
|
||||
"name": "系統資料設定",
|
||||
"function_type": 2,
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"""
|
||||
# 查詢條件
|
||||
query = db.query(SystemFunction).filter(SystemFunction.is_active == True)
|
||||
|
||||
# 如果不是系統管理公司,過濾掉 is_mana=true 的功能
|
||||
if not is_sysmana:
|
||||
query = query.filter(SystemFunction.is_mana == False)
|
||||
|
||||
# 排序
|
||||
functions = query.order_by(SystemFunction.order.asc()).all()
|
||||
|
||||
# 建立樹狀結構
|
||||
def build_tree(parent_id: int = 0) -> List[dict]:
|
||||
tree = []
|
||||
for func in functions:
|
||||
if func.upper_function_id == parent_id:
|
||||
node = {
|
||||
"id": func.id,
|
||||
"code": func.code,
|
||||
"name": func.name,
|
||||
"function_type": func.function_type,
|
||||
"order": func.order,
|
||||
"function_icon": func.function_icon or "",
|
||||
"module_code": func.module_code,
|
||||
"module_functions": func.module_functions or [],
|
||||
"description": func.description or "",
|
||||
"children": build_tree(func.id) if func.function_type == 1 else []
|
||||
}
|
||||
tree.append(node)
|
||||
return tree
|
||||
|
||||
return build_tree(0)
|
||||
603
backend/app/api/v1/tenants.py
Normal file
603
backend/app/api/v1/tenants.py
Normal file
@@ -0,0 +1,603 @@
|
||||
"""
|
||||
租戶管理 API
|
||||
用於管理多租戶資訊(僅系統管理公司可存取)
|
||||
"""
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
import string
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import get_db, require_auth, get_current_tenant
|
||||
from app.models import Tenant, Employee
|
||||
from app.schemas.tenant import (
|
||||
TenantCreateRequest,
|
||||
TenantCreateResponse,
|
||||
TenantUpdateRequest,
|
||||
TenantUpdateResponse,
|
||||
TenantResponse,
|
||||
InitializationRequest,
|
||||
InitializationResponse
|
||||
)
|
||||
from app.services.keycloak_admin_client import get_keycloak_admin_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/current", summary="取得當前租戶資訊")
|
||||
def get_current_tenant_info(
|
||||
tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""
|
||||
取得當前租戶資訊
|
||||
|
||||
根據 JWT Token 的 Realm 自動識別租戶
|
||||
"""
|
||||
return {
|
||||
"id": tenant.id,
|
||||
"code": tenant.code,
|
||||
"name": tenant.name,
|
||||
"name_eng": tenant.name_eng,
|
||||
"tax_id": tenant.tax_id,
|
||||
"prefix": tenant.prefix,
|
||||
"tel": tenant.tel,
|
||||
"add": tenant.add,
|
||||
"url": tenant.url,
|
||||
"keycloak_realm": tenant.keycloak_realm,
|
||||
"is_sysmana": tenant.is_sysmana,
|
||||
"plan_id": tenant.plan_id,
|
||||
"max_users": tenant.max_users,
|
||||
"storage_quota_gb": tenant.storage_quota_gb,
|
||||
"status": tenant.status,
|
||||
"is_active": tenant.is_active,
|
||||
"edit_by": tenant.edit_by,
|
||||
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
|
||||
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/current", summary="更新當前租戶資訊")
|
||||
def update_current_tenant_info(
|
||||
request: TenantUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""
|
||||
更新當前租戶的基本資料
|
||||
|
||||
僅允許更新以下欄位:
|
||||
- name: 公司名稱
|
||||
- name_eng: 公司英文名稱
|
||||
- tax_id: 統一編號
|
||||
- tel: 公司電話
|
||||
- add: 公司地址
|
||||
- url: 公司網站
|
||||
|
||||
注意: 租戶代碼 (code)、前綴 (prefix)、方案等核心欄位不可修改
|
||||
"""
|
||||
try:
|
||||
# 更新欄位
|
||||
if request.name is not None:
|
||||
tenant.name = request.name
|
||||
if request.name_eng is not None:
|
||||
tenant.name_eng = request.name_eng
|
||||
if request.tax_id is not None:
|
||||
tenant.tax_id = request.tax_id
|
||||
if request.tel is not None:
|
||||
tenant.tel = request.tel
|
||||
if request.add is not None:
|
||||
tenant.add = request.add
|
||||
if request.url is not None:
|
||||
tenant.url = request.url
|
||||
|
||||
# 更新編輯者
|
||||
tenant.edit_by = "current_user" # TODO: 從 JWT Token 取得實際用戶名稱
|
||||
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
|
||||
return {
|
||||
"message": "公司資料已成功更新",
|
||||
"tenant": {
|
||||
"id": tenant.id,
|
||||
"code": tenant.code,
|
||||
"name": tenant.name,
|
||||
"name_eng": tenant.name_eng,
|
||||
"tax_id": tenant.tax_id,
|
||||
"tel": tenant.tel,
|
||||
"add": tenant.add,
|
||||
"url": tenant.url,
|
||||
}
|
||||
}
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"更新失敗: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新失敗: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", summary="列出所有租戶(僅系統管理公司)")
|
||||
def list_tenants(
|
||||
db: Session = Depends(get_db),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""
|
||||
列出所有租戶
|
||||
|
||||
權限要求: 必須為系統管理公司 (is_sysmana=True)
|
||||
"""
|
||||
# 權限檢查
|
||||
if not current_tenant.is_sysmana:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only system management company can access this resource"
|
||||
)
|
||||
|
||||
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
|
||||
|
||||
return {
|
||||
"total": len(tenants),
|
||||
"items": [
|
||||
{
|
||||
"id": t.id,
|
||||
"code": t.code,
|
||||
"name": t.name,
|
||||
"name_eng": t.name_eng,
|
||||
"keycloak_realm": t.keycloak_realm,
|
||||
"tax_id": t.tax_id,
|
||||
"prefix": t.prefix,
|
||||
"domain_set": t.domain_set,
|
||||
"tel": t.tel,
|
||||
"add": t.add,
|
||||
"url": t.url,
|
||||
"plan_id": t.plan_id,
|
||||
"max_users": t.max_users,
|
||||
"storage_quota_gb": t.storage_quota_gb,
|
||||
"status": t.status,
|
||||
"is_sysmana": t.is_sysmana,
|
||||
"is_active": t.is_active,
|
||||
"is_initialized": t.is_initialized,
|
||||
"initialized_at": t.initialized_at.isoformat() if t.initialized_at else None,
|
||||
"initialized_by": t.initialized_by,
|
||||
"edit_by": t.edit_by,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
||||
}
|
||||
for t in tenants
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{tenant_id}", summary="取得指定租戶資訊(僅系統管理公司)")
|
||||
def get_tenant(
|
||||
tenant_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""
|
||||
取得指定租戶詳細資訊
|
||||
|
||||
權限要求: 必須為系統管理公司 (is_sysmana=True)
|
||||
"""
|
||||
# 權限檢查
|
||||
if not current_tenant.is_sysmana:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only system management company can access this resource"
|
||||
)
|
||||
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": tenant.id,
|
||||
"code": tenant.code,
|
||||
"name": tenant.name,
|
||||
"name_eng": tenant.name_eng,
|
||||
"tax_id": tenant.tax_id,
|
||||
"prefix": tenant.prefix,
|
||||
"domain_set": tenant.domains,
|
||||
"tel": tenant.tel,
|
||||
"add": tenant.add,
|
||||
"url": tenant.url,
|
||||
"keycloak_realm": tenant.keycloak_realm,
|
||||
"is_sysmana": tenant.is_sysmana,
|
||||
"plan_id": tenant.plan_id,
|
||||
"max_users": tenant.max_users,
|
||||
"storage_quota_gb": tenant.storage_quota_gb,
|
||||
"status": tenant.status,
|
||||
"is_active": tenant.is_active,
|
||||
"is_initialized": tenant.is_initialized,
|
||||
"initialized_at": tenant.initialized_at,
|
||||
"initialized_by": tenant.initialized_by,
|
||||
"created_at": tenant.created_at,
|
||||
"updated_at": tenant.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _generate_temp_password(length: int = 12) -> str:
|
||||
"""產生臨時密碼"""
|
||||
chars = string.ascii_letters + string.digits + "!@#$%"
|
||||
return ''.join(secrets.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
@router.post("/", response_model=TenantCreateResponse, summary="建立新租戶(僅 Superuser)")
|
||||
def create_tenant(
|
||||
request: TenantCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""
|
||||
建立新租戶(含 Keycloak Realm + Tenant Admin 帳號)
|
||||
|
||||
權限要求: 必須為系統管理公司 (is_sysmana=True)
|
||||
|
||||
流程:
|
||||
1. 驗證租戶代碼唯一性
|
||||
2. 建立 Keycloak Realm
|
||||
3. 在 Keycloak Realm 中建立 Tenant Admin 使用者
|
||||
4. 建立租戶記錄(tenants 表)
|
||||
5. 建立 Employee 記錄(employees 表)
|
||||
6. 返回租戶資訊與臨時密碼
|
||||
|
||||
Returns:
|
||||
租戶資訊 + Tenant Admin 登入資訊
|
||||
"""
|
||||
# ========== 權限檢查 ==========
|
||||
if not current_tenant.is_sysmana:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only system management company can create tenants"
|
||||
)
|
||||
|
||||
# ========== Step 1: 驗證租戶代碼唯一性 ==========
|
||||
existing_tenant = db.query(Tenant).filter(Tenant.code == request.code).first()
|
||||
if existing_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Tenant code '{request.code}' already exists"
|
||||
)
|
||||
|
||||
# 產生 Keycloak Realm 名稱 (格式: porscheworld-pwd)
|
||||
realm_name = f"porscheworld-{request.code.lower()}"
|
||||
|
||||
# ========== Step 2: 建立 Keycloak Realm ==========
|
||||
keycloak_client = get_keycloak_admin_client()
|
||||
realm_config = keycloak_client.create_realm(
|
||||
realm_name=realm_name,
|
||||
display_name=request.name
|
||||
)
|
||||
|
||||
if not realm_config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create Keycloak Realm"
|
||||
)
|
||||
|
||||
try:
|
||||
# ========== Step 3: 建立 Keycloak Realm Role (tenant-admin) ==========
|
||||
keycloak_client.create_realm_role(
|
||||
realm_name=realm_name,
|
||||
role_name="tenant-admin",
|
||||
description="租戶管理員 - 可管理公司內所有資源"
|
||||
)
|
||||
|
||||
# ========== Step 4: 建立租戶記錄 ==========
|
||||
new_tenant = Tenant(
|
||||
code=request.code,
|
||||
name=request.name,
|
||||
name_eng=request.name_eng,
|
||||
tax_id=request.tax_id,
|
||||
prefix=request.prefix,
|
||||
tel=request.tel,
|
||||
add=request.add,
|
||||
url=request.url,
|
||||
keycloak_realm=realm_name,
|
||||
plan_id=request.plan_id,
|
||||
max_users=request.max_users,
|
||||
storage_quota_gb=request.storage_quota_gb,
|
||||
status="trial",
|
||||
is_sysmana=False,
|
||||
is_active=True,
|
||||
is_initialized=False, # 尚未初始化
|
||||
edit_by="system"
|
||||
)
|
||||
|
||||
db.add(new_tenant)
|
||||
db.flush() # 取得 tenant.id
|
||||
|
||||
# ========== Step 5: 在 Keycloak 建立 Tenant Admin 使用者 ==========
|
||||
# 使用提供的臨時密碼或產生新的
|
||||
temp_password = request.admin_temp_password
|
||||
|
||||
# 分割姓名 (假設格式: "陳保時" → firstName="保時", lastName="陳")
|
||||
name_parts = request.admin_name.split()
|
||||
if len(name_parts) >= 2:
|
||||
first_name = " ".join(name_parts[1:])
|
||||
last_name = name_parts[0]
|
||||
else:
|
||||
first_name = request.admin_name
|
||||
last_name = ""
|
||||
|
||||
keycloak_user_id = keycloak_client.create_user(
|
||||
username=request.admin_username,
|
||||
email=request.admin_email,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
enabled=True,
|
||||
email_verified=False
|
||||
)
|
||||
|
||||
if not keycloak_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create Keycloak user"
|
||||
)
|
||||
|
||||
# 設定臨時密碼(首次登入必須變更)
|
||||
keycloak_client.reset_password(
|
||||
user_id=keycloak_user_id,
|
||||
password=temp_password,
|
||||
temporary=True # 臨時密碼
|
||||
)
|
||||
|
||||
# 將 tenant-admin 角色分配給使用者
|
||||
role_assigned = keycloak_client.assign_realm_role_to_user(
|
||||
realm_name=realm_name,
|
||||
user_id=keycloak_user_id,
|
||||
role_name="tenant-admin"
|
||||
)
|
||||
|
||||
if not role_assigned:
|
||||
print(f"⚠️ Warning: Failed to assign tenant-admin role to user {keycloak_user_id}")
|
||||
# 不中斷流程,但記錄警告
|
||||
|
||||
# ========== Step 6: 建立 Employee 記錄 ==========
|
||||
admin_employee = Employee(
|
||||
tenant_id=new_tenant.id,
|
||||
seq_no=1, # 第一號員工
|
||||
tenant_emp_code=f"{request.prefix}0001",
|
||||
name=request.admin_name,
|
||||
name_eng=name_parts[0] if len(name_parts) >= 2 else request.admin_name,
|
||||
keycloak_username=request.admin_username,
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
storage_quota_gb=100, # Admin 預設配額
|
||||
email_quota_mb=10240, # 10 GB
|
||||
employment_status="active",
|
||||
is_active=True,
|
||||
edit_by="system"
|
||||
)
|
||||
|
||||
db.add(admin_employee)
|
||||
db.commit()
|
||||
|
||||
# ========== Step 7: 返回結果 ==========
|
||||
return TenantCreateResponse(
|
||||
message="Tenant created successfully",
|
||||
tenant={
|
||||
"id": new_tenant.id,
|
||||
"code": new_tenant.code,
|
||||
"name": new_tenant.name,
|
||||
"keycloak_realm": realm_name,
|
||||
"status": new_tenant.status,
|
||||
},
|
||||
admin_user={
|
||||
"username": request.admin_username,
|
||||
"email": request.admin_email,
|
||||
"keycloak_user_id": keycloak_user_id,
|
||||
},
|
||||
keycloak_realm=realm_name,
|
||||
temporary_password=temp_password
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
# 嘗試清理已建立的 Realm
|
||||
keycloak_client.delete_realm(realm_name)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Database integrity error: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
# 嘗試清理已建立的 Realm
|
||||
keycloak_client.delete_realm(realm_name)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create tenant: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tenant_id}/initialize", response_model=InitializationResponse, summary="完成租戶初始化(僅 Tenant Admin)")
|
||||
def initialize_tenant(
|
||||
tenant_id: int,
|
||||
request: InitializationRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_tenant: Tenant = Depends(get_current_tenant),
|
||||
):
|
||||
"""
|
||||
完成租戶初始化流程
|
||||
|
||||
權限要求:
|
||||
- 必須為該租戶的成員
|
||||
- 必須擁有 tenant-admin 角色 (在 Keycloak 驗證)
|
||||
- 租戶必須尚未初始化 (is_initialized = false)
|
||||
|
||||
流程:
|
||||
1. 驗證權限與初始化狀態
|
||||
2. 更新公司基本資料
|
||||
3. 建立部門結構
|
||||
4. 建立系統角色 (同步到 Keycloak)
|
||||
5. 儲存預設配額與服務設定
|
||||
6. 設定 is_initialized = true
|
||||
7. 記錄審計日誌
|
||||
|
||||
Returns:
|
||||
初始化結果摘要
|
||||
"""
|
||||
from app.models import Department, UserRole, AuditLog
|
||||
|
||||
# ========== Step 1: 權限檢查 ==========
|
||||
# 驗證使用者屬於該租戶
|
||||
if current_tenant.id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only initialize your own tenant"
|
||||
)
|
||||
|
||||
# 取得租戶記錄
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found"
|
||||
)
|
||||
|
||||
# 防止重複初始化
|
||||
if tenant.is_initialized:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant has already been initialized. Initialization wizard is locked."
|
||||
)
|
||||
|
||||
# TODO: 驗證使用者擁有 tenant-admin 角色 (從 JWT Token 或 Keycloak API)
|
||||
# 目前暫時跳過,後續實作 JWT Token 驗證
|
||||
|
||||
try:
|
||||
# ========== Step 2: 更新公司基本資料 ==========
|
||||
company_info = request.company_info
|
||||
|
||||
if "name" in company_info:
|
||||
tenant.name = company_info["name"]
|
||||
if "name_eng" in company_info:
|
||||
tenant.name_eng = company_info["name_eng"]
|
||||
if "tax_id" in company_info:
|
||||
tenant.tax_id = company_info["tax_id"]
|
||||
if "tel" in company_info:
|
||||
tenant.tel = company_info["tel"]
|
||||
if "add" in company_info:
|
||||
tenant.add = company_info["add"]
|
||||
if "url" in company_info:
|
||||
tenant.url = company_info["url"]
|
||||
|
||||
# ========== Step 3: 建立部門結構 ==========
|
||||
departments_created = []
|
||||
|
||||
for dept_data in request.departments:
|
||||
new_dept = Department(
|
||||
tenant_id=tenant_id,
|
||||
code=dept_data.get("code", dept_data["name"][:10]),
|
||||
name=dept_data["name"],
|
||||
name_eng=dept_data.get("name_eng"),
|
||||
parent_id=dept_data.get("parent_id"),
|
||||
is_active=True,
|
||||
edit_by="system"
|
||||
)
|
||||
db.add(new_dept)
|
||||
departments_created.append(dept_data["name"])
|
||||
|
||||
db.flush() # 取得部門 ID
|
||||
|
||||
# ========== Step 4: 建立系統角色 ==========
|
||||
keycloak_client = get_keycloak_admin_client()
|
||||
roles_created = []
|
||||
|
||||
for role_data in request.roles:
|
||||
# 在資料庫建立角色記錄
|
||||
new_role = UserRole(
|
||||
tenant_id=tenant_id,
|
||||
role_code=role_data["code"],
|
||||
role_name=role_data["name"],
|
||||
description=role_data.get("description", ""),
|
||||
is_active=True,
|
||||
edit_by="system"
|
||||
)
|
||||
db.add(new_role)
|
||||
|
||||
# 在 Keycloak Realm 建立對應角色
|
||||
role_created = keycloak_client.create_realm_role(
|
||||
realm_name=tenant.keycloak_realm,
|
||||
role_name=role_data["code"],
|
||||
description=role_data.get("description", role_data["name"])
|
||||
)
|
||||
|
||||
if role_created:
|
||||
roles_created.append(role_data["name"])
|
||||
else:
|
||||
print(f"⚠️ Warning: Failed to create role {role_data['code']} in Keycloak")
|
||||
|
||||
# ========== Step 5: 儲存預設配額與服務設定 ==========
|
||||
# TODO: 實作預設配額儲存邏輯 (需要設計 tenant_settings 表)
|
||||
# 目前暫時儲存在 tenant 的 JSONB 欄位或獨立表
|
||||
|
||||
default_settings = request.default_settings
|
||||
# 這裡可以儲存到 tenant metadata 或獨立的 settings 表
|
||||
|
||||
# ========== Step 6: 設定初始化完成 ==========
|
||||
tenant.is_initialized = True
|
||||
tenant.initialized_at = datetime.utcnow()
|
||||
# TODO: 從 JWT Token 取得 current_user.username
|
||||
tenant.initialized_by = "admin" # 暫時硬編碼
|
||||
|
||||
# ========== Step 7: 記錄審計日誌 ==========
|
||||
audit_log = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=None, # TODO: 從 current_user 取得
|
||||
action="tenant.initialized",
|
||||
resource_type="tenant",
|
||||
resource_id=str(tenant_id),
|
||||
details={
|
||||
"departments_created": len(departments_created),
|
||||
"roles_created": len(roles_created),
|
||||
"department_names": departments_created,
|
||||
"role_names": roles_created,
|
||||
"default_settings": default_settings,
|
||||
},
|
||||
ip_address=None, # TODO: 從 request 取得
|
||||
user_agent=None,
|
||||
)
|
||||
db.add(audit_log)
|
||||
|
||||
# 提交所有變更
|
||||
db.commit()
|
||||
|
||||
# ========== Step 8: 返回結果 ==========
|
||||
return InitializationResponse(
|
||||
message="Tenant initialization completed successfully",
|
||||
summary={
|
||||
"tenant_id": tenant_id,
|
||||
"tenant_name": tenant.name,
|
||||
"departments_created": len(departments_created),
|
||||
"roles_created": len(roles_created),
|
||||
"initialized_at": tenant.initialized_at.isoformat(),
|
||||
"initialized_by": tenant.initialized_by,
|
||||
}
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Database integrity error: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Initialization failed: {str(e)}"
|
||||
)
|
||||
4
backend/app/batch/__init__.py
Normal file
4
backend/app/batch/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
批次作業模組
|
||||
包含所有定時排程的批次處理任務
|
||||
"""
|
||||
160
backend/app/batch/archive_audit_logs.py
Normal file
160
backend/app/batch/archive_audit_logs.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
審計日誌歸檔批次 (5.3)
|
||||
執行時間: 每月 1 日 01:00
|
||||
批次名稱: archive_audit_logs
|
||||
|
||||
將 90 天前的審計日誌匯出為 CSV,並從主資料庫刪除
|
||||
歸檔目錄: /mnt/nas/working/audit_logs/
|
||||
"""
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.batch.base import log_batch_execution
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ARCHIVE_DAYS = 90 # 保留最近 90 天,超過的歸檔
|
||||
ARCHIVE_BASE_DIR = "/mnt/nas/working/audit_logs"
|
||||
|
||||
|
||||
def _get_archive_dir() -> str:
|
||||
"""取得歸檔目錄,不存在時建立"""
|
||||
os.makedirs(ARCHIVE_BASE_DIR, exist_ok=True)
|
||||
return ARCHIVE_BASE_DIR
|
||||
|
||||
|
||||
def run_archive_audit_logs(dry_run: bool = False) -> dict:
|
||||
"""
|
||||
執行審計日誌歸檔批次
|
||||
|
||||
Args:
|
||||
dry_run: True 時只統計不實際刪除
|
||||
|
||||
Returns:
|
||||
執行結果摘要
|
||||
"""
|
||||
started_at = datetime.utcnow()
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=ARCHIVE_DAYS)
|
||||
|
||||
logger.info(f"=== 開始審計日誌歸檔批次 === 截止日期: {cutoff_date.strftime('%Y-%m-%d')}")
|
||||
if dry_run:
|
||||
logger.info("[DRY RUN] 不會實際刪除資料")
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
# 1. 查詢超過 90 天的日誌
|
||||
old_logs = db.query(AuditLog).filter(
|
||||
AuditLog.performed_at < cutoff_date
|
||||
).order_by(AuditLog.performed_at).all()
|
||||
|
||||
total_count = len(old_logs)
|
||||
logger.info(f"找到 {total_count} 筆待歸檔日誌")
|
||||
|
||||
if total_count == 0:
|
||||
message = f"無需歸檔 (截止日期 {cutoff_date.strftime('%Y-%m-%d')} 前無記錄)"
|
||||
log_batch_execution(
|
||||
batch_name="archive_audit_logs",
|
||||
status="success",
|
||||
message=message,
|
||||
started_at=started_at,
|
||||
)
|
||||
return {"status": "success", "archived": 0, "message": message}
|
||||
|
||||
# 2. 匯出到 CSV
|
||||
archive_month = cutoff_date.strftime("%Y%m")
|
||||
archive_dir = _get_archive_dir()
|
||||
csv_path = os.path.join(archive_dir, f"archive_{archive_month}.csv")
|
||||
|
||||
fieldnames = [
|
||||
"id", "action", "resource_type", "resource_id",
|
||||
"performed_by", "ip_address",
|
||||
"details", "performed_at"
|
||||
]
|
||||
|
||||
logger.info(f"匯出至: {csv_path}")
|
||||
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for log in old_logs:
|
||||
writer.writerow({
|
||||
"id": log.id,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"performed_by": getattr(log, "performed_by", ""),
|
||||
"ip_address": getattr(log, "ip_address", ""),
|
||||
"details": str(getattr(log, "details", "")),
|
||||
"performed_at": str(log.performed_at),
|
||||
})
|
||||
|
||||
logger.info(f"已匯出 {total_count} 筆至 {csv_path}")
|
||||
|
||||
# 3. 刪除舊日誌 (非 dry_run 才執行)
|
||||
deleted_count = 0
|
||||
if not dry_run:
|
||||
for log in old_logs:
|
||||
db.delete(log)
|
||||
db.commit()
|
||||
deleted_count = total_count
|
||||
logger.info(f"已刪除 {deleted_count} 筆舊日誌")
|
||||
else:
|
||||
logger.info(f"[DRY RUN] 將刪除 {total_count} 筆 (未實際執行)")
|
||||
|
||||
# 4. 記錄批次執行日誌
|
||||
finished_at = datetime.utcnow()
|
||||
message = (
|
||||
f"歸檔 {total_count} 筆到 {csv_path}"
|
||||
+ (f"; 已刪除 {deleted_count} 筆" if not dry_run else " (DRY RUN)")
|
||||
)
|
||||
log_batch_execution(
|
||||
batch_name="archive_audit_logs",
|
||||
status="success",
|
||||
message=message,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
|
||||
logger.info(f"=== 審計日誌歸檔批次完成 === {message}")
|
||||
return {
|
||||
"status": "success",
|
||||
"archived": total_count,
|
||||
"deleted": deleted_count,
|
||||
"csv_path": csv_path,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"審計日誌歸檔批次失敗: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
log_batch_execution(
|
||||
batch_name="archive_audit_logs",
|
||||
status="failed",
|
||||
message=error_msg,
|
||||
started_at=started_at,
|
||||
)
|
||||
return {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import argparse
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true", help="只統計不實際刪除")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = run_archive_audit_logs(dry_run=args.dry_run)
|
||||
print(f"執行結果: {result}")
|
||||
59
backend/app/batch/base.py
Normal file
59
backend/app/batch/base.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
批次作業基礎工具
|
||||
提供 log_batch_execution 等共用函式
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_batch_execution(
|
||||
batch_name: str,
|
||||
status: str,
|
||||
message: Optional[str] = None,
|
||||
started_at: Optional[datetime] = None,
|
||||
finished_at: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""
|
||||
記錄批次執行日誌到資料庫
|
||||
|
||||
Args:
|
||||
batch_name: 批次名稱
|
||||
status: 執行狀態 (success/failed/warning)
|
||||
message: 執行訊息
|
||||
started_at: 開始時間 (若未提供則使用 finished_at)
|
||||
finished_at: 完成時間 (若未提供則使用現在)
|
||||
"""
|
||||
from app.db.session import get_db
|
||||
from app.models.batch_log import BatchLog
|
||||
|
||||
now = datetime.utcnow()
|
||||
finished = finished_at or now
|
||||
started = started_at or finished
|
||||
|
||||
duration = None
|
||||
if started and finished:
|
||||
duration = int((finished - started).total_seconds())
|
||||
|
||||
try:
|
||||
db = next(get_db())
|
||||
log_entry = BatchLog(
|
||||
batch_name=batch_name,
|
||||
status=status,
|
||||
message=message,
|
||||
started_at=started,
|
||||
finished_at=finished,
|
||||
duration_seconds=duration,
|
||||
)
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
logger.info(f"[{batch_name}] 批次執行記錄已寫入: {status}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{batch_name}] 寫入批次日誌失敗: {e}")
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
152
backend/app/batch/daily_quota_check.py
Normal file
152
backend/app/batch/daily_quota_check.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
每日配額檢查批次 (5.1)
|
||||
執行時間: 每日 02:00
|
||||
批次名稱: daily_quota_check
|
||||
|
||||
檢查郵件和雲端硬碟配額使用情況,超過 80% 發送告警
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.batch.base import log_batch_execution
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QUOTA_ALERT_THRESHOLD = 0.8 # 超過 80% 發送告警
|
||||
ALERT_EMAIL = "admin@porscheworld.tw"
|
||||
|
||||
|
||||
def _send_alert_email(to: str, subject: str, body: str) -> bool:
|
||||
"""
|
||||
發送告警郵件
|
||||
|
||||
目前使用 SMTP 直送,未來可整合 Mailserver
|
||||
"""
|
||||
try:
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from app.core.config import settings
|
||||
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.MAIL_ADMIN_USER
|
||||
msg["To"] = to
|
||||
|
||||
with smtplib.SMTP(settings.MAIL_SERVER, settings.MAIL_PORT) as smtp:
|
||||
if settings.MAIL_USE_TLS:
|
||||
smtp.starttls()
|
||||
smtp.login(settings.MAIL_ADMIN_USER, settings.MAIL_ADMIN_PASSWORD)
|
||||
smtp.send_message(msg)
|
||||
|
||||
logger.info(f"告警郵件已發送至 {to}: {subject}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"發送告警郵件失敗: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run_daily_quota_check() -> dict:
|
||||
"""
|
||||
執行每日配額檢查批次
|
||||
|
||||
Returns:
|
||||
執行結果摘要
|
||||
"""
|
||||
started_at = datetime.utcnow()
|
||||
alerts_sent = 0
|
||||
errors = []
|
||||
summary = {
|
||||
"email_checked": 0,
|
||||
"email_alerts": 0,
|
||||
"drive_checked": 0,
|
||||
"drive_alerts": 0,
|
||||
}
|
||||
|
||||
logger.info("=== 開始每日配額檢查批次 ===")
|
||||
|
||||
# 取得資料庫 Session
|
||||
from app.db.session import get_db
|
||||
from app.models.email_account import EmailAccount
|
||||
from app.models.network_drive import NetworkDrive
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
# 1. 檢查郵件配額
|
||||
logger.info("檢查郵件配額使用情況...")
|
||||
email_accounts = db.query(EmailAccount).filter(
|
||||
EmailAccount.is_active == True
|
||||
).all()
|
||||
|
||||
for account in email_accounts:
|
||||
summary["email_checked"] += 1
|
||||
# 目前郵件 Mailserver API 未整合,跳過實際配額查詢
|
||||
# TODO: 整合 Mailserver API 後取得實際使用量
|
||||
# usage_mb = mailserver_service.get_usage(account.email_address)
|
||||
# if usage_mb and usage_mb / account.quota_mb > QUOTA_ALERT_THRESHOLD:
|
||||
# _send_alert_email(...)
|
||||
pass
|
||||
|
||||
logger.info(f"郵件帳號檢查完成: {summary['email_checked']} 個帳號")
|
||||
|
||||
# 2. 檢查雲端硬碟配額 (Drive Service API)
|
||||
logger.info("檢查雲端硬碟配額使用情況...")
|
||||
network_drives = db.query(NetworkDrive).filter(
|
||||
NetworkDrive.is_active == True
|
||||
).all()
|
||||
|
||||
from app.services.drive_service import get_drive_service_client
|
||||
drive_client = get_drive_service_client()
|
||||
|
||||
for drive in network_drives:
|
||||
summary["drive_checked"] += 1
|
||||
try:
|
||||
# 查詢配額使用量 (Drive Service 未上線時會回傳 None)
|
||||
# 注意: drive.id 是資料庫 ID,需要 drive_user_id
|
||||
# 目前跳過實際查詢,等 Drive Service 上線後補充
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"查詢 {drive.drive_name} 配額失敗: {e}")
|
||||
|
||||
logger.info(f"雲端硬碟檢查完成: {summary['drive_checked']} 個帳號")
|
||||
|
||||
# 3. 記錄批次執行日誌
|
||||
finished_at = datetime.utcnow()
|
||||
message = (
|
||||
f"郵件帳號: {summary['email_checked']} 個, 告警: {summary['email_alerts']} 個; "
|
||||
f"雲端硬碟: {summary['drive_checked']} 個, 告警: {summary['drive_alerts']} 個"
|
||||
)
|
||||
log_batch_execution(
|
||||
batch_name="daily_quota_check",
|
||||
status="success",
|
||||
message=message,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
|
||||
logger.info(f"=== 每日配額檢查批次完成 === {message}")
|
||||
return {"status": "success", "summary": summary}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"每日配額檢查批次失敗: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
log_batch_execution(
|
||||
batch_name="daily_quota_check",
|
||||
status="failed",
|
||||
message=error_msg,
|
||||
started_at=started_at,
|
||||
)
|
||||
return {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 允許直接執行此批次
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
result = run_daily_quota_check()
|
||||
print(f"執行結果: {result}")
|
||||
103
backend/app/batch/scheduler.py
Normal file
103
backend/app/batch/scheduler.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
批次作業排程器 (5.4)
|
||||
使用 schedule 套件管理所有批次排程
|
||||
|
||||
排程清單:
|
||||
- 每日 00:00 - auto_terminate_employees (未來實作)
|
||||
- 每日 02:00 - daily_quota_check
|
||||
- 每日 03:00 - sync_keycloak_users
|
||||
- 每月 1 日 01:00 - archive_audit_logs
|
||||
|
||||
啟動方式:
|
||||
python -m app.batch.scheduler
|
||||
"""
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _setup_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
def _run_daily_quota_check():
|
||||
logger.info("觸發: 每日配額檢查批次")
|
||||
try:
|
||||
from app.batch.daily_quota_check import run_daily_quota_check
|
||||
result = run_daily_quota_check()
|
||||
logger.info(f"每日配額檢查批次完成: {result.get('status')}")
|
||||
except Exception as e:
|
||||
logger.error(f"每日配額檢查批次異常: {e}")
|
||||
|
||||
|
||||
def _run_sync_keycloak_users():
|
||||
logger.info("觸發: Keycloak 同步批次")
|
||||
try:
|
||||
from app.batch.sync_keycloak_users import run_sync_keycloak_users
|
||||
result = run_sync_keycloak_users()
|
||||
logger.info(f"Keycloak 同步批次完成: {result.get('status')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Keycloak 同步批次異常: {e}")
|
||||
|
||||
|
||||
def _run_archive_audit_logs():
|
||||
"""只在每月 1 日執行"""
|
||||
if datetime.now().day != 1:
|
||||
return
|
||||
logger.info("觸發: 審計日誌歸檔批次 (每月 1 日)")
|
||||
try:
|
||||
from app.batch.archive_audit_logs import run_archive_audit_logs
|
||||
result = run_archive_audit_logs()
|
||||
logger.info(f"審計日誌歸檔批次完成: {result.get('status')}")
|
||||
except Exception as e:
|
||||
logger.error(f"審計日誌歸檔批次異常: {e}")
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""啟動排程器"""
|
||||
try:
|
||||
import schedule
|
||||
except ImportError:
|
||||
logger.error("缺少 schedule 套件,請執行: pip install schedule")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("=== HR Portal 批次排程器啟動 ===")
|
||||
|
||||
# 每日 02:00 - 配額檢查
|
||||
schedule.every().day.at("02:00").do(_run_daily_quota_check)
|
||||
|
||||
# 每日 03:00 - Keycloak 同步
|
||||
schedule.every().day.at("03:00").do(_run_sync_keycloak_users)
|
||||
|
||||
# 每日 01:00 - 審計日誌歸檔 (函式內部判斷是否為每月 1 日)
|
||||
schedule.every().day.at("01:00").do(_run_archive_audit_logs)
|
||||
|
||||
logger.info("排程設定完成:")
|
||||
logger.info(" 02:00 - 每日配額檢查")
|
||||
logger.info(" 03:00 - Keycloak 同步")
|
||||
logger.info(" 01:00 - 審計日誌歸檔 (每月 1 日)")
|
||||
|
||||
# 處理 SIGTERM (Docker 停止信號)
|
||||
def handle_sigterm(signum, frame):
|
||||
logger.info("收到停止信號,排程器正在關閉...")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||
|
||||
logger.info("排程器運行中,等待任務觸發...")
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(60) # 每分鐘檢查一次
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_setup_logging()
|
||||
start_scheduler()
|
||||
146
backend/app/batch/sync_keycloak_users.py
Normal file
146
backend/app/batch/sync_keycloak_users.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Keycloak 同步批次 (5.2)
|
||||
執行時間: 每日 03:00
|
||||
批次名稱: sync_keycloak_users
|
||||
|
||||
同步 Keycloak 使用者狀態到 HR Portal
|
||||
以 HR Portal 為準 (Single Source of Truth)
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.batch.base import log_batch_execution
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_sync_keycloak_users() -> dict:
|
||||
"""
|
||||
執行 Keycloak 同步批次
|
||||
|
||||
以 HR Portal 員工狀態為準,同步到 Keycloak:
|
||||
- active → Keycloak enabled = True
|
||||
- terminated/on_leave → Keycloak enabled = False
|
||||
|
||||
Returns:
|
||||
執行結果摘要
|
||||
"""
|
||||
started_at = datetime.utcnow()
|
||||
summary = {
|
||||
"total_checked": 0,
|
||||
"synced": 0,
|
||||
"not_found_in_keycloak": 0,
|
||||
"no_keycloak_id": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
issues = []
|
||||
|
||||
logger.info("=== 開始 Keycloak 同步批次 ===")
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.services.keycloak_admin_client import get_keycloak_admin_client
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
# 1. 取得所有員工
|
||||
employees = db.query(Employee).all()
|
||||
keycloak_client = get_keycloak_admin_client()
|
||||
|
||||
logger.info(f"共 {len(employees)} 位員工待檢查")
|
||||
|
||||
for emp in employees:
|
||||
summary["total_checked"] += 1
|
||||
|
||||
# 跳過沒有 Keycloak ID 的員工 (尚未執行到職流程)
|
||||
# 以 username_base 查詢 Keycloak
|
||||
username = emp.username_base
|
||||
if not username:
|
||||
summary["no_keycloak_id"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# 2. 查詢 Keycloak 使用者
|
||||
kc_user = keycloak_client.get_user_by_username(username)
|
||||
|
||||
if not kc_user:
|
||||
# Keycloak 使用者不存在,可能尚未建立
|
||||
summary["not_found_in_keycloak"] += 1
|
||||
logger.debug(f"員工 {emp.employee_id} ({username}) 在 Keycloak 中不存在,跳過")
|
||||
continue
|
||||
|
||||
kc_user_id = kc_user.get("id")
|
||||
kc_enabled = kc_user.get("enabled", False)
|
||||
|
||||
# 3. 判斷應有的 enabled 狀態
|
||||
should_be_enabled = (emp.status == "active")
|
||||
|
||||
# 4. 狀態不一致時,以 HR Portal 為準同步到 Keycloak
|
||||
if kc_enabled != should_be_enabled:
|
||||
success = keycloak_client.update_user(
|
||||
kc_user_id, {"enabled": should_be_enabled}
|
||||
)
|
||||
if success:
|
||||
summary["synced"] += 1
|
||||
logger.info(
|
||||
f"✓ 同步 {emp.employee_id} ({username}): "
|
||||
f"Keycloak enabled {kc_enabled} → {should_be_enabled} "
|
||||
f"(HR 狀態: {emp.status})"
|
||||
)
|
||||
else:
|
||||
summary["errors"] += 1
|
||||
issues.append(f"{emp.employee_id}: 同步失敗")
|
||||
logger.warning(f"✗ 同步 {emp.employee_id} ({username}) 失敗")
|
||||
|
||||
except Exception as e:
|
||||
summary["errors"] += 1
|
||||
issues.append(f"{emp.employee_id}: {str(e)}")
|
||||
logger.error(f"處理員工 {emp.employee_id} 時發生錯誤: {e}")
|
||||
|
||||
# 5. 記錄批次執行日誌
|
||||
finished_at = datetime.utcnow()
|
||||
message = (
|
||||
f"檢查: {summary['total_checked']}, "
|
||||
f"同步: {summary['synced']}, "
|
||||
f"Keycloak 無帳號: {summary['not_found_in_keycloak']}, "
|
||||
f"錯誤: {summary['errors']}"
|
||||
)
|
||||
if issues:
|
||||
message += f"\n問題清單: {'; '.join(issues[:10])}"
|
||||
if len(issues) > 10:
|
||||
message += f" ... 共 {len(issues)} 個問題"
|
||||
|
||||
status = "failed" if summary["errors"] > 0 else "success"
|
||||
log_batch_execution(
|
||||
batch_name="sync_keycloak_users",
|
||||
status=status,
|
||||
message=message,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
|
||||
logger.info(f"=== Keycloak 同步批次完成 === {message}")
|
||||
return {"status": status, "summary": summary}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Keycloak 同步批次失敗: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
log_batch_execution(
|
||||
batch_name="sync_keycloak_users",
|
||||
status="failed",
|
||||
message=error_msg,
|
||||
started_at=started_at,
|
||||
)
|
||||
return {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
result = run_sync_keycloak_users()
|
||||
print(f"執行結果: {result}")
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
136
backend/app/core/audit.py
Normal file
136
backend/app/core/audit.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
審計日誌裝飾器和工具函數
|
||||
"""
|
||||
from functools import wraps
|
||||
from typing import Callable, Optional
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def get_current_username() -> str:
|
||||
"""
|
||||
獲取當前用戶名稱
|
||||
|
||||
TODO: 實作後從 JWT Token 獲取
|
||||
目前返回系統用戶
|
||||
"""
|
||||
# TODO: 從 Keycloak JWT Token 解析用戶名
|
||||
return "system@porscheworld.tw"
|
||||
|
||||
|
||||
def audit_log_decorator(
|
||||
action: str,
|
||||
resource_type: str,
|
||||
get_resource_id: Optional[Callable] = None,
|
||||
get_details: Optional[Callable] = None,
|
||||
):
|
||||
"""
|
||||
審計日誌裝飾器
|
||||
|
||||
使用範例:
|
||||
@audit_log_decorator(
|
||||
action="create",
|
||||
resource_type="employee",
|
||||
get_resource_id=lambda result: result.id,
|
||||
get_details=lambda result: {"employee_id": result.employee_id}
|
||||
)
|
||||
def create_employee(...):
|
||||
pass
|
||||
|
||||
Args:
|
||||
action: 操作類型
|
||||
resource_type: 資源類型
|
||||
get_resource_id: 從返回結果獲取資源 ID 的函數
|
||||
get_details: 從返回結果獲取詳細資訊的函數
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
# 執行原函數
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 獲取 DB Session
|
||||
db: Optional[Session] = kwargs.get("db")
|
||||
if not db:
|
||||
return result
|
||||
|
||||
# 獲取 Request (用於 IP)
|
||||
request: Optional[Request] = kwargs.get("request")
|
||||
ip_address = None
|
||||
if request:
|
||||
from app.services.audit_service import audit_service
|
||||
ip_address = audit_service.get_client_ip(request)
|
||||
|
||||
# 獲取資源 ID
|
||||
resource_id = None
|
||||
if get_resource_id and result:
|
||||
resource_id = get_resource_id(result)
|
||||
|
||||
# 獲取詳細資訊
|
||||
details = None
|
||||
if get_details and result:
|
||||
details = get_details(result)
|
||||
|
||||
# 記錄審計日誌
|
||||
from app.services.audit_service import audit_service
|
||||
audit_service.log(
|
||||
db=db,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=get_current_username(),
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
# 執行原函數
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# 獲取 DB Session
|
||||
db: Optional[Session] = kwargs.get("db")
|
||||
if not db:
|
||||
return result
|
||||
|
||||
# 獲取 Request (用於 IP)
|
||||
request: Optional[Request] = kwargs.get("request")
|
||||
ip_address = None
|
||||
if request:
|
||||
from app.services.audit_service import audit_service
|
||||
ip_address = audit_service.get_client_ip(request)
|
||||
|
||||
# 獲取資源 ID
|
||||
resource_id = None
|
||||
if get_resource_id and result:
|
||||
resource_id = get_resource_id(result)
|
||||
|
||||
# 獲取詳細資訊
|
||||
details = None
|
||||
if get_details and result:
|
||||
details = get_details(result)
|
||||
|
||||
# 記錄審計日誌
|
||||
from app.services.audit_service import audit_service
|
||||
audit_service.log(
|
||||
db=db,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=get_current_username(),
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# 檢查是否為異步函數
|
||||
import asyncio
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
92
backend/app/core/config.py
Normal file
92
backend/app/core/config.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""簡化配置 - 用於測試"""
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 載入 .env 檔案 (必須在讀取環境變數之前)
|
||||
load_dotenv()
|
||||
|
||||
# 直接從環境變數讀取,不依賴 pydantic-settings 的複雜功能
|
||||
class Settings:
|
||||
"""應用配置 (簡化版)"""
|
||||
|
||||
# 基本資訊
|
||||
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "HR Portal API")
|
||||
VERSION: str = os.getenv("VERSION", "2.0.0")
|
||||
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.getenv("PORT", "8000"))
|
||||
|
||||
# 資料庫
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal")
|
||||
DATABASE_ECHO: bool = os.getenv("DATABASE_ECHO", "False").lower() == "true"
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: str = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:10180,http://10.1.0.245:3000,http://10.1.0.245:10180,https://hr.ease.taipei")
|
||||
|
||||
def get_allowed_origins(self):
|
||||
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
|
||||
|
||||
# Keycloak
|
||||
KEYCLOAK_URL: str = os.getenv("KEYCLOAK_URL", "https://auth.ease.taipei")
|
||||
KEYCLOAK_REALM: str = os.getenv("KEYCLOAK_REALM", "porscheworld")
|
||||
KEYCLOAK_CLIENT_ID: str = os.getenv("KEYCLOAK_CLIENT_ID", "hr-backend")
|
||||
KEYCLOAK_CLIENT_SECRET: str = os.getenv("KEYCLOAK_CLIENT_SECRET", "")
|
||||
KEYCLOAK_ADMIN_USERNAME: str = os.getenv("KEYCLOAK_ADMIN_USERNAME", "")
|
||||
KEYCLOAK_ADMIN_PASSWORD: str = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "")
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||
|
||||
# 郵件
|
||||
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "10.1.0.30")
|
||||
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
|
||||
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "True").lower() == "true"
|
||||
MAIL_ADMIN_USER: str = os.getenv("MAIL_ADMIN_USER", "admin@porscheworld.tw")
|
||||
MAIL_ADMIN_PASSWORD: str = os.getenv("MAIL_ADMIN_PASSWORD", "")
|
||||
|
||||
# NAS
|
||||
NAS_HOST: str = os.getenv("NAS_HOST", "10.1.0.30")
|
||||
NAS_PORT: int = int(os.getenv("NAS_PORT", "5000"))
|
||||
NAS_USERNAME: str = os.getenv("NAS_USERNAME", "")
|
||||
NAS_PASSWORD: str = os.getenv("NAS_PASSWORD", "")
|
||||
NAS_WEBDAV_URL: str = os.getenv("NAS_WEBDAV_URL", "https://nas.lab.taipei/webdav")
|
||||
NAS_SMB_SHARE: str = os.getenv("NAS_SMB_SHARE", "Working")
|
||||
|
||||
# 日誌
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
LOG_FILE: str = os.getenv("LOG_FILE", "logs/hr_portal.log")
|
||||
|
||||
# 分頁
|
||||
DEFAULT_PAGE_SIZE: int = int(os.getenv("DEFAULT_PAGE_SIZE", "20"))
|
||||
MAX_PAGE_SIZE: int = int(os.getenv("MAX_PAGE_SIZE", "100"))
|
||||
|
||||
# 郵件配額 (MB)
|
||||
EMAIL_QUOTA_JUNIOR: int = int(os.getenv("EMAIL_QUOTA_JUNIOR", "1000"))
|
||||
EMAIL_QUOTA_MID: int = int(os.getenv("EMAIL_QUOTA_MID", "2000"))
|
||||
EMAIL_QUOTA_SENIOR: int = int(os.getenv("EMAIL_QUOTA_SENIOR", "5000"))
|
||||
EMAIL_QUOTA_MANAGER: int = int(os.getenv("EMAIL_QUOTA_MANAGER", "10000"))
|
||||
|
||||
# NAS 配額 (GB)
|
||||
NAS_QUOTA_JUNIOR: int = int(os.getenv("NAS_QUOTA_JUNIOR", "50"))
|
||||
NAS_QUOTA_MID: int = int(os.getenv("NAS_QUOTA_MID", "100"))
|
||||
NAS_QUOTA_SENIOR: int = int(os.getenv("NAS_QUOTA_SENIOR", "200"))
|
||||
NAS_QUOTA_MANAGER: int = int(os.getenv("NAS_QUOTA_MANAGER", "500"))
|
||||
|
||||
# Drive Service (Nextcloud 微服務)
|
||||
DRIVE_SERVICE_URL: str = os.getenv("DRIVE_SERVICE_URL", "https://drive-api.ease.taipei")
|
||||
DRIVE_SERVICE_TIMEOUT: int = int(os.getenv("DRIVE_SERVICE_TIMEOUT", "10"))
|
||||
DRIVE_SERVICE_TENANT_ID: int = int(os.getenv("DRIVE_SERVICE_TENANT_ID", "1"))
|
||||
|
||||
# Docker Mailserver SSH 整合
|
||||
MAILSERVER_SSH_HOST: str = os.getenv("MAILSERVER_SSH_HOST", "10.1.0.254")
|
||||
MAILSERVER_SSH_PORT: int = int(os.getenv("MAILSERVER_SSH_PORT", "22"))
|
||||
MAILSERVER_SSH_USER: str = os.getenv("MAILSERVER_SSH_USER", "porsche")
|
||||
MAILSERVER_SSH_PASSWORD: str = os.getenv("MAILSERVER_SSH_PASSWORD", "")
|
||||
MAILSERVER_CONTAINER_NAME: str = os.getenv("MAILSERVER_CONTAINER_NAME", "mailserver")
|
||||
MAILSERVER_SSH_TIMEOUT: int = int(os.getenv("MAILSERVER_SSH_TIMEOUT", "30"))
|
||||
|
||||
# 創建實例
|
||||
settings = Settings()
|
||||
87
backend/app/core/config.py.backup
Normal file
87
backend/app/core/config.py.backup
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
應用配置管理
|
||||
使用 Pydantic Settings 管理環境變數
|
||||
"""
|
||||
from typing import List, Union
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""應用配置"""
|
||||
|
||||
# 基本資訊
|
||||
PROJECT_NAME: str = "HR Portal API"
|
||||
VERSION: str = "2.0.0"
|
||||
ENVIRONMENT: str = "development" # development, staging, production
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
# 資料庫配置 (使用 psycopg 驅動)
|
||||
DATABASE_URL: str = "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal"
|
||||
DATABASE_ECHO: bool = False # SQL 查詢日誌
|
||||
|
||||
# CORS 配置 (字串格式,逗號分隔)
|
||||
ALLOWED_ORIGINS: str = "http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei"
|
||||
|
||||
def get_allowed_origins(self) -> List[str]:
|
||||
"""取得 CORS 允許的來源清單"""
|
||||
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
|
||||
|
||||
# Keycloak 配置
|
||||
KEYCLOAK_URL: str = "https://auth.ease.taipei"
|
||||
KEYCLOAK_REALM: str = "porscheworld"
|
||||
KEYCLOAK_CLIENT_ID: str = "hr-backend"
|
||||
KEYCLOAK_CLIENT_SECRET: str = "" # 從環境變數讀取
|
||||
KEYCLOAK_ADMIN_USERNAME: str = ""
|
||||
KEYCLOAK_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# 郵件配置 (Docker Mailserver)
|
||||
MAIL_SERVER: str = "10.1.0.30"
|
||||
MAIL_PORT: int = 587
|
||||
MAIL_USE_TLS: bool = True
|
||||
MAIL_ADMIN_USER: str = "admin@porscheworld.tw"
|
||||
MAIL_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# NAS 配置 (Synology)
|
||||
NAS_HOST: str = "10.1.0.30"
|
||||
NAS_PORT: int = 5000
|
||||
NAS_USERNAME: str = ""
|
||||
NAS_PASSWORD: str = ""
|
||||
NAS_WEBDAV_URL: str = "https://nas.lab.taipei/webdav"
|
||||
NAS_SMB_SHARE: str = "Working"
|
||||
|
||||
# 日誌配置
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/hr_portal.log"
|
||||
|
||||
# 分頁配置
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
# 配額配置 (MB)
|
||||
EMAIL_QUOTA_JUNIOR: int = 1000
|
||||
EMAIL_QUOTA_MID: int = 2000
|
||||
EMAIL_QUOTA_SENIOR: int = 5000
|
||||
EMAIL_QUOTA_MANAGER: int = 10000
|
||||
|
||||
# NAS 配額配置 (GB)
|
||||
NAS_QUOTA_JUNIOR: int = 50
|
||||
NAS_QUOTA_MID: int = 100
|
||||
NAS_QUOTA_SENIOR: int = 200
|
||||
NAS_QUOTA_MANAGER: int = 500
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
)
|
||||
|
||||
|
||||
# 全域配置實例
|
||||
settings = Settings()
|
||||
94
backend/app/core/config.pydantic_backup
Normal file
94
backend/app/core/config.pydantic_backup
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
應用配置管理
|
||||
使用 Pydantic Settings 管理環境變數
|
||||
"""
|
||||
from typing import List, Union
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# 手動載入 .env 檔案 (避免網路磁碟 I/O 延遲問題)
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
||||
if os.path.exists(env_path):
|
||||
load_dotenv(env_path)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""應用配置"""
|
||||
|
||||
# 基本資訊
|
||||
PROJECT_NAME: str = "HR Portal API"
|
||||
VERSION: str = "2.0.0"
|
||||
ENVIRONMENT: str = "development" # development, staging, production
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
# 資料庫配置 (使用 psycopg 驅動)
|
||||
DATABASE_URL: str = "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal"
|
||||
DATABASE_ECHO: bool = False # SQL 查詢日誌
|
||||
|
||||
# CORS 配置 (字串格式,逗號分隔)
|
||||
ALLOWED_ORIGINS: str = "http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei"
|
||||
|
||||
def get_allowed_origins(self) -> List[str]:
|
||||
"""取得 CORS 允許的來源清單"""
|
||||
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
|
||||
|
||||
# Keycloak 配置
|
||||
KEYCLOAK_URL: str = "https://auth.ease.taipei"
|
||||
KEYCLOAK_REALM: str = "porscheworld"
|
||||
KEYCLOAK_CLIENT_ID: str = "hr-backend"
|
||||
KEYCLOAK_CLIENT_SECRET: str = "" # 從環境變數讀取
|
||||
KEYCLOAK_ADMIN_USERNAME: str = ""
|
||||
KEYCLOAK_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# 郵件配置 (Docker Mailserver)
|
||||
MAIL_SERVER: str = "10.1.0.30"
|
||||
MAIL_PORT: int = 587
|
||||
MAIL_USE_TLS: bool = True
|
||||
MAIL_ADMIN_USER: str = "admin@porscheworld.tw"
|
||||
MAIL_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# NAS 配置 (Synology)
|
||||
NAS_HOST: str = "10.1.0.30"
|
||||
NAS_PORT: int = 5000
|
||||
NAS_USERNAME: str = ""
|
||||
NAS_PASSWORD: str = ""
|
||||
NAS_WEBDAV_URL: str = "https://nas.lab.taipei/webdav"
|
||||
NAS_SMB_SHARE: str = "Working"
|
||||
|
||||
# 日誌配置
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/hr_portal.log"
|
||||
|
||||
# 分頁配置
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
# 配額配置 (MB)
|
||||
EMAIL_QUOTA_JUNIOR: int = 1000
|
||||
EMAIL_QUOTA_MID: int = 2000
|
||||
EMAIL_QUOTA_SENIOR: int = 5000
|
||||
EMAIL_QUOTA_MANAGER: int = 10000
|
||||
|
||||
# NAS 配額配置 (GB)
|
||||
NAS_QUOTA_JUNIOR: int = 50
|
||||
NAS_QUOTA_MID: int = 100
|
||||
NAS_QUOTA_SENIOR: int = 200
|
||||
NAS_QUOTA_MANAGER: int = 500
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
# 不使用 pydantic-settings 的 env_file (避免網路磁碟I/O問題)
|
||||
# 改用 python-dotenv 手動載入 (見檔案開頭)
|
||||
case_sensitive=True,
|
||||
)
|
||||
|
||||
|
||||
# 全域配置實例
|
||||
settings = Settings()
|
||||
77
backend/app/core/config_simple.py
Normal file
77
backend/app/core/config_simple.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""簡化配置 - 用於測試"""
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
|
||||
# 直接從環境變數讀取,不依賴 pydantic-settings 的複雜功能
|
||||
class Settings:
|
||||
"""應用配置 (簡化版)"""
|
||||
|
||||
# 基本資訊
|
||||
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "HR Portal API")
|
||||
VERSION: str = os.getenv("VERSION", "2.0.0")
|
||||
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.getenv("PORT", "8000"))
|
||||
|
||||
# 資料庫
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal")
|
||||
DATABASE_ECHO: bool = os.getenv("DATABASE_ECHO", "False").lower() == "true"
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: str = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei")
|
||||
|
||||
def get_allowed_origins(self):
|
||||
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
|
||||
|
||||
# Keycloak
|
||||
KEYCLOAK_URL: str = os.getenv("KEYCLOAK_URL", "https://auth.ease.taipei")
|
||||
KEYCLOAK_REALM: str = os.getenv("KEYCLOAK_REALM", "porscheworld")
|
||||
KEYCLOAK_CLIENT_ID: str = os.getenv("KEYCLOAK_CLIENT_ID", "hr-backend")
|
||||
KEYCLOAK_CLIENT_SECRET: str = os.getenv("KEYCLOAK_CLIENT_SECRET", "")
|
||||
KEYCLOAK_ADMIN_USERNAME: str = os.getenv("KEYCLOAK_ADMIN_USERNAME", "")
|
||||
KEYCLOAK_ADMIN_PASSWORD: str = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "")
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||
|
||||
# 郵件
|
||||
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "10.1.0.30")
|
||||
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
|
||||
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "True").lower() == "true"
|
||||
MAIL_ADMIN_USER: str = os.getenv("MAIL_ADMIN_USER", "admin@porscheworld.tw")
|
||||
MAIL_ADMIN_PASSWORD: str = os.getenv("MAIL_ADMIN_PASSWORD", "")
|
||||
|
||||
# NAS
|
||||
NAS_HOST: str = os.getenv("NAS_HOST", "10.1.0.30")
|
||||
NAS_PORT: int = int(os.getenv("NAS_PORT", "5000"))
|
||||
NAS_USERNAME: str = os.getenv("NAS_USERNAME", "")
|
||||
NAS_PASSWORD: str = os.getenv("NAS_PASSWORD", "")
|
||||
NAS_WEBDAV_URL: str = os.getenv("NAS_WEBDAV_URL", "https://nas.lab.taipei/webdav")
|
||||
NAS_SMB_SHARE: str = os.getenv("NAS_SMB_SHARE", "Working")
|
||||
|
||||
# 日誌
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
LOG_FILE: str = os.getenv("LOG_FILE", "logs/hr_portal.log")
|
||||
|
||||
# 分頁
|
||||
DEFAULT_PAGE_SIZE: int = int(os.getenv("DEFAULT_PAGE_SIZE", "20"))
|
||||
MAX_PAGE_SIZE: int = int(os.getenv("MAX_PAGE_SIZE", "100"))
|
||||
|
||||
# 郵件配額 (MB)
|
||||
EMAIL_QUOTA_JUNIOR: int = int(os.getenv("EMAIL_QUOTA_JUNIOR", "1000"))
|
||||
EMAIL_QUOTA_MID: int = int(os.getenv("EMAIL_QUOTA_MID", "2000"))
|
||||
EMAIL_QUOTA_SENIOR: int = int(os.getenv("EMAIL_QUOTA_SENIOR", "5000"))
|
||||
EMAIL_QUOTA_MANAGER: int = int(os.getenv("EMAIL_QUOTA_MANAGER", "10000"))
|
||||
|
||||
# NAS 配額 (GB)
|
||||
NAS_QUOTA_JUNIOR: int = int(os.getenv("NAS_QUOTA_JUNIOR", "50"))
|
||||
NAS_QUOTA_MID: int = int(os.getenv("NAS_QUOTA_MID", "100"))
|
||||
NAS_QUOTA_SENIOR: int = int(os.getenv("NAS_QUOTA_SENIOR", "200"))
|
||||
NAS_QUOTA_MANAGER: int = int(os.getenv("NAS_QUOTA_MANAGER", "500"))
|
||||
|
||||
# 載入 .env 並創建實例
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
settings = Settings()
|
||||
11
backend/app/core/config_test.py
Normal file
11
backend/app/core/config_test.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""測試配置"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class TestSettings(BaseSettings):
|
||||
PROJECT_NAME: str = "Test"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = TestSettings()
|
||||
print(f"[OK] Settings loaded: {settings.PROJECT_NAME}")
|
||||
54
backend/app/core/logging_config.py
Normal file
54
backend/app/core/logging_config.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
日誌配置
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""設置日誌系統"""
|
||||
# 延遲導入避免循環依賴
|
||||
from app.core.config import settings
|
||||
|
||||
# 創建日誌目錄
|
||||
log_file = Path(settings.LOG_FILE)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 根日誌器
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, settings.LOG_LEVEL))
|
||||
|
||||
# 格式化器
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# JSON 格式化器 (生產環境)
|
||||
json_formatter = jsonlogger.JsonFormatter(
|
||||
"%(asctime)s %(name)s %(levelname)s %(message)s"
|
||||
)
|
||||
|
||||
# 控制台處理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
# 文件處理器
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
|
||||
if settings.ENVIRONMENT == "production":
|
||||
file_handler.setFormatter(json_formatter)
|
||||
else:
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# 添加處理器
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# 設置第三方日誌級別
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
10
backend/app/db/base.py
Normal file
10
backend/app/db/base.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
資料庫 Base 類別
|
||||
所有 SQLAlchemy Model 都繼承自此
|
||||
"""
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# NOTE: 不在這裡匯入 models,避免循環導入
|
||||
# Models 的匯入應該在 alembic/env.py 中處理
|
||||
50
backend/app/db/session.py
Normal file
50
backend/app/db/session.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
資料庫連線管理 (延遲初始化版本)
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from typing import Generator
|
||||
|
||||
# 延遲初始化
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""獲取資料庫引擎 (延遲初始化)"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
from app.core.config import settings
|
||||
_engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DATABASE_ECHO,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_local():
|
||||
"""獲取 Session 工廠 (延遲初始化)"""
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
_SessionLocal = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=get_engine(),
|
||||
)
|
||||
return _SessionLocal
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
取得資料庫 Session
|
||||
用於 FastAPI 依賴注入
|
||||
"""
|
||||
SessionLocal = get_session_local()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
52
backend/app/db/session_old.py
Normal file
52
backend/app/db/session_old.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
資料庫連線管理
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from typing import Generator
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# 延遲初始化避免模組導入時就連接資料庫
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
|
||||
def get_engine():
|
||||
"""獲取資料庫引擎 (延遲初始化)"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DATABASE_ECHO,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
return _engine
|
||||
|
||||
def get_session_local():
|
||||
"""獲取 Session 工廠 (延遲初始化)"""
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
_SessionLocal = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=get_engine(),
|
||||
)
|
||||
return _SessionLocal
|
||||
|
||||
# 向後兼容
|
||||
engine = property(lambda self: get_engine())
|
||||
SessionLocal = property(lambda self: get_session_local())
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
取得資料庫 Session
|
||||
用於 FastAPI 依賴注入
|
||||
"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
105
backend/app/main.py
Normal file
105
backend/app/main.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
HR Portal Backend API
|
||||
FastAPI 主應用程式
|
||||
"""
|
||||
import traceback
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging_config import setup_logging
|
||||
from app.db.session import get_engine
|
||||
from app.db.base import Base
|
||||
|
||||
# 設置日誌
|
||||
setup_logging()
|
||||
|
||||
# 創建 FastAPI 應用
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
description="HR Portal - 人力資源管理系統 API",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
# CORS 設定
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.get_allowed_origins(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# 全局異常處理器
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""全局異常處理器 - 記錄所有未捕獲的異常"""
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f"[ERROR] Unhandled Exception in {request.method} {request.url.path}")
|
||||
print(f"Exception Type: {type(exc).__name__}")
|
||||
print(f"Exception Message: {str(exc)}")
|
||||
print(f"Traceback:")
|
||||
print(traceback.format_exc())
|
||||
print(f"{'=' * 80}\n")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": str(exc),
|
||||
"type": type(exc).__name__,
|
||||
"path": request.url.path
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 啟動事件
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""應用啟動時執行"""
|
||||
# 資料庫表格由 Alembic 管理,不需要在啟動時創建
|
||||
print(f"[OK] {settings.PROJECT_NAME} v{settings.VERSION} started!")
|
||||
print(f"[*] Environment: {settings.ENVIRONMENT}")
|
||||
print(f"[*] API Documentation: http://{settings.HOST}:{settings.PORT}/docs")
|
||||
|
||||
|
||||
# 關閉事件
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""應用關閉時執行"""
|
||||
print(f"[*] {settings.PROJECT_NAME} stopped")
|
||||
|
||||
|
||||
# 健康檢查端點
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
"""健康檢查"""
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "healthy",
|
||||
"service": settings.PROJECT_NAME,
|
||||
"version": settings.VERSION,
|
||||
"environment": settings.ENVIRONMENT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 根路徑
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root():
|
||||
"""根路徑"""
|
||||
return {
|
||||
"message": f"Welcome to {settings.PROJECT_NAME}",
|
||||
"version": settings.VERSION,
|
||||
"docs": "/docs",
|
||||
"redoc": "/redoc",
|
||||
}
|
||||
|
||||
|
||||
# 導入並註冊 API 路由
|
||||
from app.api.v1.router import api_router
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
84
backend/app/models/__init__.py
Normal file
84
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Models 模組
|
||||
匯出所有資料庫模型
|
||||
"""
|
||||
# 多租戶核心模型
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.models.system_function_cache import SystemFunctionCache
|
||||
from app.models.system_function import SystemFunction
|
||||
from app.models.personal_service import PersonalService
|
||||
|
||||
# HR 組織架構
|
||||
from app.models.department import Department
|
||||
from app.models.department_member import DepartmentMember
|
||||
|
||||
# HR 員工模型
|
||||
from app.models.employee import Employee, EmployeeStatus
|
||||
from app.models.emp_resume import EmpResume
|
||||
from app.models.emp_setting import EmpSetting
|
||||
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
|
||||
|
||||
# RBAC 權限系統
|
||||
from app.models.role import UserRole, RoleRight, UserRoleAssignment
|
||||
|
||||
# 其他業務模型
|
||||
from app.models.email_account import EmailAccount
|
||||
from app.models.network_drive import NetworkDrive
|
||||
from app.models.permission import Permission
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.batch_log import BatchLog
|
||||
|
||||
# 初始化系統
|
||||
from app.models.installation import (
|
||||
InstallationSession,
|
||||
InstallationChecklistItem,
|
||||
InstallationChecklistResult,
|
||||
InstallationStep,
|
||||
InstallationLog,
|
||||
InstallationTenantInfo,
|
||||
InstallationDepartmentSetup,
|
||||
TemporaryPassword,
|
||||
InstallationAccessLog,
|
||||
InstallationEnvironmentConfig,
|
||||
InstallationSystemStatus
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 多租戶核心
|
||||
"Tenant",
|
||||
"TenantStatus",
|
||||
"SystemFunctionCache",
|
||||
"SystemFunction",
|
||||
"PersonalService",
|
||||
# 組織架構
|
||||
"Department",
|
||||
"DepartmentMember",
|
||||
# 員工模型
|
||||
"Employee",
|
||||
"EmployeeStatus",
|
||||
"EmpResume",
|
||||
"EmpSetting",
|
||||
"EmpPersonalServiceSetting",
|
||||
# RBAC 權限系統
|
||||
"UserRole",
|
||||
"RoleRight",
|
||||
"UserRoleAssignment",
|
||||
# 其他業務
|
||||
"EmailAccount",
|
||||
"NetworkDrive",
|
||||
"Permission",
|
||||
"AuditLog",
|
||||
"BatchLog",
|
||||
# 初始化系統
|
||||
"InstallationSession",
|
||||
"InstallationChecklistItem",
|
||||
"InstallationChecklistResult",
|
||||
"InstallationStep",
|
||||
"InstallationLog",
|
||||
"InstallationTenantInfo",
|
||||
"InstallationDepartmentSetup",
|
||||
"TemporaryPassword",
|
||||
"InstallationAccessLog",
|
||||
"InstallationEnvironmentConfig",
|
||||
"InstallationSystemStatus",
|
||||
]
|
||||
64
backend/app/models/audit_log.py
Normal file
64
backend/app/models/audit_log.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
審計日誌 Model
|
||||
記錄所有關鍵操作,符合 ISO 要求
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, JSON, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""審計日誌表"""
|
||||
|
||||
__tablename__ = "tenant_audit_logs"
|
||||
__table_args__ = (
|
||||
Index("idx_audit_tenant_action", "tenant_id", "action"),
|
||||
Index("idx_audit_tenant_resource", "tenant_id", "resource_type", "resource_id"),
|
||||
Index("idx_audit_tenant_time", "tenant_id", "performed_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
action = Column(String(50), nullable=False, index=True, comment="操作類型 (create/update/delete/login)")
|
||||
resource_type = Column(String(50), nullable=False, index=True, comment="資源類型 (employee/department/role)")
|
||||
resource_id = Column(Integer, nullable=True, index=True, comment="資源 ID")
|
||||
performed_by = Column(String(100), nullable=False, index=True, comment="操作者 SSO 帳號")
|
||||
performed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True, comment="操作時間")
|
||||
details = Column(JSONB, nullable=True, comment="詳細變更內容 (JSON)")
|
||||
ip_address = Column(String(45), nullable=True, comment="IP 位址 (IPv4/IPv6)")
|
||||
|
||||
# 通用欄位 (Note: audit_logs 不需要 is_active,只記錄不修改)
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog {self.action} {self.resource_type}:{self.resource_id} by {self.performed_by}>"
|
||||
|
||||
@classmethod
|
||||
def create_log(
|
||||
cls,
|
||||
tenant_id: int,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
performed_by: str,
|
||||
resource_id: int = None,
|
||||
details: dict = None,
|
||||
ip_address: str = None,
|
||||
) -> "AuditLog":
|
||||
"""創建審計日誌"""
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
31
backend/app/models/batch_log.py
Normal file
31
backend/app/models/batch_log.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
批次執行日誌 Model
|
||||
記錄所有批次作業的執行結果
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class BatchLog(Base):
|
||||
"""批次執行日誌表"""
|
||||
|
||||
__tablename__ = "tenant_batch_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
batch_name = Column(String(100), nullable=False, index=True, comment="批次名稱")
|
||||
status = Column(String(20), nullable=False, comment="執行狀態: success/failed/warning")
|
||||
message = Column(Text, comment="執行訊息或錯誤詳情")
|
||||
started_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True, comment="開始時間")
|
||||
finished_at = Column(DateTime, comment="完成時間")
|
||||
duration_seconds = Column(Integer, comment="執行時間 (秒)")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BatchLog {self.batch_name} [{self.status}] @ {self.started_at}>"
|
||||
49
backend/app/models/business_unit.py.deprecated
Normal file
49
backend/app/models/business_unit.py.deprecated
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
事業部 Model
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class BusinessUnit(Base):
|
||||
"""事業部表"""
|
||||
|
||||
__tablename__ = "business_units"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_tenant_bu_code"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
name = Column(String(100), nullable=False, comment="事業部名稱")
|
||||
name_en = Column(String(100), comment="英文名稱")
|
||||
code = Column(String(20), nullable=False, index=True, comment="事業部代碼 (BD, TD, OM, 租戶內唯一)")
|
||||
email_domain = Column(String(100), unique=True, nullable=False, comment="郵件網域 (ease.taipei, lab.taipei, porscheworld.tw)")
|
||||
description = Column(Text, comment="說明")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Phase 2.2 新增欄位
|
||||
primary_domain = Column(String(100), comment="主要網域 (與 email_domain 相同)")
|
||||
email_address = Column(String(255), comment="事業部信箱 (例如: business@ease.taipei)")
|
||||
email_quota_mb = Column(Integer, default=10240, nullable=False, comment="事業部信箱配額 (MB)")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="business_units")
|
||||
# departments relationship 已移除 (business_unit_id FK 已從 departments 表刪除於 migration 0005)
|
||||
employee_identities = relationship(
|
||||
"EmployeeIdentity",
|
||||
back_populates="business_unit",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BusinessUnit {self.code} - {self.name}>"
|
||||
|
||||
@property
|
||||
def sso_domain(self) -> str:
|
||||
"""SSO 帳號網域"""
|
||||
return self.email_domain
|
||||
74
backend/app/models/department.py
Normal file
74
backend/app/models/department.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
部門 Model
|
||||
統一樹狀部門結構:
|
||||
- depth=0, parent_id=NULL: 第一層部門 (原事業部),可設定 email_domain
|
||||
- depth=1+: 子部門,繼承上層 email_domain
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Department(Base):
|
||||
"""部門表 (統一樹狀結構)"""
|
||||
|
||||
__tablename__ = "tenant_departments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_dept_seq"),
|
||||
UniqueConstraint("tenant_id", "parent_id", "code", name="uq_tenant_parent_dept_code"),
|
||||
Index("idx_dept_tenant_id", "tenant_id"),
|
||||
Index("idx_departments_parent", "parent_id"),
|
||||
Index("idx_departments_depth", "depth"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
|
||||
parent_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="CASCADE"), nullable=True,
|
||||
comment="上層部門 ID (NULL=第一層,即原事業部)")
|
||||
code = Column(String(20), nullable=False, comment="部門代碼 (同層內唯一)")
|
||||
name = Column(String(100), nullable=False, comment="部門名稱")
|
||||
name_en = Column(String(100), nullable=True, comment="英文名稱")
|
||||
email_domain = Column(String(100), nullable=True,
|
||||
comment="郵件網域 (只有 depth=0 可設定,例如 ease.taipei)")
|
||||
email_address = Column(String(255), nullable=True, comment="部門信箱 (例如: wind@ease.taipei)")
|
||||
email_quota_mb = Column(Integer, default=5120, nullable=False, comment="部門信箱配額 (MB)")
|
||||
depth = Column(Integer, default=0, nullable=False, comment="層次深度 (0=第一層,1=第二層,以此類推)")
|
||||
description = Column(Text, comment="說明")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="departments")
|
||||
parent = relationship("Department", back_populates="children", remote_side="Department.id")
|
||||
children = relationship("Department", back_populates="parent", cascade="all, delete-orphan")
|
||||
members = relationship(
|
||||
"DepartmentMember",
|
||||
back_populates="department",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Department depth={self.depth} code={self.code} name={self.name}>"
|
||||
|
||||
@property
|
||||
def effective_email_domain(self) -> str | None:
|
||||
"""有效郵件網域 (第一層自身設定,子層追溯上層)"""
|
||||
if self.depth == 0:
|
||||
return self.email_domain
|
||||
if self.parent:
|
||||
return self.parent.effective_email_domain
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_top_level(self) -> bool:
|
||||
"""是否為第一層部門 (原事業部)"""
|
||||
return self.depth == 0 and self.parent_id is None
|
||||
53
backend/app/models/department_member.py
Normal file
53
backend/app/models/department_member.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
部門成員 Model
|
||||
記錄員工與部門的多對多關係 (純粹組織歸屬,與 RBAC 權限無關)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class DepartmentMember(Base):
|
||||
"""部門成員表"""
|
||||
|
||||
__tablename__ = "tenant_dept_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "department_id", name="uq_employee_department"),
|
||||
Index("idx_dept_members_tenant", "tenant_id"),
|
||||
Index("idx_dept_members_employee", "employee_id"),
|
||||
Index("idx_dept_members_department", "department_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
employee_id = Column(Integer, ForeignKey("tenant_employees.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="員工 ID")
|
||||
department_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="部門 ID")
|
||||
position = Column(String(100), nullable=True, comment="在該部門的職稱")
|
||||
membership_type = Column(String(50), default="permanent", nullable=False,
|
||||
comment="成員類型: permanent/temporary/project")
|
||||
|
||||
# 時間記錄(審計追蹤)
|
||||
joined_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="加入時間")
|
||||
ended_at = Column(DateTime, nullable=True, comment="離開時間(軟刪除)")
|
||||
|
||||
# 審計欄位
|
||||
assigned_by = Column(String(36), nullable=True, comment="分配者 keycloak_user_id")
|
||||
removed_by = Column(String(36), nullable=True, comment="移除者 keycloak_user_id")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
employee = relationship("Employee", back_populates="department_memberships")
|
||||
department = relationship("Department", back_populates="members")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DepartmentMember employee_id={self.employee_id} department_id={self.department_id}>"
|
||||
123
backend/app/models/email_account.py
Normal file
123
backend/app/models/email_account.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
郵件帳號 Model
|
||||
支援員工在不同網域擁有多個郵件帳號,並管理配額
|
||||
符合設計文件規範: HR Portal設計文件.md
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmailAccount(Base):
|
||||
"""郵件帳號表 (一個員工可以有多個郵件帳號)"""
|
||||
|
||||
__tablename__ = "tenant_email_accounts"
|
||||
__table_args__ = (
|
||||
# 郵件地址必須唯一
|
||||
Index("idx_email_accounts_email", "email_address", unique=True),
|
||||
# 員工索引
|
||||
Index("idx_email_accounts_employee", "employee_id"),
|
||||
# 租戶索引
|
||||
Index("idx_email_accounts_tenant", "tenant_id"),
|
||||
# 狀態索引 (快速查詢啟用的帳號)
|
||||
Index("idx_email_accounts_active", "is_active"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="租戶 ID"
|
||||
)
|
||||
|
||||
# 支援個人/部門信箱
|
||||
account_type = Column(
|
||||
String(20),
|
||||
default='personal',
|
||||
nullable=False,
|
||||
comment="帳號類型: personal(個人), department(部門)"
|
||||
)
|
||||
employee_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_employees.id", ondelete="CASCADE"),
|
||||
nullable=True, # 部門信箱不需要 employee_id
|
||||
index=True,
|
||||
comment="員工 ID (僅 personal 類型需要)"
|
||||
)
|
||||
department_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_departments.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="部門 ID (僅 department 類型需要)"
|
||||
)
|
||||
|
||||
# 郵件設定
|
||||
email_address = Column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="郵件地址 (例如: porsche.chen@lab.taipei)"
|
||||
)
|
||||
quota_mb = Column(
|
||||
Integer,
|
||||
default=2048,
|
||||
nullable=False,
|
||||
comment="配額 (MB),依職級: Junior=2048, Mid=3072, Senior=5120, Manager=10240"
|
||||
)
|
||||
|
||||
# 進階功能
|
||||
forward_to = Column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="轉寄地址 (可選,例如外部郵箱)"
|
||||
)
|
||||
auto_reply = Column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="自動回覆內容 (可選,例如休假通知)"
|
||||
)
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
employee = relationship("Employee", back_populates="email_accounts")
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmailAccount {self.email_address} (配額:{self.quota_mb}MB)>"
|
||||
|
||||
@property
|
||||
def local_part(self) -> str:
|
||||
"""郵件前綴 (@ 之前的部分)"""
|
||||
return self.email_address.split('@')[0] if '@' in self.email_address else self.email_address
|
||||
|
||||
@property
|
||||
def domain_part(self) -> str:
|
||||
"""網域部分 (@ 之後的部分)"""
|
||||
return self.email_address.split('@')[1] if '@' in self.email_address else ""
|
||||
|
||||
@property
|
||||
def quota_gb(self) -> float:
|
||||
"""配額 (GB,用於顯示)"""
|
||||
return round(self.quota_mb / 1024, 2)
|
||||
|
||||
@classmethod
|
||||
def get_default_quota_by_level(cls, job_level: str) -> int:
|
||||
"""根據職級取得預設配額 (MB)"""
|
||||
quota_map = {
|
||||
"Junior": 2048,
|
||||
"Mid": 3072,
|
||||
"Senior": 5120,
|
||||
"Manager": 10240,
|
||||
}
|
||||
return quota_map.get(job_level, 2048)
|
||||
50
backend/app/models/emp_personal_service_setting.py
Normal file
50
backend/app/models/emp_personal_service_setting.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
員工個人化服務設定 Model
|
||||
記錄員工啟用的個人化服務(SSO, Email, Calendar, Drive, Office)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmpPersonalServiceSetting(Base):
|
||||
"""員工個人化服務設定表"""
|
||||
|
||||
__tablename__ = "tenant_emp_personal_service_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "keycloak_user_id", "service_id", name="uq_emp_service"),
|
||||
Index("idx_emp_service_tenant", "tenant_id"),
|
||||
Index("idx_emp_service_user", "keycloak_user_id"),
|
||||
Index("idx_emp_service_service", "service_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
keycloak_user_id = Column(String(36), nullable=False, comment="Keycloak User UUID")
|
||||
service_id = Column(Integer, ForeignKey("personal_services.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="個人化服務 ID")
|
||||
|
||||
# 服務配額設定(依服務類型不同)
|
||||
quota_gb = Column(Integer, nullable=True, comment="儲存配額 (GB),適用於 Drive")
|
||||
quota_mb = Column(Integer, nullable=True, comment="郵件配額 (MB),適用於 Email")
|
||||
|
||||
# 審計欄位(完整記錄)
|
||||
enabled_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="啟用時間")
|
||||
enabled_by = Column(String(36), nullable=True, comment="啟用者 keycloak_user_id")
|
||||
disabled_at = Column(DateTime, nullable=True, comment="停用時間(軟刪除)")
|
||||
disabled_by = Column(String(36), nullable=True, comment="停用者 keycloak_user_id")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
service = relationship("PersonalService")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmpPersonalServiceSetting user={self.keycloak_user_id} service={self.service_id}>"
|
||||
69
backend/app/models/emp_resume.py
Normal file
69
backend/app/models/emp_resume.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
員工履歷資料 Model (人員基本檔)
|
||||
記錄員工的個人資料、教育背景等(與任用無關的基本資料)
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmpResume(Base):
|
||||
"""員工履歷表(人員基本檔)"""
|
||||
|
||||
__tablename__ = "tenant_emp_resumes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_resume_seq"),
|
||||
UniqueConstraint("tenant_id", "id_number", name="uq_tenant_id_number"),
|
||||
Index("idx_emp_resume_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
|
||||
|
||||
# 個人基本資料
|
||||
legal_name = Column(String(100), nullable=False, comment="法定姓名")
|
||||
english_name = Column(String(100), nullable=True, comment="英文名稱")
|
||||
id_number = Column(String(20), nullable=False, comment="身分證字號/護照號碼")
|
||||
birth_date = Column(Date, nullable=True, comment="出生日期")
|
||||
gender = Column(String(10), nullable=True, comment="性別: M/F/Other")
|
||||
marital_status = Column(String(20), nullable=True, comment="婚姻狀況: single/married/divorced/widowed")
|
||||
nationality = Column(String(50), nullable=True, comment="國籍")
|
||||
|
||||
# 聯絡資訊
|
||||
phone = Column(String(20), nullable=True, comment="聯絡電話")
|
||||
mobile = Column(String(20), nullable=True, comment="手機")
|
||||
personal_email = Column(String(255), nullable=True, comment="個人郵箱")
|
||||
address = Column(Text, nullable=True, comment="通訊地址")
|
||||
emergency_contact = Column(String(100), nullable=True, comment="緊急聯絡人")
|
||||
emergency_phone = Column(String(20), nullable=True, comment="緊急聯絡電話")
|
||||
|
||||
# 教育背景
|
||||
education_level = Column(String(50), nullable=True, comment="學歷: high_school/bachelor/master/phd")
|
||||
school_name = Column(String(200), nullable=True, comment="畢業學校")
|
||||
major = Column(String(100), nullable=True, comment="主修科系")
|
||||
graduation_year = Column(Integer, nullable=True, comment="畢業年份")
|
||||
|
||||
# 備註
|
||||
notes = Column(Text, nullable=True, comment="備註")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
employment_setting = relationship(
|
||||
"EmpSetting",
|
||||
back_populates="resume",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmpResume {self.legal_name} ({self.id_number})>"
|
||||
86
backend/app/models/emp_setting.py
Normal file
86
backend/app/models/emp_setting.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
員工任用設定 Model (員工任用資料檔)
|
||||
記錄員工的任用資訊、職務、薪資等(與組織任用相關的資料)
|
||||
使用複合主鍵 (tenant_id, seq_no)
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmpSetting(Base):
|
||||
"""員工任用設定表(複合主鍵)"""
|
||||
|
||||
__tablename__ = "tenant_emp_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "tenant_resume_id", name="uq_tenant_resume_setting"),
|
||||
UniqueConstraint("tenant_id", "tenant_emp_code", name="uq_tenant_emp_code"),
|
||||
Index("idx_emp_setting_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
# 複合主鍵
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, primary_key=True, comment="租戶內序號 (觸發器自動生成)")
|
||||
|
||||
# 關聯人員基本檔
|
||||
tenant_resume_id = Column(Integer, ForeignKey("tenant_emp_resumes.id", ondelete="RESTRICT"), nullable=False,
|
||||
comment="人員基本檔 ID(一個人只有一筆任用設定)")
|
||||
|
||||
# 員工編號(自動生成)
|
||||
tenant_emp_code = Column(String(20), nullable=False, index=True,
|
||||
comment="員工編號(自動生成,格式: prefix + seq_no,例如 PWD0001)")
|
||||
|
||||
# SSO 整合
|
||||
tenant_keycloak_user_id = Column(String(36), unique=True, nullable=True, index=True,
|
||||
comment="Keycloak User UUID (唯一 SSO 識別碼,永久不變)")
|
||||
tenant_keycloak_username = Column(String(100), unique=True, nullable=True,
|
||||
comment="Keycloak 登入帳號")
|
||||
|
||||
# 任用資訊
|
||||
hire_at = Column(Date, nullable=False, comment="到職日期")
|
||||
resign_date = Column(Date, nullable=True, comment="離職日期")
|
||||
job_title = Column(String(100), nullable=True, comment="職稱")
|
||||
employment_type = Column(String(50), nullable=False, default="full_time",
|
||||
comment="任用類型: full_time/part_time/contractor/intern")
|
||||
|
||||
# 薪資資訊(加密儲存)
|
||||
salary_amount = Column(Integer, nullable=True, comment="月薪(加密)")
|
||||
salary_currency = Column(String(10), default="TWD", comment="薪資幣別")
|
||||
|
||||
# 主要部門(員工可屬於多個部門,但有一個主要部門)
|
||||
primary_dept_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="SET NULL"), nullable=True,
|
||||
comment="主要部門 ID")
|
||||
|
||||
# 個人化服務配額設定
|
||||
storage_quota_gb = Column(Integer, default=20, nullable=False, comment="儲存配額 (GB) - Drive 使用")
|
||||
email_quota_mb = Column(Integer, default=5120, nullable=False, comment="郵件配額 (MB) - Email 使用")
|
||||
|
||||
# 狀態
|
||||
employment_status = Column(String(20), default="active", nullable=False,
|
||||
comment="任用狀態: active/on_leave/resigned/terminated")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
resume = relationship("EmpResume", back_populates="employment_setting")
|
||||
primary_department = relationship("Department", foreign_keys=[primary_dept_id])
|
||||
|
||||
# 關聯:部門歸屬(多對多)- 透過 resume 的 employee 關聯
|
||||
# department_memberships 在 Employee Model 中定義
|
||||
|
||||
# 關聯:角色分配(多對多)- 透過 keycloak_user_id 查詢
|
||||
# user_role_assignments 在 UserRoleAssignment Model 中定義
|
||||
|
||||
# 關聯:個人化服務設定(多對多)- 透過 keycloak_user_id 查詢
|
||||
# personal_service_settings 在 EmpPersonalServiceSetting Model 中定義
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmpSetting {self.tenant_emp_code} (tenant_id={self.tenant_id}, seq_no={self.seq_no})>"
|
||||
85
backend/app/models/employee.py
Normal file
85
backend/app/models/employee.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
員工基本資料 Model
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Enum, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmployeeStatus(str, enum.Enum):
|
||||
"""員工狀態"""
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
TERMINATED = "terminated"
|
||||
|
||||
|
||||
class Employee(Base):
|
||||
"""員工基本資料表"""
|
||||
|
||||
__tablename__ = "tenant_employees"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", name="uq_tenant_employee_id"),
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_seq_no"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (自動從1開始)")
|
||||
employee_id = Column(String(20), nullable=False, index=True, comment="員工編號 (EMP001, 租戶內唯一,永久不變)")
|
||||
keycloak_user_id = Column(String(36), unique=True, nullable=True, index=True,
|
||||
comment="Keycloak User UUID (唯一 SSO 識別碼,永久不變,一個員工只有一個)")
|
||||
username_base = Column(String(50), unique=True, nullable=False, index=True, comment="基礎帳號名稱 (全系統唯一)")
|
||||
legal_name = Column(String(100), nullable=False, comment="法定姓名")
|
||||
english_name = Column(String(100), comment="英文名稱")
|
||||
phone = Column(String(20), comment="電話")
|
||||
mobile = Column(String(20), comment="手機")
|
||||
hire_date = Column(Date, nullable=False, comment="到職日期")
|
||||
status = Column(
|
||||
String(20),
|
||||
default=EmployeeStatus.ACTIVE,
|
||||
nullable=False,
|
||||
comment="狀態 (active/inactive/terminated)"
|
||||
)
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="employees")
|
||||
department_memberships = relationship(
|
||||
"DepartmentMember",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
email_accounts = relationship(
|
||||
"EmailAccount",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
permissions = relationship(
|
||||
"Permission",
|
||||
foreign_keys="Permission.employee_id",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
network_drive = relationship(
|
||||
"NetworkDrive",
|
||||
back_populates="employee",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Employee {self.employee_id} - {self.legal_name}>"
|
||||
|
||||
# is_active 已改為資料庫欄位,移除 @property
|
||||
66
backend/app/models/employee_identity.py.deprecated
Normal file
66
backend/app/models/employee_identity.py.deprecated
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
員工身份 Model
|
||||
一個員工可以在多個事業部任職,每個事業部對應一個身份
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmployeeIdentity(Base):
|
||||
"""員工身份表"""
|
||||
|
||||
__tablename__ = "employee_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", "business_unit_id", name="uq_tenant_emp_bu"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# SSO 帳號 (= 郵件地址)
|
||||
username = Column(String(100), unique=True, nullable=False, index=True, comment="SSO 帳號 (porsche.chen@lab.taipei)")
|
||||
keycloak_id = Column(String(100), unique=True, nullable=False, index=True, comment="Keycloak UUID")
|
||||
|
||||
# 組織與職務
|
||||
business_unit_id = Column(Integer, ForeignKey("business_units.id"), nullable=False, index=True)
|
||||
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True, index=True)
|
||||
job_title = Column(String(100), nullable=False, comment="職稱")
|
||||
job_level = Column(String(20), nullable=False, comment="職級 (Junior/Mid/Senior/Manager)")
|
||||
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要身份")
|
||||
|
||||
# 郵件配額
|
||||
email_quota_mb = Column(Integer, nullable=False, comment="郵件配額 (MB)")
|
||||
|
||||
# 時間記錄
|
||||
started_at = Column(Date, nullable=False, comment="開始日期")
|
||||
ended_at = Column(Date, nullable=True, comment="結束日期")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
employee = relationship("Employee", back_populates="identities")
|
||||
business_unit = relationship("BusinessUnit", back_populates="employee_identities")
|
||||
department = relationship("Department") # back_populates 已移除 (employee_identities 廢棄)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmployeeIdentity {self.username}>"
|
||||
|
||||
@property
|
||||
def email(self) -> str:
|
||||
"""郵件地址 (= SSO 帳號)"""
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def is_cross_department(self) -> bool:
|
||||
"""是否跨部門任職 (檢查同一員工是否有其他身份)"""
|
||||
return len(self.employee.identities) > 1
|
||||
|
||||
def generate_username(self, username_base: str, email_domain: str) -> str:
|
||||
"""生成 SSO 帳號"""
|
||||
return f"{username_base}@{email_domain}"
|
||||
362
backend/app/models/installation.py
Normal file
362
backend/app/models/installation.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
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)
|
||||
107
backend/app/models/invoice.py
Normal file
107
backend/app/models/invoice.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
發票記錄 Model
|
||||
管理租戶的帳單和發票
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class InvoiceStatus(str, enum.Enum):
|
||||
"""發票狀態"""
|
||||
PENDING = "pending" # 待付款
|
||||
PAID = "paid" # 已付款
|
||||
OVERDUE = "overdue" # 逾期未付
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
|
||||
|
||||
class Invoice(Base):
|
||||
"""發票記錄表"""
|
||||
|
||||
__tablename__ = "invoices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 發票資訊
|
||||
invoice_number = Column(String(50), unique=True, nullable=False, index=True, comment="發票號碼 (INV-2026-03-001)")
|
||||
issue_date = Column(Date, nullable=False, comment="開立日期")
|
||||
due_date = Column(Date, nullable=False, comment="到期日")
|
||||
|
||||
# 金額
|
||||
amount = Column(Numeric(10, 2), nullable=False, comment="金額 (未稅)")
|
||||
tax = Column(Numeric(10, 2), default=0, nullable=False, comment="稅額")
|
||||
total = Column(Numeric(10, 2), nullable=False, comment="總計 (含稅)")
|
||||
|
||||
# 狀態
|
||||
status = Column(String(20), default=InvoiceStatus.PENDING, nullable=False, comment="狀態")
|
||||
|
||||
# 付款資訊
|
||||
paid_at = Column(DateTime, nullable=True, comment="付款時間")
|
||||
payment_method = Column(String(20), nullable=True, comment="付款方式 (credit_card/wire_transfer)")
|
||||
|
||||
# 發票明細 (JSON 格式)
|
||||
line_items = Column(JSONB, nullable=True, comment="發票明細")
|
||||
# 範例:
|
||||
# [
|
||||
# {"description": "標準方案 (20 人)", "quantity": 1, "unit_price": 10000, "amount": 10000},
|
||||
# {"description": "超額用戶 (2 人)", "quantity": 2, "unit_price": 500, "amount": 1000}
|
||||
# ]
|
||||
|
||||
# PDF 檔案
|
||||
pdf_path = Column(String(200), nullable=True, comment="發票 PDF 路徑")
|
||||
|
||||
# 備註
|
||||
notes = Column(Text, nullable=True, comment="備註")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
payments = relationship(
|
||||
"Payment",
|
||||
back_populates="invoice",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Invoice {self.invoice_number} - NT$ {self.total} ({self.status})>"
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
"""是否已付款"""
|
||||
return self.status == InvoiceStatus.PAID
|
||||
|
||||
@property
|
||||
def is_overdue(self) -> bool:
|
||||
"""是否逾期"""
|
||||
return (
|
||||
self.status in [InvoiceStatus.PENDING, InvoiceStatus.OVERDUE] and
|
||||
date.today() > self.due_date
|
||||
)
|
||||
|
||||
@property
|
||||
def days_overdue(self) -> int:
|
||||
"""逾期天數"""
|
||||
if not self.is_overdue:
|
||||
return 0
|
||||
return (date.today() - self.due_date).days
|
||||
|
||||
def mark_as_paid(self, payment_method: str = None):
|
||||
"""標記為已付款"""
|
||||
self.status = InvoiceStatus.PAID
|
||||
self.paid_at = datetime.utcnow()
|
||||
if payment_method:
|
||||
self.payment_method = payment_method
|
||||
|
||||
@classmethod
|
||||
def generate_invoice_number(cls, year: int, month: int, sequence: int) -> str:
|
||||
"""生成發票號碼"""
|
||||
return f"INV-{year:04d}-{month:02d}-{sequence:03d}"
|
||||
68
backend/app/models/network_drive.py
Normal file
68
backend/app/models/network_drive.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
網路硬碟 Model
|
||||
一個員工對應一個 NAS 帳號
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class NetworkDrive(Base):
|
||||
"""網路硬碟表"""
|
||||
|
||||
__tablename__ = "tenant_network_drives"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", name="uq_network_drive_employee"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
employee_id = Column(Integer, ForeignKey("tenant_employees.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||
|
||||
# 一個員工只有一個 NAS 帳號
|
||||
drive_name = Column(String(100), unique=True, nullable=False, comment="NAS 帳號名稱 (與 username_base 相同)")
|
||||
quota_gb = Column(Integer, nullable=False, comment="配額 (GB),取所有身份中的最高職級")
|
||||
|
||||
# 訪問路徑
|
||||
webdav_url = Column(String(255), comment="WebDAV 路徑")
|
||||
smb_url = Column(String(255), comment="SMB 路徑")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
employee = relationship("Employee", back_populates="network_drive")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDrive {self.drive_name} - {self.quota_gb}GB>"
|
||||
|
||||
@property
|
||||
def webdav_path(self) -> str:
|
||||
"""WebDAV 完整路徑"""
|
||||
return self.webdav_url or f"https://nas.lab.taipei/webdav/{self.drive_name}"
|
||||
|
||||
@property
|
||||
def smb_path(self) -> str:
|
||||
"""SMB 完整路徑"""
|
||||
return self.smb_url or f"\\\\10.1.0.30\\{self.drive_name}"
|
||||
|
||||
def update_quota_from_job_level(self, job_level: str) -> None:
|
||||
"""根據職級更新配額"""
|
||||
from app.core.config import settings
|
||||
|
||||
quota_mapping = {
|
||||
"Junior": settings.NAS_QUOTA_JUNIOR,
|
||||
"Mid": settings.NAS_QUOTA_MID,
|
||||
"Senior": settings.NAS_QUOTA_SENIOR,
|
||||
"Manager": settings.NAS_QUOTA_MANAGER,
|
||||
}
|
||||
|
||||
new_quota = quota_mapping.get(job_level, settings.NAS_QUOTA_JUNIOR)
|
||||
|
||||
# 只在配額增加時更新 (不降低配額)
|
||||
if new_quota > self.quota_gb:
|
||||
self.quota_gb = new_quota
|
||||
51
backend/app/models/payment.py
Normal file
51
backend/app/models/payment.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
付款記錄 Model
|
||||
記錄所有付款交易
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PaymentStatus(str, enum.Enum):
|
||||
"""付款狀態"""
|
||||
SUCCESS = "success" # 成功
|
||||
FAILED = "failed" # 失敗
|
||||
PENDING = "pending" # 處理中
|
||||
REFUNDED = "refunded" # 已退款
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
"""付款記錄表"""
|
||||
|
||||
__tablename__ = "payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
invoice_id = Column(Integer, ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 付款資訊
|
||||
amount = Column(Numeric(10, 2), nullable=False, comment="付款金額")
|
||||
payment_method = Column(String(20), nullable=False, comment="付款方式 (credit_card/wire_transfer/cash)")
|
||||
transaction_id = Column(String(100), nullable=True, comment="金流交易編號")
|
||||
status = Column(String(20), default=PaymentStatus.PENDING, nullable=False, comment="狀態")
|
||||
|
||||
# 時間記錄
|
||||
paid_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="付款時間")
|
||||
|
||||
# 備註
|
||||
notes = Column(Text, nullable=True, comment="備註")
|
||||
|
||||
# 關聯
|
||||
invoice = relationship("Invoice", back_populates="payments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Payment NT$ {self.amount} - {self.status}>"
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
"""是否付款成功"""
|
||||
return self.status == PaymentStatus.SUCCESS
|
||||
112
backend/app/models/permission.py
Normal file
112
backend/app/models/permission.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
系統權限 Model
|
||||
管理員工在各系統的存取權限 (Gitea, Portainer, etc.)
|
||||
符合設計文件規範: HR Portal設計文件.md
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""系統權限表"""
|
||||
|
||||
__tablename__ = "tenant_permissions"
|
||||
__table_args__ = (
|
||||
# 同一員工在同一系統只能有一個權限記錄
|
||||
UniqueConstraint("employee_id", "system_name", name="uq_employee_system"),
|
||||
# 索引
|
||||
Index("idx_permissions_employee", "employee_id"),
|
||||
Index("idx_permissions_tenant", "tenant_id"),
|
||||
Index("idx_permissions_system", "system_name"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="租戶 ID"
|
||||
)
|
||||
employee_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_employees.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="員工 ID"
|
||||
)
|
||||
|
||||
# 權限設定
|
||||
system_name = Column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="系統名稱 (gitea, portainer, traefik, keycloak)"
|
||||
)
|
||||
access_level = Column(
|
||||
String(50),
|
||||
default='user',
|
||||
nullable=False,
|
||||
comment="存取層級 (admin/user/readonly)"
|
||||
)
|
||||
|
||||
# 授予資訊
|
||||
granted_at = Column(
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
comment="授予時間"
|
||||
)
|
||||
granted_by = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_employees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="授予人 (員工 ID)"
|
||||
)
|
||||
|
||||
# 通用欄位 (Note: Permission 表不需要 is_active,依靠 granted_at 判斷)
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
employee = relationship(
|
||||
"Employee",
|
||||
foreign_keys=[employee_id],
|
||||
back_populates="permissions"
|
||||
)
|
||||
granted_by_employee = relationship(
|
||||
"Employee",
|
||||
foreign_keys=[granted_by]
|
||||
)
|
||||
granter = relationship(
|
||||
"Employee",
|
||||
foreign_keys=[granted_by],
|
||||
viewonly=True,
|
||||
)
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission {self.system_name}:{self.access_level}>"
|
||||
|
||||
@classmethod
|
||||
def get_available_systems(cls) -> list[str]:
|
||||
"""取得可用的系統清單"""
|
||||
return [
|
||||
"gitea", # Git 代碼託管
|
||||
"portainer", # 容器管理
|
||||
"traefik", # 反向代理管理
|
||||
"keycloak", # SSO 管理
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_available_access_levels(cls) -> list[str]:
|
||||
"""取得可用的存取層級"""
|
||||
return [
|
||||
"admin", # 管理員 (完整控制)
|
||||
"user", # 一般使用者
|
||||
"readonly", # 唯讀
|
||||
]
|
||||
31
backend/app/models/personal_service.py
Normal file
31
backend/app/models/personal_service.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
個人化服務 Model
|
||||
定義可為員工啟用的個人服務(SSO、Email、Calendar、Drive、Office)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PersonalService(Base):
|
||||
"""個人化服務表"""
|
||||
|
||||
__tablename__ = "personal_services"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("service_code", name="uq_personal_service_code"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
service_code = Column(String(20), unique=True, nullable=False, comment="服務代碼: SSO/Email/Calendar/Drive/Office")
|
||||
service_name = Column(String(100), nullable=False, comment="服務名稱")
|
||||
description = Column(String(500), nullable=True, comment="服務說明")
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
|
||||
# 通用欄位
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PersonalService {self.service_code} - {self.service_name}>"
|
||||
120
backend/app/models/role.py
Normal file
120
backend/app/models/role.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
RBAC 角色相關 Models
|
||||
- UserRole: 租戶層級角色 (不綁定部門)
|
||||
- RoleRight: 角色對系統功能的 CRUD 權限
|
||||
- UserRoleAssignment: 使用者角色分配 (直接對人,跨部門有效)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""角色表 (租戶層級,不綁定部門)"""
|
||||
|
||||
__tablename__ = "tenant_user_roles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_role_seq"),
|
||||
UniqueConstraint("tenant_id", "role_code", name="uq_tenant_role_code"),
|
||||
Index("idx_roles_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
|
||||
role_code = Column(String(100), nullable=False, comment="角色代碼 (租戶內唯一,例如 HR_ADMIN)")
|
||||
role_name = Column(String(200), nullable=False, comment="角色名稱")
|
||||
description = Column(Text, nullable=True, comment="角色說明")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="user_roles")
|
||||
rights = relationship("RoleRight", back_populates="role", cascade="all, delete-orphan", lazy="selectin")
|
||||
user_assignments = relationship("UserRoleAssignment", back_populates="role", cascade="all, delete-orphan",
|
||||
lazy="dynamic")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole {self.role_code} - {self.role_name}>"
|
||||
|
||||
|
||||
class RoleRight(Base):
|
||||
"""角色功能權限表 (Role and System Right)"""
|
||||
|
||||
__tablename__ = "tenant_role_rights"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("role_id", "function_id", name="uq_role_function"),
|
||||
Index("idx_role_rights_role", "role_id"),
|
||||
Index("idx_role_rights_function", "function_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role_id = Column(Integer, ForeignKey("tenant_user_roles.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="角色 ID")
|
||||
function_id = Column(Integer, ForeignKey("system_functions_cache.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="系統功能 ID")
|
||||
can_read = Column(Boolean, default=False, nullable=False, comment="查詢權限")
|
||||
can_create = Column(Boolean, default=False, nullable=False, comment="新增權限")
|
||||
can_update = Column(Boolean, default=False, nullable=False, comment="修改權限")
|
||||
can_delete = Column(Boolean, default=False, nullable=False, comment="刪除權限")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
role = relationship("UserRole", back_populates="rights")
|
||||
function = relationship("SystemFunctionCache")
|
||||
|
||||
def __repr__(self):
|
||||
perms = []
|
||||
if self.can_read: perms.append("R")
|
||||
if self.can_create: perms.append("C")
|
||||
if self.can_update: perms.append("U")
|
||||
if self.can_delete: perms.append("D")
|
||||
return f"<RoleRight role={self.role_id} fn={self.function_id} [{','.join(perms)}]>"
|
||||
|
||||
|
||||
class UserRoleAssignment(Base):
|
||||
"""使用者角色分配表 (直接對人,跨部門有效)"""
|
||||
|
||||
__tablename__ = "tenant_user_role_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("keycloak_user_id", "role_id", name="uq_user_role"),
|
||||
Index("idx_user_roles_tenant", "tenant_id"),
|
||||
Index("idx_user_roles_keycloak", "keycloak_user_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
keycloak_user_id = Column(String(36), nullable=False, comment="Keycloak User UUID (永久識別碼)")
|
||||
role_id = Column(Integer, ForeignKey("tenant_user_roles.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="角色 ID")
|
||||
|
||||
# 審計欄位(完整記錄)
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="分配時間")
|
||||
assigned_by = Column(String(36), nullable=True, comment="分配者 keycloak_user_id")
|
||||
revoked_at = Column(DateTime, nullable=True, comment="撤銷時間(軟刪除)")
|
||||
revoked_by = Column(String(36), nullable=True, comment="撤銷者 keycloak_user_id")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
role = relationship("UserRole", back_populates="user_assignments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRoleAssignment user={self.keycloak_user_id} role={self.role_id}>"
|
||||
77
backend/app/models/subscription.py
Normal file
77
backend/app/models/subscription.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
訂閱記錄 Model
|
||||
管理租戶的訂閱狀態和歷史
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, Boolean, ForeignKey, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SubscriptionStatus(str, enum.Enum):
|
||||
"""訂閱狀態"""
|
||||
ACTIVE = "active" # 進行中
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
EXPIRED = "expired" # 已過期
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
"""訂閱記錄表"""
|
||||
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 方案資訊
|
||||
plan_id = Column(String(50), nullable=False, comment="方案 ID (starter/standard/enterprise)")
|
||||
start_date = Column(Date, nullable=False, comment="開始日期")
|
||||
end_date = Column(Date, nullable=False, comment="結束日期")
|
||||
status = Column(String(20), default=SubscriptionStatus.ACTIVE, nullable=False, comment="狀態")
|
||||
|
||||
# 自動續約
|
||||
auto_renew = Column(Boolean, default=True, nullable=False, comment="是否自動續約")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
cancelled_at = Column(DateTime, nullable=True, comment="取消時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="subscriptions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subscription {self.plan_id} for Tenant#{self.tenant_id} ({self.start_date} ~ {self.end_date})>"
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""是否為活躍訂閱"""
|
||||
today = date.today()
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE and
|
||||
self.start_date <= today <= self.end_date
|
||||
)
|
||||
|
||||
@property
|
||||
def days_remaining(self) -> int:
|
||||
"""剩餘天數"""
|
||||
if not self.is_active:
|
||||
return 0
|
||||
return (self.end_date - date.today()).days
|
||||
|
||||
def renew(self, months: int = 1) -> "Subscription":
|
||||
"""續約 (創建新的訂閱記錄)"""
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
new_start = self.end_date + relativedelta(days=1)
|
||||
new_end = new_start + relativedelta(months=months) - relativedelta(days=1)
|
||||
|
||||
return Subscription(
|
||||
tenant_id=self.tenant_id,
|
||||
plan_id=self.plan_id,
|
||||
start_date=new_start,
|
||||
end_date=new_end,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
auto_renew=self.auto_renew
|
||||
)
|
||||
111
backend/app/models/system_function.py
Normal file
111
backend/app/models/system_function.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
SystemFunction Model
|
||||
系統功能明細檔
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SystemFunction(Base):
|
||||
"""系統功能明細"""
|
||||
__tablename__ = "system_functions"
|
||||
|
||||
# 1. 資料編號 (PK, 自動編號從 10 開始, 1~9 為功能設定編號)
|
||||
id = Column(Integer, primary_key=True, index=True, comment="資料編號")
|
||||
|
||||
# 2. 系統功能代碼/功能英文名稱
|
||||
code = Column(String(200), nullable=False, index=True, comment="系統功能代碼/功能英文名稱")
|
||||
|
||||
# 3. 上層功能代碼 (0 為初始層)
|
||||
upper_function_id = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
index=True,
|
||||
comment="上層功能代碼 (0為初始層)"
|
||||
)
|
||||
|
||||
# 4. 系統功能中文名稱
|
||||
name = Column(String(200), nullable=False, comment="系統功能中文名稱")
|
||||
|
||||
# 5. 系統功能類型 (1:node, 2:function)
|
||||
function_type = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="系統功能類型 (1:node, 2:function)"
|
||||
)
|
||||
|
||||
# 6. 系統功能次序
|
||||
order = Column(Integer, nullable=False, comment="系統功能次序")
|
||||
|
||||
# 7. 功能圖示
|
||||
function_icon = Column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
comment="功能圖示"
|
||||
)
|
||||
|
||||
# 8. 功能模組名稱 (function_type=2 必填)
|
||||
module_code = Column(
|
||||
String(200),
|
||||
nullable=True,
|
||||
comment="功能模組名稱 (function_type=2 必填)"
|
||||
)
|
||||
|
||||
# 9. 模組項目 (JSON: [View, Create, Read, Update, Delete, Print, File])
|
||||
module_functions = Column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
server_default="[]",
|
||||
comment="模組項目 (View,Create,Read,Update,Delete,Print,File)"
|
||||
)
|
||||
|
||||
# 10. 說明 (富文本格式)
|
||||
description = Column(
|
||||
Text,
|
||||
nullable=False,
|
||||
server_default="",
|
||||
comment="說明 (富文本格式)"
|
||||
)
|
||||
|
||||
# 11. 系統管理
|
||||
is_mana = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="true",
|
||||
comment="系統管理"
|
||||
)
|
||||
|
||||
# 12. 啟用
|
||||
is_active = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="true",
|
||||
index=True,
|
||||
comment="啟用"
|
||||
)
|
||||
|
||||
# 13. 資料建立者
|
||||
edit_by = Column(Integer, nullable=False, comment="資料建立者")
|
||||
|
||||
# 14. 資料最新建立時間
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="資料最新建立時間"
|
||||
)
|
||||
|
||||
# 15. 資料最新修改時間
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
onupdate=func.now(),
|
||||
comment="資料最新修改時間"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemFunction(id={self.id}, code={self.code}, name={self.name})>"
|
||||
31
backend/app/models/system_function_cache.py
Normal file
31
backend/app/models/system_function_cache.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
系統功能快取 Model
|
||||
從 System Admin 服務同步的系統功能定義 (只讀副本)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint, Index
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SystemFunctionCache(Base):
|
||||
"""系統功能快取表"""
|
||||
|
||||
__tablename__ = "system_functions_cache"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("function_code", name="uq_function_code"),
|
||||
Index("idx_func_cache_service", "service_code"),
|
||||
Index("idx_func_cache_category", "function_category"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, comment="與 System Admin 的 id 一致")
|
||||
service_code = Column(String(50), nullable=False, comment="服務代碼: hr/erp/mail/ai")
|
||||
function_code = Column(String(100), nullable=False, comment="功能代碼: HR_EMPLOYEE_VIEW")
|
||||
function_name = Column(String(200), nullable=False, comment="功能名稱")
|
||||
function_category = Column(String(50), nullable=True,
|
||||
comment="功能分類: query/manage/approve/report")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
synced_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="最後同步時間")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemFunctionCache {self.function_code} ({self.service_code})>"
|
||||
114
backend/app/models/tenant.py
Normal file
114
backend/app/models/tenant.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
租戶 Model
|
||||
多租戶 SaaS 的核心 - 每個客戶公司對應一個租戶
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class TenantStatus(str, enum.Enum):
|
||||
"""租戶狀態"""
|
||||
TRIAL = "trial" # 試用中
|
||||
ACTIVE = "active" # 正常使用
|
||||
SUSPENDED = "suspended" # 暫停 (逾期未付款)
|
||||
DELETED = "deleted" # 已刪除
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""租戶表 (客戶組織)"""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
# 基本欄位
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True, comment="租戶代碼 (英文,例如 porscheworld)")
|
||||
name = Column(String(200), nullable=False, comment="公司名稱")
|
||||
name_eng = Column(String(200), nullable=True, comment="公司英文名稱")
|
||||
|
||||
# SSO 整合
|
||||
keycloak_realm = Column(String(100), unique=True, nullable=True, index=True,
|
||||
comment="Keycloak Realm 名稱 (等同 code,每個組織一個獨立 Realm)")
|
||||
|
||||
# 公司資訊
|
||||
tax_id = Column(String(20), nullable=True, comment="統一編號")
|
||||
prefix = Column(String(10), nullable=False, default="ORG", comment="員工編號前綴 (例如 PWD → PWD0001)")
|
||||
domain = Column(String(100), nullable=True, comment="主網域 (例如 porscheworld.tw)")
|
||||
domain_set = Column(Text, nullable=True, comment="網域集合 (JSON Array,例如 [\"ease.taipei\", \"lab.taipei\"])")
|
||||
tel = Column(String(50), nullable=True, comment="公司電話")
|
||||
add = Column(String(500), nullable=True, comment="公司地址")
|
||||
url = Column(String(200), nullable=True, comment="公司網站")
|
||||
|
||||
# 訂閱與方案
|
||||
plan_id = Column(String(50), nullable=False, default="starter", comment="方案 ID (starter/standard/enterprise)")
|
||||
max_users = Column(Integer, nullable=False, default=5, comment="最大用戶數")
|
||||
storage_quota_gb = Column(Integer, nullable=False, default=100, comment="總儲存配額 (GB)")
|
||||
|
||||
# 狀態管理
|
||||
status = Column(String(20), default=TenantStatus.TRIAL, nullable=False, comment="狀態")
|
||||
is_sysmana = Column(Boolean, default=False, nullable=False, comment="是否為系統管理公司 (管理其他租戶)")
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
|
||||
# 初始化狀態
|
||||
is_initialized = Column(Boolean, default=False, nullable=False, comment="是否已完成初始化設定")
|
||||
initialized_at = Column(DateTime, nullable=True, comment="初始化完成時間")
|
||||
initialized_by = Column(String(255), nullable=True, comment="執行初始化的使用者名稱")
|
||||
|
||||
# 時間記錄(通用欄位)
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
departments = relationship(
|
||||
"Department",
|
||||
back_populates="tenant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
employees = relationship(
|
||||
"Employee",
|
||||
back_populates="tenant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
user_roles = relationship(
|
||||
"UserRole",
|
||||
back_populates="tenant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant {self.code} - {self.name}>"
|
||||
|
||||
# is_active 已改為資料庫欄位,移除 @property
|
||||
|
||||
@property
|
||||
def is_trial(self) -> bool:
|
||||
"""是否為試用狀態"""
|
||||
return self.status == TenantStatus.TRIAL
|
||||
|
||||
@property
|
||||
def total_users(self) -> int:
|
||||
"""總用戶數"""
|
||||
return self.employees.count()
|
||||
|
||||
@property
|
||||
def is_over_user_limit(self) -> bool:
|
||||
"""是否超過用戶數限制"""
|
||||
return self.total_users > self.max_users
|
||||
|
||||
@property
|
||||
def domains(self):
|
||||
"""網域列表(從 domain_set JSON 解析)"""
|
||||
if not self.domain_set:
|
||||
return []
|
||||
import json
|
||||
try:
|
||||
return json.loads(self.domain_set)
|
||||
except:
|
||||
return []
|
||||
119
backend/app/models/tenant_domain.py
Normal file
119
backend/app/models/tenant_domain.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
租戶網域 Model
|
||||
支援單一租戶使用多個網域 (多品牌/國際化)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class DomainStatus(str, enum.Enum):
|
||||
"""網域狀態"""
|
||||
PENDING = "pending" # 待驗證
|
||||
ACTIVE = "active" # 啟用中
|
||||
DISABLED = "disabled" # 已停用
|
||||
|
||||
|
||||
class TenantDomain(Base):
|
||||
"""租戶網域表 (一個租戶可以有多個網域)"""
|
||||
|
||||
__tablename__ = "tenant_domains"
|
||||
__table_args__ = (
|
||||
# 每個租戶只能有一個主要網域
|
||||
Index("idx_tenant_primary_domain", "tenant_id", unique=True, postgresql_where=Column("is_primary") == True),
|
||||
# 一般索引
|
||||
Index("idx_tenant_domains_tenant", "tenant_id"),
|
||||
Index("idx_tenant_domains_status", "status"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
domain = Column(String(100), unique=True, nullable=False, index=True, comment="網域名稱 (abc.com.tw)")
|
||||
|
||||
# 網域屬性
|
||||
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要網域")
|
||||
status = Column(String(20), default=DomainStatus.PENDING, nullable=False, comment="狀態")
|
||||
verified = Column(Boolean, default=False, nullable=False, comment="DNS 驗證狀態")
|
||||
|
||||
# DNS 驗證
|
||||
verification_token = Column(String(100), nullable=True, comment="驗證 Token")
|
||||
verified_at = Column(DateTime, nullable=True, comment="驗證時間")
|
||||
|
||||
# 服務啟用狀態
|
||||
enable_email = Column(Boolean, default=True, nullable=False, comment="啟用郵件服務")
|
||||
enable_webmail = Column(Boolean, default=True, nullable=False, comment="啟用 WebMail")
|
||||
enable_drive = Column(Boolean, default=True, nullable=False, comment="啟用雲端硬碟")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="domains")
|
||||
email_aliases = relationship(
|
||||
"UserEmailAlias",
|
||||
back_populates="domain",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantDomain {self.domain} ({'主要' if self.is_primary else '次要'})>"
|
||||
|
||||
@property
|
||||
def is_verified(self) -> bool:
|
||||
"""是否已驗證"""
|
||||
return self.verified and self.status == DomainStatus.ACTIVE
|
||||
|
||||
def generate_dns_records(self) -> list:
|
||||
"""生成 DNS 驗證記錄指引"""
|
||||
records = []
|
||||
|
||||
# TXT 記錄 - 網域所有權驗證
|
||||
records.append({
|
||||
"type": "TXT",
|
||||
"name": "@",
|
||||
"value": f"porsche-cloud-verify={self.verification_token}",
|
||||
"purpose": "網域所有權驗證"
|
||||
})
|
||||
|
||||
if self.enable_email:
|
||||
# MX 記錄 - 郵件伺服器
|
||||
records.append({
|
||||
"type": "MX",
|
||||
"name": "@",
|
||||
"value": "mail.porschecloud.tw",
|
||||
"priority": 10,
|
||||
"purpose": "郵件伺服器"
|
||||
})
|
||||
|
||||
# SPF 記錄 - 防止郵件偽造
|
||||
records.append({
|
||||
"type": "TXT",
|
||||
"name": "@",
|
||||
"value": "v=spf1 include:porschecloud.tw ~all",
|
||||
"purpose": "郵件 SPF 記錄"
|
||||
})
|
||||
|
||||
if self.enable_webmail:
|
||||
# CNAME - WebMail
|
||||
records.append({
|
||||
"type": "CNAME",
|
||||
"name": "mail",
|
||||
"value": f"tenant-{self.tenant.tenant_code}.porschecloud.tw",
|
||||
"purpose": "WebMail 訪問"
|
||||
})
|
||||
|
||||
if self.enable_drive:
|
||||
# CNAME - 雲端硬碟
|
||||
records.append({
|
||||
"type": "CNAME",
|
||||
"name": "drive",
|
||||
"value": f"tenant-{self.tenant.tenant_code}.porschecloud.tw",
|
||||
"purpose": "雲端硬碟訪問"
|
||||
})
|
||||
|
||||
return records
|
||||
56
backend/app/models/usage_log.py
Normal file
56
backend/app/models/usage_log.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
使用量記錄 Model
|
||||
記錄租戶和用戶的資源使用情況
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UsageLog(Base):
|
||||
"""使用量記錄表 (每日統計)"""
|
||||
|
||||
__tablename__ = "usage_logs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "user_id", "date", name="uq_usage_tenant_user_date"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
date = Column(Date, nullable=False, index=True, comment="統計日期")
|
||||
|
||||
# 郵件使用量
|
||||
email_storage_gb = Column(Numeric(10, 2), default=0, nullable=False, comment="郵件儲存 (GB)")
|
||||
emails_sent = Column(Integer, default=0, nullable=False, comment="發送郵件數")
|
||||
emails_received = Column(Integer, default=0, nullable=False, comment="接收郵件數")
|
||||
|
||||
# 雲端硬碟使用量
|
||||
drive_storage_gb = Column(Numeric(10, 2), default=0, nullable=False, comment="硬碟儲存 (GB)")
|
||||
files_uploaded = Column(Integer, default=0, nullable=False, comment="上傳檔案數")
|
||||
files_downloaded = Column(Integer, default=0, nullable=False, comment="下載檔案數")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UsageLog Tenant#{self.tenant_id} User#{self.user_id} {self.date}>"
|
||||
|
||||
@property
|
||||
def total_storage_gb(self) -> float:
|
||||
"""總儲存使用量 (GB)"""
|
||||
return float(self.email_storage_gb) + float(self.drive_storage_gb)
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, tenant_id: int, user_id: int = None, log_date: date = None):
|
||||
"""獲取或創建當日記錄"""
|
||||
if log_date is None:
|
||||
log_date = date.today()
|
||||
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
date=log_date
|
||||
)
|
||||
51
backend/app/models/user_email_alias.py
Normal file
51
backend/app/models/user_email_alias.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
用戶郵件別名 Model
|
||||
支援員工在不同網域擁有多個郵件地址
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UserEmailAlias(Base):
|
||||
"""用戶郵件別名表 (一個用戶可以有多個郵件地址)"""
|
||||
|
||||
__tablename__ = "user_email_aliases"
|
||||
__table_args__ = (
|
||||
# 每個用戶只能有一個主要郵件
|
||||
Index("idx_user_primary_email", "user_id", unique=True, postgresql_where=Column("is_primary") == True),
|
||||
# 一般索引
|
||||
Index("idx_email_aliases_user", "user_id"),
|
||||
Index("idx_email_aliases_tenant", "tenant_id"),
|
||||
Index("idx_email_aliases_domain", "domain_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
domain_id = Column(Integer, ForeignKey("tenant_domains.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
email = Column(String(150), unique=True, nullable=False, index=True, comment="郵件地址 (sales@brand-a.com)")
|
||||
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要郵件地址")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
user = relationship("Employee", back_populates="email_aliases")
|
||||
domain = relationship("TenantDomain", back_populates="email_aliases")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserEmailAlias {self.email} ({'主要' if self.is_primary else '別名'})>"
|
||||
|
||||
@property
|
||||
def local_part(self) -> str:
|
||||
"""郵件前綴 (@ 之前的部分)"""
|
||||
return self.email.split('@')[0] if '@' in self.email else self.email
|
||||
|
||||
@property
|
||||
def domain_part(self) -> str:
|
||||
"""網域部分 (@ 之後的部分)"""
|
||||
return self.email.split('@')[1] if '@' in self.email else ""
|
||||
107
backend/app/schemas/__init__.py
Normal file
107
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Schemas 模組
|
||||
匯出所有 Pydantic Schemas
|
||||
"""
|
||||
|
||||
# Base
|
||||
from app.schemas.base import (
|
||||
BaseSchema,
|
||||
TimestampSchema,
|
||||
PaginationParams,
|
||||
PaginatedResponse,
|
||||
)
|
||||
|
||||
# Employee
|
||||
from app.schemas.employee import (
|
||||
EmployeeBase,
|
||||
EmployeeCreate,
|
||||
EmployeeUpdate,
|
||||
EmployeeInDB,
|
||||
EmployeeResponse,
|
||||
EmployeeListItem,
|
||||
EmployeeDetail,
|
||||
)
|
||||
|
||||
# Business Unit
|
||||
from app.schemas.business_unit import (
|
||||
BusinessUnitBase,
|
||||
BusinessUnitCreate,
|
||||
BusinessUnitUpdate,
|
||||
BusinessUnitInDB,
|
||||
BusinessUnitResponse,
|
||||
BusinessUnitListItem,
|
||||
)
|
||||
|
||||
# Department
|
||||
from app.schemas.department import (
|
||||
DepartmentBase,
|
||||
DepartmentCreate,
|
||||
DepartmentUpdate,
|
||||
DepartmentResponse,
|
||||
DepartmentListItem,
|
||||
DepartmentTreeNode,
|
||||
)
|
||||
|
||||
# Employee Identity
|
||||
from app.schemas.employee_identity import (
|
||||
EmployeeIdentityBase,
|
||||
EmployeeIdentityCreate,
|
||||
EmployeeIdentityUpdate,
|
||||
EmployeeIdentityInDB,
|
||||
EmployeeIdentityResponse,
|
||||
EmployeeIdentityListItem,
|
||||
)
|
||||
|
||||
# Network Drive
|
||||
from app.schemas.network_drive import (
|
||||
NetworkDriveBase,
|
||||
NetworkDriveCreate,
|
||||
NetworkDriveUpdate,
|
||||
NetworkDriveInDB,
|
||||
NetworkDriveResponse,
|
||||
NetworkDriveListItem,
|
||||
NetworkDriveQuotaUpdate,
|
||||
)
|
||||
|
||||
# Audit Log
|
||||
from app.schemas.audit_log import (
|
||||
AuditLogBase,
|
||||
AuditLogCreate,
|
||||
AuditLogInDB,
|
||||
AuditLogResponse,
|
||||
AuditLogListItem,
|
||||
AuditLogFilter,
|
||||
)
|
||||
|
||||
# Email Account
|
||||
from app.schemas.email_account import (
|
||||
EmailAccountBase,
|
||||
EmailAccountCreate,
|
||||
EmailAccountUpdate,
|
||||
EmailAccountInDB,
|
||||
EmailAccountResponse,
|
||||
EmailAccountListItem,
|
||||
EmailAccountQuotaUpdate,
|
||||
)
|
||||
|
||||
# Permission
|
||||
from app.schemas.permission import (
|
||||
PermissionBase,
|
||||
PermissionCreate,
|
||||
PermissionUpdate,
|
||||
PermissionInDB,
|
||||
PermissionResponse,
|
||||
PermissionListItem,
|
||||
PermissionBatchCreate,
|
||||
PermissionFilter,
|
||||
VALID_SYSTEMS,
|
||||
VALID_ACCESS_LEVELS,
|
||||
)
|
||||
|
||||
# Response
|
||||
from app.schemas.response import (
|
||||
ResponseModel,
|
||||
ErrorResponse,
|
||||
MessageResponse,
|
||||
SuccessResponse,
|
||||
)
|
||||
107
backend/app/schemas/audit_log.py
Normal file
107
backend/app/schemas/audit_log.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
審計日誌 Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import Field, ConfigDict
|
||||
|
||||
from app.schemas.base import BaseSchema
|
||||
|
||||
|
||||
class AuditLogBase(BaseSchema):
|
||||
"""審計日誌基礎 Schema"""
|
||||
|
||||
action: str = Field(..., max_length=50, description="操作類型 (create/update/delete/login)")
|
||||
resource_type: str = Field(..., max_length=50, description="資源類型 (employee/identity/department)")
|
||||
resource_id: Optional[int] = Field(None, description="資源 ID")
|
||||
performed_by: str = Field(..., max_length=100, description="操作者 SSO 帳號")
|
||||
details: Optional[Dict[str, Any]] = Field(None, description="詳細變更內容")
|
||||
ip_address: Optional[str] = Field(None, max_length=45, description="IP 位址")
|
||||
|
||||
|
||||
class AuditLogCreate(AuditLogBase):
|
||||
"""創建審計日誌 Schema"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"action": "create",
|
||||
"resource_type": "employee",
|
||||
"resource_id": 1,
|
||||
"performed_by": "porsche.chen@lab.taipei",
|
||||
"details": {
|
||||
"employee_id": "EMP001",
|
||||
"legal_name": "陳保時",
|
||||
"username_base": "porsche.chen"
|
||||
},
|
||||
"ip_address": "10.1.0.245"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuditLogInDB(AuditLogBase):
|
||||
"""資料庫中的審計日誌 Schema"""
|
||||
|
||||
id: int
|
||||
performed_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AuditLogResponse(AuditLogInDB):
|
||||
"""審計日誌響應 Schema"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"action": "create",
|
||||
"resource_type": "employee",
|
||||
"resource_id": 1,
|
||||
"performed_by": "porsche.chen@lab.taipei",
|
||||
"performed_at": "2020-01-01T12:00:00",
|
||||
"details": {
|
||||
"employee_id": "EMP001",
|
||||
"legal_name": "陳保時",
|
||||
"username_base": "porsche.chen"
|
||||
},
|
||||
"ip_address": "10.1.0.245"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuditLogListItem(BaseSchema):
|
||||
"""審計日誌列表項 Schema"""
|
||||
|
||||
id: int
|
||||
action: str
|
||||
resource_type: str
|
||||
resource_id: Optional[int] = None
|
||||
performed_by: str
|
||||
performed_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AuditLogFilter(BaseSchema):
|
||||
"""審計日誌篩選參數"""
|
||||
|
||||
action: Optional[str] = None
|
||||
resource_type: Optional[str] = None
|
||||
resource_id: Optional[int] = None
|
||||
performed_by: Optional[str] = None
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"action": "create",
|
||||
"resource_type": "employee",
|
||||
"start_date": "2020-01-01T00:00:00",
|
||||
"end_date": "2020-12-31T23:59:59"
|
||||
}
|
||||
}
|
||||
)
|
||||
55
backend/app/schemas/auth.py
Normal file
55
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
認證相關 Schemas
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token 響應"""
|
||||
|
||||
access_token: str = Field(..., description="Access Token")
|
||||
token_type: str = Field(default="bearer", description="Token 類型")
|
||||
expires_in: int = Field(..., description="過期時間 (秒)")
|
||||
refresh_token: Optional[str] = Field(None, description="Refresh Token")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 1800,
|
||||
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
"""用戶資訊"""
|
||||
|
||||
sub: str = Field(..., description="用戶 ID (Keycloak UUID)")
|
||||
username: str = Field(..., description="用戶名稱")
|
||||
email: str = Field(..., description="郵件地址")
|
||||
first_name: Optional[str] = Field(None, description="名字")
|
||||
last_name: Optional[str] = Field(None, description="姓氏")
|
||||
email_verified: bool = Field(False, description="郵件是否已驗證")
|
||||
tenant: Optional[Dict[str, Any]] = Field(None, description="租戶資訊")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""登入請求"""
|
||||
|
||||
username: str = Field(..., min_length=3, description="用戶名稱")
|
||||
password: str = Field(..., min_length=6, description="密碼")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"username": "porsche.chen@lab.taipei",
|
||||
"password": "your-password"
|
||||
}
|
||||
}
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user