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:
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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user