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