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

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

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

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

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

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

263 lines
7.1 KiB
Python

"""
網路硬碟管理 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