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:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
"""
Schemas 模組
匯出所有 Pydantic Schemas
"""
# Base
from app.schemas.base import (
BaseSchema,
TimestampSchema,
PaginationParams,
PaginatedResponse,
)
# Employee
from app.schemas.employee import (
EmployeeBase,
EmployeeCreate,
EmployeeUpdate,
EmployeeInDB,
EmployeeResponse,
EmployeeListItem,
EmployeeDetail,
)
# Business Unit
from app.schemas.business_unit import (
BusinessUnitBase,
BusinessUnitCreate,
BusinessUnitUpdate,
BusinessUnitInDB,
BusinessUnitResponse,
BusinessUnitListItem,
)
# Department
from app.schemas.department import (
DepartmentBase,
DepartmentCreate,
DepartmentUpdate,
DepartmentResponse,
DepartmentListItem,
DepartmentTreeNode,
)
# Employee Identity
from app.schemas.employee_identity import (
EmployeeIdentityBase,
EmployeeIdentityCreate,
EmployeeIdentityUpdate,
EmployeeIdentityInDB,
EmployeeIdentityResponse,
EmployeeIdentityListItem,
)
# Network Drive
from app.schemas.network_drive import (
NetworkDriveBase,
NetworkDriveCreate,
NetworkDriveUpdate,
NetworkDriveInDB,
NetworkDriveResponse,
NetworkDriveListItem,
NetworkDriveQuotaUpdate,
)
# Audit Log
from app.schemas.audit_log import (
AuditLogBase,
AuditLogCreate,
AuditLogInDB,
AuditLogResponse,
AuditLogListItem,
AuditLogFilter,
)
# Email Account
from app.schemas.email_account import (
EmailAccountBase,
EmailAccountCreate,
EmailAccountUpdate,
EmailAccountInDB,
EmailAccountResponse,
EmailAccountListItem,
EmailAccountQuotaUpdate,
)
# Permission
from app.schemas.permission import (
PermissionBase,
PermissionCreate,
PermissionUpdate,
PermissionInDB,
PermissionResponse,
PermissionListItem,
PermissionBatchCreate,
PermissionFilter,
VALID_SYSTEMS,
VALID_ACCESS_LEVELS,
)
# Response
from app.schemas.response import (
ResponseModel,
ErrorResponse,
MessageResponse,
SuccessResponse,
)

View File

@@ -0,0 +1,107 @@
"""
審計日誌 Schemas
"""
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import Field, ConfigDict
from app.schemas.base import BaseSchema
class AuditLogBase(BaseSchema):
"""審計日誌基礎 Schema"""
action: str = Field(..., max_length=50, description="操作類型 (create/update/delete/login)")
resource_type: str = Field(..., max_length=50, description="資源類型 (employee/identity/department)")
resource_id: Optional[int] = Field(None, description="資源 ID")
performed_by: str = Field(..., max_length=100, description="操作者 SSO 帳號")
details: Optional[Dict[str, Any]] = Field(None, description="詳細變更內容")
ip_address: Optional[str] = Field(None, max_length=45, description="IP 位址")
class AuditLogCreate(AuditLogBase):
"""創建審計日誌 Schema"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"action": "create",
"resource_type": "employee",
"resource_id": 1,
"performed_by": "porsche.chen@lab.taipei",
"details": {
"employee_id": "EMP001",
"legal_name": "陳保時",
"username_base": "porsche.chen"
},
"ip_address": "10.1.0.245"
}
}
)
class AuditLogInDB(AuditLogBase):
"""資料庫中的審計日誌 Schema"""
id: int
performed_at: datetime
model_config = ConfigDict(from_attributes=True)
class AuditLogResponse(AuditLogInDB):
"""審計日誌響應 Schema"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"action": "create",
"resource_type": "employee",
"resource_id": 1,
"performed_by": "porsche.chen@lab.taipei",
"performed_at": "2020-01-01T12:00:00",
"details": {
"employee_id": "EMP001",
"legal_name": "陳保時",
"username_base": "porsche.chen"
},
"ip_address": "10.1.0.245"
}
}
)
class AuditLogListItem(BaseSchema):
"""審計日誌列表項 Schema"""
id: int
action: str
resource_type: str
resource_id: Optional[int] = None
performed_by: str
performed_at: datetime
model_config = ConfigDict(from_attributes=True)
class AuditLogFilter(BaseSchema):
"""審計日誌篩選參數"""
action: Optional[str] = None
resource_type: Optional[str] = None
resource_id: Optional[int] = None
performed_by: Optional[str] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
model_config = ConfigDict(
json_schema_extra={
"example": {
"action": "create",
"resource_type": "employee",
"start_date": "2020-01-01T00:00:00",
"end_date": "2020-12-31T23:59:59"
}
}
)

View File

