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