@@ -0,0 +1,55 @@
"""
認證相關 Schemas
"""
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
class TokenResponse(BaseModel):
"""Token 響應"""
access_token: str = Field(..., description="Access Token")
token_type: str = Field(default="bearer", description="Token 類型")
expires_in: int = Field(..., description="過期時間 (秒)")
refresh_token: Optional[str] = Field(None, description="Refresh Token")
model_config = ConfigDict(
json_schema_extra={
"example": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 1800,
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
)
class UserInfo(BaseModel):
"""用戶資訊"""
sub: str = Field(..., description="用戶 ID (Keycloak UUID)")
username: str = Field(..., description="用戶名稱")
email: str = Field(..., description="郵件地址")
first_name: Optional[str] = Field(None, description="名字")
last_name: Optional[str] = Field(None, description="姓氏")
email_verified: bool = Field(False, description="郵件是否已驗證")
tenant: Optional[Dict[str, Any]] = Field(None, description="租戶資訊")
model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel):
"""登入請求"""
username: str = Field(..., min_length=3, description="用戶名稱")
password: str = Field(..., min_length=6, description="密碼")
model_config = ConfigDict(
json_schema_extra={
"example": {
"username": "porsche.chen@lab.taipei",
"password": "your-password"
}
}
)

View File

@@ -0,0 +1,49 @@
"""
基礎 Schema 類別
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class BaseSchema(BaseModel):
"""基礎 Schema"""
model_config = ConfigDict(
from_attributes=True, # 支援從 ORM 模型轉換
use_enum_values=True, # 使用 Enum 的值
)
class TimestampSchema(BaseSchema):
"""帶時間戳的 Schema"""
created_at: datetime
updated_at: datetime
class PaginationParams(BaseModel):
"""分頁參數"""
page: int = 1
page_size: int = 20
model_config = ConfigDict(
json_schema_extra={
"example": {
"page": 1,
"page_size": 20
}
}
)
class PaginatedResponse(BaseModel):
"""分頁響應"""
total: int
page: int
page_size: int
total_pages: int
items: list
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,89 @@
"""
事業部 Schemas
"""
from datetime import datetime
from typing import Optional
from pydantic import Field, ConfigDict
from app.schemas.base import BaseSchema
class BusinessUnitBase(BaseSchema):
"""事業部基礎 Schema"""
name: str = Field(..., min_length=2, max_length=100, description="事業部名稱")
name_en: Optional[str] = Field(None, max_length=100, description="英文名稱")
code: str = Field(..., min_length=2, max_length=20, description="事業部代碼")
email_domain: str = Field(..., description="郵件網域")
description: Optional[str] = Field(None, description="說明")
class BusinessUnitCreate(BusinessUnitBase):
"""創建事業部 Schema"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "業務發展部",
"name_en": "Business Development",
"code": "biz",
"email_domain": "ease.taipei",
"description": "碳權申請諮詢、碳足跡盤查、碳權交易媒合、業務拓展"
}
}
)
class BusinessUnitUpdate(BaseSchema):
"""更新事業部 Schema"""
name: Optional[str] = Field(None, min_length=2, max_length=100)
name_en: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
is_active: Optional[bool] = None
class BusinessUnitInDB(BusinessUnitBase):
"""資料庫中的事業部 Schema"""
id: int
is_active: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class BusinessUnitResponse(BusinessUnitInDB):
"""事業部響應 Schema"""
departments_count: Optional[int] = Field(None, description="部門數量")
employees_count: Optional[int] = Field(None, description="員工數量")
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"name": "業務發展部",
"name_en": "Business Development",
"code": "biz",
"email_domain": "ease.taipei",
"description": "碳權申請諮詢、碳足跡盤查、碳權交易媒合、業務拓展",
"is_active": True,
"created_at": "2020-01-01T00:00:00",
"departments_count": 3,
"employees_count": 15
}
}
)
class BusinessUnitListItem(BaseSchema):
"""事業部列表項 Schema"""
id: int
name: str
code: str
email_domain: str
is_active: bool
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,125 @@
"""
部門 Schemas (統一樹狀結構)
"""
from datetime import datetime
from typing import Optional, List, Any
from pydantic import Field, ConfigDict
from app.schemas.base import BaseSchema
class DepartmentBase(BaseSchema):
"""部門基礎 Schema"""
name: str = Field(..., min_length=2, max_length=100, description="部門名稱")
name_en: Optional[str] = Field(None, max_length=100, description="英文名稱")
code: str = Field(..., min_length=1, max_length=20, description="部門代碼")
description: Optional[str] = Field(None, description="說明")
class DepartmentCreate(DepartmentBase):
"""創建部門 Schema
- parent_id=NULL: 建立第一層部門,可設定 email_domain
- parent_id=有值: 建立子部門,不可設定 email_domain (繼承)
"""
parent_id: Optional[int] = Field(None, description="上層部門 ID (NULL=第一層)")
email_domain: Optional[str] = Field(None, max_length=100,
description="郵件網域 (只有第一層可設定,例如 ease.taipei)")
email_address: Optional[str] = Field(None, max_length=255, description="部門信箱")
email_quota_mb: Optional[int] = Field(5120, description="部門信箱配額 (MB)")
model_config = ConfigDict(
json_schema_extra={
"example": {
"parent_id": None,
"name": "業務發展部",
"name_en": "Business Development",
"code": "BD",
"email_domain": "ease.taipei",
"description": "業務發展相關部門"
}
}
)
class DepartmentUpdate(BaseSchema):
"""更新部門 Schema
注意: code 和 parent_id 建立後不可修改
email_domain 只有第一層 (depth=0) 可更新
"""
name: Optional[str] = Field(None, min_length=2, max_length=100)
name_en: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
email_domain: Optional[str] = Field(None, max_length=100,
description="只有第一層部門可更新")
email_address: Optional[str] = Field(None, max_length=255)
email_quota_mb: Optional[int] = None
is_active: Optional[bool] = None
class DepartmentResponse(BaseSchema):
"""部門響應 Schema"""
id: int
tenant_id: int
parent_id: Optional[int] = None
code: str
name: str
name_en: Optional[str] = None
depth: int
email_domain: Optional[str] = None
effective_email_domain: Optional[str] = None
email_address: Optional[str] = None
email_quota_mb: int
description: Optional[str] = None
is_active: bool
is_top_level: bool = False
created_at: datetime
parent_name: Optional[str] = None
member_count: Optional[int] = None
model_config = ConfigDict(from_attributes=True)
class DepartmentListItem(BaseSchema):
"""部門列表項 Schema"""
id: int
tenant_id: int
parent_id: Optional[int] = None
code: str
name: str
depth: int
email_domain: Optional[str] = None
effective_email_domain: Optional[str] = None
email_address: Optional[str] = None
is_active: bool
member_count: Optional[int] = None
model_config = ConfigDict(from_attributes=True)
class DepartmentTreeNode(BaseSchema):
"""部門樹狀節點 Schema (遞迴)"""
id: int
code: str
name: str
name_en: Optional[str] = None
depth: int
parent_id: Optional[int] = None
email_domain: Optional[str] = None
effective_email_domain: Optional[str] = None
email_address: Optional[str] = None
email_quota_mb: int
description: Optional[str] = None
is_active: bool
is_top_level: bool
member_count: int = 0
children: List[Any] = []
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,126 @@
"""
郵件帳號 Schemas
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, EmailStr, ConfigDict, field_validator
from app.schemas.base import BaseSchema, TimestampSchema
class EmailAccountBase(BaseSchema):
"""郵件帳號基礎 Schema"""
email_address: EmailStr = Field(..., description="郵件地址")
quota_mb: int = Field(2048, ge=1024, le=102400, description="配額 (MB), 1GB-100GB")
forward_to: Optional[EmailStr] = Field(None, description="轉寄地址")
auto_reply: Optional[str] = Field(None, max_length=1000, description="自動回覆內容")
is_active: bool = Field(True, description="是否啟用")
class EmailAccountCreate(EmailAccountBase):
"""創建郵件帳號 Schema"""
employee_id: int = Field(..., description="員工 ID")
model_config = ConfigDict(
json_schema_extra={
"example": {
"employee_id": 1,
"email_address": "porsche.chen@porscheworld.tw",
"quota_mb": 2048,
"forward_to": None,
"auto_reply": None,
"is_active": True
}
}
)
class EmailAccountUpdate(BaseSchema):
"""更新郵件帳號 Schema"""
quota_mb: Optional[int] = Field(None, ge=1024, le=102400, description="配額 (MB)")
forward_to: Optional[EmailStr] = Field(None, description="轉寄地址")
auto_reply: Optional[str] = Field(None, max_length=1000, description="自動回覆內容")
is_active: Optional[bool] = Field(None, description="是否啟用")
@field_validator('forward_to')
@classmethod
def validate_forward_to(cls, v):
"""允許空字串來清除轉寄地址"""
if v == "":
return None
return v
@field_validator('auto_reply')
@classmethod
def validate_auto_reply(cls, v):
"""允許空字串來清除自動回覆"""
if v == "":
return None
return v
class EmailAccountInDB(EmailAccountBase, TimestampSchema):
"""資料庫中的郵件帳號 Schema"""
id: int
tenant_id: int
employee_id: int
model_config = ConfigDict(from_attributes=True)
class EmailAccountResponse(EmailAccountInDB):
"""郵件帳號響應 Schema (包含關聯資料)"""
employee_name: Optional[str] = Field(None, description="員工姓名")
employee_number: Optional[str] = Field(None, description="員工編號")
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"tenant_id": 1,
"employee_id": 1,
"email_address": "porsche.chen@porscheworld.tw",
"quota_mb": 2048,
"forward_to": None,
"auto_reply": None,
"is_active": True,
"employee_name": "陳保時",
"employee_number": "EMP001",
"created_at": "2020-01-01T00:00:00",
"updated_at": "2020-01-01T00:00:00"
}
}
)
class EmailAccountListItem(BaseSchema):
"""郵件帳號列表項 Schema (簡化版)"""
id: int
email_address: str
quota_mb: int
is_active: bool
employee_id: int
employee_name: Optional[str] = None
employee_number: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class EmailAccountQuotaUpdate(BaseSchema):
"""郵件配額更新 Schema"""
quota_mb: int = Field(..., ge=1024, le=102400, description="配額 (MB), 1GB-100GB")
model_config = ConfigDict(
json_schema_extra={
"example": {
"quota_mb": 5120 # 5GB
}
}
)

View File

@@ -0,0 +1,120 @@
"""
員工 Schemas
"""
from datetime import date, datetime
from typing import Optional, List
from pydantic import BaseModel, Field, EmailStr, ConfigDict
from app.schemas.base import BaseSchema, TimestampSchema
from app.models.employee import EmployeeStatus
class EmployeeBase(BaseSchema):
"""員工基礎 Schema"""
username_base: str = Field(..., min_length=3, max_length=50, description="基礎帳號名稱 (全公司唯一)")
legal_name: str = Field(..., min_length=2, max_length=100, description="法定姓名")
english_name: Optional[str] = Field(None, max_length=100, description="英文名稱")
phone: Optional[str] = Field(None, max_length=20, description="電話")
mobile: Optional[str] = Field(None, max_length=20, description="手機")
class EmployeeCreate(EmployeeBase):
"""創建員工 Schema (多層部門架構: department_id 指向任何層部門)"""
hire_date: date = Field(..., description="到職日期")
# 組織資訊 (新多層部門架構)
department_id: Optional[int] = Field(None, description="部門 ID (任何層級,選填)")
job_title: str = Field(..., min_length=2, max_length=100, description="職稱")
email_quota_mb: int = Field(5120, gt=0, description="郵件配額 (MB),預設 5120")
model_config = ConfigDict(
json_schema_extra={
"example": {
"username_base": "porsche.chen",
"legal_name": "陳保時",
"english_name": "Porsche Chen",
"phone": "02-1234-5678",
"mobile": "0912-345-678",
"hire_date": "2020-01-01",
"department_id": 2,
"job_title": "軟體工程師",
"email_quota_mb": 5120
}
}
)
class EmployeeUpdate(BaseSchema):
"""更新員工 Schema"""
legal_name: Optional[str] = Field(None, min_length=2, max_length=100)
english_name: Optional[str] = Field(None, max_length=100)
phone: Optional[str] = Field(None, max_length=20)
mobile: Optional[str] = Field(None, max_length=20)
status: Optional[EmployeeStatus] = None
class EmployeeInDB(EmployeeBase, TimestampSchema):
"""資料庫中的員工 Schema"""
id: int
employee_id: str = Field(..., description="員工編號 (EMP001)")
hire_date: date
status: EmployeeStatus
model_config = ConfigDict(from_attributes=True)
class EmployeeResponse(EmployeeInDB):
"""員工響應 Schema (多部門成員架構)"""
has_network_drive: Optional[bool] = Field(None, description="是否有 NAS 帳號")
department_count: Optional[int] = Field(None, description="所屬部門數量")
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"employee_id": "EMP001",
"username_base": "porsche.chen",
"legal_name": "陳保時",
"english_name": "Porsche Chen",
"phone": "02-1234-5678",
"mobile": "0912-345-678",
"hire_date": "2020-01-01",
"status": "active",
"has_network_drive": True,
"department_count": 2,
"created_at": "2020-01-01T00:00:00",
"updated_at": "2020-01-01T00:00:00"
}
}
)
class EmployeeListItem(BaseSchema):
"""員工列表項 Schema (簡化版,多部門成員架構)"""
id: int
employee_id: str
username_base: str
legal_name: str
english_name: Optional[str] = None
status: EmployeeStatus
hire_date: date
# 主要部門資訊 (從 department_memberships 取得)
primary_department: Optional[str] = Field(None, description="主要部門名稱")
primary_job_title: Optional[str] = Field(None, description="職稱")
model_config = ConfigDict(from_attributes=True)
class EmployeeDetail(EmployeeInDB):
"""員工詳情 Schema (包含完整關聯資料)"""
# 將在後續添加 identities 和 network_drive
pass

View File

@@ -0,0 +1,118 @@
"""
員工身份 Schemas
"""
from datetime import date, datetime
from typing import Optional
from pydantic import Field, ConfigDict
from app.schemas.base import BaseSchema, TimestampSchema
class EmployeeIdentityBase(BaseSchema):
"""員工身份基礎 Schema"""
job_title: str = Field(..., min_length=2, max_length=100, description="職稱")
job_level: str = Field(..., description="職級 (Junior/Mid/Senior/Manager)")
email_quota_mb: int = Field(..., gt=0, description="郵件配額 (MB)")
class EmployeeIdentityCreate(EmployeeIdentityBase):
"""創建員工身份 Schema"""
employee_id: int = Field(..., description="員工 ID")
business_unit_id: int = Field(..., description="事業部 ID")
department_id: Optional[int] = Field(None, description="部門 ID")
is_primary: bool = Field(False, description="是否為主要身份")
started_at: date = Field(..., description="開始日期")
model_config = ConfigDict(
json_schema_extra={
"example": {
"employee_id": 1,
"business_unit_id": 2,
"department_id": 4,
"job_title": "技術總監",
"job_level": "Senior",
"email_quota_mb": 5000,
"is_primary": True,
"started_at": "2020-01-01"
}
}
)
class EmployeeIdentityUpdate(BaseSchema):
"""更新員工身份 Schema"""
department_id: Optional[int] = None
job_title: Optional[str] = Field(None, min_length=2, max_length=100)
job_level: Optional[str] = None
email_quota_mb: Optional[int] = Field(None, gt=0)
is_primary: Optional[bool] = None
ended_at: Optional[date] = None
is_active: Optional[bool] = None
class EmployeeIdentityInDB(EmployeeIdentityBase, TimestampSchema):
"""資料庫中的員工身份 Schema"""
id: int
employee_id: int
username: str = Field(..., description="SSO 帳號")
keycloak_id: str = Field(..., description="Keycloak UUID")
business_unit_id: int
department_id: Optional[int] = None
is_primary: bool
started_at: date
ended_at: Optional[date] = None
is_active: bool
model_config = ConfigDict(from_attributes=True)
class EmployeeIdentityResponse(EmployeeIdentityInDB):
"""員工身份響應 Schema"""
employee_name: Optional[str] = Field(None, description="員工姓名")
business_unit_name: Optional[str] = Field(None, description="事業部名稱")
department_name: Optional[str] = Field(None, description="部門名稱")
email_domain: Optional[str] = Field(None, description="郵件網域")
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"employee_id": 1,
"username": "porsche.chen@lab.taipei",
"keycloak_id": "abc123-uuid",
"business_unit_id": 2,
"department_id": 4,
"job_title": "技術總監",
"job_level": "Senior",
"email_quota_mb": 5000,
"is_primary": True,
"started_at": "2020-01-01",
"ended_at": None,
"is_active": True,
"created_at": "2020-01-01T00:00:00",
"updated_at": "2020-01-01T00:00:00",
"employee_name": "陳保時",
"business_unit_name": "智能發展部",
"department_name": "資訊部",
"email_domain": "lab.taipei"
}
}
)
class EmployeeIdentityListItem(BaseSchema):
"""員工身份列表項 Schema"""
id: int
username: str
job_title: str
job_level: str
is_primary: bool
is_active: bool
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,105 @@
"""
網路硬碟 Schemas
"""
from datetime import datetime
from typing import Optional
from pydantic import Field, ConfigDict
from app.schemas.base import BaseSchema, TimestampSchema
class NetworkDriveBase(BaseSchema):
"""網路硬碟基礎 Schema"""
quota_gb: int = Field(..., gt=0, description="配額 (GB)")
webdav_url: Optional[str] = Field(None, max_length=255, description="WebDAV 路徑")
smb_url: Optional[str] = Field(None, max_length=255, description="SMB 路徑")
class NetworkDriveCreate(NetworkDriveBase):
"""創建網路硬碟 Schema"""
employee_id: int = Field(..., description="員工 ID")
drive_name: str = Field(..., min_length=3, max_length=100, description="NAS 帳號名稱")
model_config = ConfigDict(
json_schema_extra={
"example": {
"employee_id": 1,
"drive_name": "porsche.chen",
"quota_gb": 200,
"webdav_url": "https://nas.lab.taipei/webdav/porsche.chen",
"smb_url": "\\\\10.1.0.30\\porsche.chen"
}
}
)
class NetworkDriveUpdate(BaseSchema):
"""更新網路硬碟 Schema"""
quota_gb: Optional[int] = Field(None, gt=0)
webdav_url: Optional[str] = Field(None, max_length=255)
smb_url: Optional[str] = Field(None, max_length=255)
is_active: Optional[bool] = None
class NetworkDriveInDB(NetworkDriveBase, TimestampSchema):
"""資料庫中的網路硬碟 Schema"""
id: int
employee_id: int
drive_name: str
is_active: bool
model_config = ConfigDict(from_attributes=True)
class NetworkDriveResponse(NetworkDriveInDB):
"""網路硬碟響應 Schema"""
employee_name: Optional[str] = Field(None, description="員工姓名")
employee_username: Optional[str] = Field(None, description="員工基礎帳號")
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"employee_id": 1,
"drive_name": "porsche.chen",
"quota_gb": 200,
"webdav_url": "https://nas.lab.taipei/webdav/porsche.chen",
"smb_url": "\\\\10.1.0.30\\porsche.chen",
"is_active": True,
"created_at": "2020-01-01T00:00:00",
"updated_at": "2020-01-01T00:00:00",
"employee_name": "陳保時",
"employee_username": "porsche.chen"
}
}
)
class NetworkDriveListItem(BaseSchema):
"""網路硬碟列表項 Schema"""
id: int
drive_name: str
quota_gb: int
is_active: bool
model_config = ConfigDict(from_attributes=True)
class NetworkDriveQuotaUpdate(BaseSchema):
"""更新配額 Schema"""
quota_gb: int = Field(..., gt=0, le=1000, description="新配額 (GB)")
model_config = ConfigDict(
json_schema_extra={
"example": {
"quota_gb": 500
}
}
)

View File

@@ -0,0 +1,167 @@
"""
權限 Schemas
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict, field_validator
from app.schemas.base import BaseSchema, TimestampSchema
# 系統名稱常數
VALID_SYSTEMS = ["gitea", "portainer", "traefik", "keycloak"]
# 存取層級常數
VALID_ACCESS_LEVELS = ["admin", "user", "readonly"]
class PermissionBase(BaseSchema):
"""權限基礎 Schema"""
system_name: str = Field(..., description="系統名稱: gitea, portainer, traefik, keycloak")
access_level: str = Field("user", description="存取層級: admin, user, readonly")
@field_validator('system_name')
@classmethod
def validate_system_name(cls, v):
"""驗證系統名稱"""
if v.lower() not in VALID_SYSTEMS:
raise ValueError(f"system_name 必須是以下之一: {', '.join(VALID_SYSTEMS)}")
return v.lower()
@field_validator('access_level')
@classmethod
def validate_access_level(cls, v):
"""驗證存取層級"""
if v.lower() not in VALID_ACCESS_LEVELS:
raise ValueError(f"access_level 必須是以下之一: {', '.join(VALID_ACCESS_LEVELS)}")
return v.lower()
class PermissionCreate(PermissionBase):
"""創建權限 Schema"""
employee_id: int = Field(..., description="員工 ID")
granted_by: Optional[int] = Field(None, description="授予人 ID")
model_config = ConfigDict(
json_schema_extra={
"example": {
"employee_id": 1,
"system_name": "gitea",
"access_level": "user",
"granted_by": 2
}
}
)
class PermissionUpdate(BaseSchema):
"""更新權限 Schema"""
access_level: str = Field(..., description="存取層級: admin, user, readonly")
granted_by: Optional[int] = Field(None, description="授予人 ID")
@field_validator('access_level')
@classmethod
def validate_access_level(cls, v):
"""驗證存取層級"""
if v.lower() not in VALID_ACCESS_LEVELS:
raise ValueError(f"access_level 必須是以下之一: {', '.join(VALID_ACCESS_LEVELS)}")
return v.lower()
class PermissionInDB(PermissionBase):
"""資料庫中的權限 Schema"""
id: int
tenant_id: int
employee_id: int
granted_at: datetime
granted_by: Optional[int] = None
model_config = ConfigDict(from_attributes=True)
class PermissionResponse(PermissionInDB):
"""權限響應 Schema (包含關聯資料)"""
employee_name: Optional[str] = Field(None, description="員工姓名")
employee_number: Optional[str] = Field(None, description="員工編號")
granted_by_name: Optional[str] = Field(None, description="授予人姓名")
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"tenant_id": 1,
"employee_id": 1,
"system_name": "gitea",
"access_level": "admin",
"granted_at": "2020-01-01T00:00:00",
"granted_by": 2,
"employee_name": "陳保時",
"employee_number": "EMP001",
"granted_by_name": "管理員"
}
}
)
class PermissionListItem(BaseSchema):
"""權限列表項 Schema (簡化版)"""
id: int
employee_id: int
system_name: str
access_level: str
granted_at: datetime
employee_name: Optional[str] = None
employee_number: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class PermissionBatchCreate(BaseSchema):
"""批量創建權限 Schema"""
employee_id: int = Field(..., description="員工 ID")
permissions: list[PermissionBase] = Field(..., description="權限列表")
granted_by: Optional[int] = Field(None, description="授予人 ID")
model_config = ConfigDict(
json_schema_extra={
"example": {
"employee_id": 1,
"permissions": [
{"system_name": "gitea", "access_level": "user"},
{"system_name": "portainer", "access_level": "readonly"}
],
"granted_by": 2
}
}
)
class PermissionFilter(BaseSchema):
"""權限篩選 Schema"""
employee_id: Optional[int] = Field(None, description="員工 ID")
system_name: Optional[str] = Field(None, description="系統名稱")
access_level: Optional[str] = Field(None, description="存取層級")
@field_validator('system_name')
@classmethod
def validate_system_name(cls, v):
"""驗證系統名稱"""
if v and v.lower() not in VALID_SYSTEMS:
raise ValueError(f"system_name 必須是以下之一: {', '.join(VALID_SYSTEMS)}")
return v.lower() if v else None
@field_validator('access_level')
@classmethod
def validate_access_level(cls, v):
"""驗證存取層級"""
if v and v.lower() not in VALID_ACCESS_LEVELS:
raise ValueError(f"access_level 必須是以下之一: {', '.join(VALID_ACCESS_LEVELS)}")
return v.lower() if v else None

View File

@@ -0,0 +1,37 @@
"""
通用響應 Schemas
"""
from typing import Optional, Any, Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar('T')
class ResponseModel(BaseModel, Generic[T]):
"""通用響應模型"""
success: bool = Field(True, description="操作是否成功")
message: Optional[str] = Field(None, description="響應訊息")
data: Optional[T] = Field(None, description="響應數據")
class ErrorResponse(BaseModel):
"""錯誤響應"""
success: bool = Field(False, description="操作是否成功")
message: str = Field(..., description="錯誤訊息")
error_code: Optional[str] = Field(None, description="錯誤代碼")
details: Optional[Any] = Field(None, description="錯誤詳情")
class MessageResponse(BaseModel):
"""簡單訊息響應"""
message: str = Field(..., description="響應訊息")
class SuccessResponse(BaseModel):
"""成功響應"""
success: bool = Field(True, description="操作是否成功")
message: str = Field(..., description="成功訊息")

View File

@@ -0,0 +1,114 @@
"""
SystemFunction Schemas
系統功能明細 API 資料結構
"""
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
from datetime import datetime
class SystemFunctionBase(BaseModel):
"""系統功能基礎 Schema"""
code: str = Field(..., max_length=200, description="系統功能代碼/功能英文名稱")
upper_function_id: int = Field(0, description="上層功能代碼 (0為初始層)")
name: str = Field(..., max_length=200, description="系統功能中文名稱")
function_type: int = Field(..., description="系統功能類型 (1:node, 2:function)")
order: int = Field(..., description="系統功能次序")
function_icon: str = Field("", max_length=200, description="功能圖示")
module_code: Optional[str] = Field(None, max_length=200, description="功能模組名稱")
module_functions: List[str] = Field(default_factory=list, description="模組項目")
description: str = Field("", description="說明 (富文本格式)")
is_mana: bool = Field(True, description="系統管理")
is_active: bool = Field(True, description="啟用")
@field_validator('function_type')
@classmethod
def validate_function_type(cls, v):
"""驗證功能類型"""
if v not in [1, 2]:
raise ValueError('function_type 必須為 1 (node) 或 2 (function)')
return v
@field_validator('module_functions')
@classmethod
def validate_module_functions(cls, v):
"""驗證模組項目"""
allowed_functions = ['View', 'Create', 'Read', 'Update', 'Delete', 'Print', 'File']
for func in v:
if func not in allowed_functions:
raise ValueError(f'module_functions 只能包含: {", ".join(allowed_functions)}')
return v
@field_validator('upper_function_id')
@classmethod
def validate_upper_function_id(cls, v, values):
"""驗證上層功能代碼"""
# upper_function_id 必須是 function_type=1 且 is_active=1 的功能, 或 0 (初始層)
if v < 0:
raise ValueError('upper_function_id 不能小於 0')
return v
class SystemFunctionCreate(SystemFunctionBase):
"""系統功能建立 Schema"""
edit_by: int = Field(..., description="資料建立者")
@field_validator('module_code')
@classmethod
def validate_module_code_create(cls, v, info):
"""驗證 module_code (function_type=2 必填)"""
function_type = info.data.get('function_type')
if function_type == 2 and not v:
raise ValueError('function_type=2 時, module_code 為必填')
if function_type == 1 and v:
raise ValueError('function_type=1 時, module_code 不能輸入')
return v
@field_validator('module_functions')
@classmethod
def validate_module_functions_create(cls, v, info):
"""驗證 module_functions (function_type=2 必填)"""
function_type = info.data.get('function_type')
if function_type == 2 and not v:
raise ValueError('function_type=2 時, module_functions 為必填')
return v
class SystemFunctionUpdate(BaseModel):
"""系統功能更新 Schema (部分更新)"""
code: Optional[str] = Field(None, max_length=200)
upper_function_id: Optional[int] = None
name: Optional[str] = Field(None, max_length=200)
function_type: Optional[int] = None
order: Optional[int] = None
function_icon: Optional[str] = Field(None, max_length=200)
module_code: Optional[str] = Field(None, max_length=200)
module_functions: Optional[List[str]] = None
description: Optional[str] = None
is_mana: Optional[bool] = None
is_active: Optional[bool] = None
edit_by: int = Field(..., description="資料編輯者")
class SystemFunctionInDB(SystemFunctionBase):
"""系統功能資料庫 Schema"""
id: int
edit_by: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class SystemFunctionResponse(SystemFunctionInDB):
"""系統功能回應 Schema"""
pass
class SystemFunctionListResponse(BaseModel):
"""系統功能列表回應"""
total: int
items: List[SystemFunctionResponse]
page: int
page_size: int

View File

@@ -0,0 +1,134 @@
"""
租戶相關 Pydantic Schemas
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field, validator
class TenantBase(BaseModel):
"""租戶基本資料"""
code: str = Field(..., min_length=2, max_length=50, description="租戶代碼")
name: str = Field(..., min_length=1, max_length=200, description="公司名稱")
name_eng: Optional[str] = Field(None, max_length=200, description="公司英文名稱")
tax_id: Optional[str] = Field(None, max_length=20, description="統一編號")
prefix: str = Field(..., min_length=1, max_length=10, description="員工編號前綴")
tel: Optional[str] = Field(None, max_length=50, description="公司電話")
add: Optional[str] = Field(None, max_length=500, description="公司地址")
url: Optional[str] = Field(None, max_length=200, description="公司網站")
plan_id: str = Field(default="starter", description="方案 ID")
max_users: int = Field(default=5, ge=1, description="最大用戶數")
storage_quota_gb: int = Field(default=100, ge=1, description="總儲存配額 (GB)")
class TenantCreateRequest(TenantBase):
"""建立租戶請求 (Superuser only)"""
admin_username: str = Field(..., description="Tenant Admin 帳號")
admin_email: str = Field(..., description="Tenant Admin 郵件")
admin_name: str = Field(..., description="Tenant Admin 姓名")
admin_temp_password: str = Field(..., min_length=8, description="Tenant Admin 臨時密碼")
@validator('admin_email')
def validate_email(cls, v):
"""驗證郵件格式"""
if '@' not in v:
raise ValueError('Invalid email format')
return v
@validator('tax_id')
def validate_tax_id(cls, v):
"""驗證統一編號 (台灣 8 位數字)"""
if v and (not v.isdigit() or len(v) != 8):
raise ValueError('Tax ID must be 8 digits')
return v
class TenantCreateResponse(BaseModel):
"""建立租戶回應"""
message: str
tenant: dict
admin_user: dict
keycloak_realm: str
temporary_password: str # 返回臨時密碼供管理員記錄
class Config:
from_attributes = True
class TenantUpdateRequest(BaseModel):
"""更新租戶請求"""
name: Optional[str] = Field(None, min_length=1, max_length=200)
name_eng: Optional[str] = Field(None, max_length=200)
tax_id: Optional[str] = Field(None, max_length=20)
tel: Optional[str] = Field(None, max_length=50)
add: Optional[str] = Field(None, max_length=500)
url: Optional[str] = Field(None, max_length=200)
@validator('tax_id')
def validate_tax_id(cls, v):
"""驗證統一編號"""
if v and (not v.isdigit() or len(v) != 8):
raise ValueError('Tax ID must be 8 digits')
return v
class TenantUpdateResponse(BaseModel):
"""更新租戶回應"""
message: str
tenant: dict
class Config:
from_attributes = True
class TenantResponse(BaseModel):
"""租戶詳細資訊"""
id: int
code: str
name: str
name_eng: Optional[str]
tax_id: Optional[str]
prefix: str
tel: Optional[str]
add: Optional[str]
url: Optional[str]
keycloak_realm: Optional[str]
plan_id: str
max_users: int
storage_quota_gb: int
status: str
is_sysmana: bool
is_active: bool
is_initialized: bool
initialized_at: Optional[datetime]
initialized_by: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class InitializationRequest(BaseModel):
"""租戶初始化請求 (Tenant Admin only)"""
# Step 1: 公司基本資料 (可修改)
company_info: dict = Field(..., description="公司基本資料")
# Step 2: 部門結構
departments: List[dict] = Field(..., description="部門列表")
# Step 3: 系統角色
roles: List[dict] = Field(..., description="角色列表")
# Step 4: 預設配額與服務
default_settings: dict = Field(..., description="預設配額與服務")
class InitializationResponse(BaseModel):
"""初始化完成回應"""
message: str
summary: dict
class Config:
from_attributes = True