feat(vmis): 租戶自動開通完整流程 + Admin Portal SSO + NC 行事曆訂閱
Backend: - schedule_tenant: NC 新容器自動 pgsql 安裝 (_nc_db_check 全新容器處理) - schedule_tenant: NC 初始化加入 Redis + APCu memcache 設定 (修正 OIDC invalid_state) - schedule_tenant: 新租戶 KC realm 自動設定 accessCodeLifespan=600s (修正 authentication_expired) - schedule_account: NC Mail 帳號自動設定 (nc_mail_result/nc_mail_done_at) - schedule_account: NC 台灣國定假日行事曆自動訂閱 (CalDAV MKCALENDAR) - nextcloud_client: 新增 subscribe_calendar() CalDAV 訂閱方法 - settings: 新增系統設定 API (site_title/version/timezone/SSO/Keycloak) - models/result: 新增 nc_mail_result, nc_mail_done_at 欄位 - alembic: 遷移 002(system_settings) 003(keycloak_admin) 004(nc_mail_result) Frontend (Admin Portal): - 新增完整管理後台 (index/tenants/accounts/servers/schedules/logs/settings/system-status) - api.js: Keycloak JS Adapter SSO 整合 (PKCE/S256, fallback KC JS 來源, 自動 token 更新) - index.html: Promise.allSettled 取代 Promise.all,防止單一 API 失敗影響整頁 - 所有頁面加入 try/catch + toast 錯誤處理 - 新增品牌 LOGO 與 favicon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ def _get_lights(db: Session, account_id: int) -> Optional[AccountStatusLight]:
|
||||
sso_result=result.sso_result,
|
||||
mailbox_result=result.mailbox_result,
|
||||
nc_result=result.nc_result,
|
||||
nc_mail_result=result.nc_mail_result,
|
||||
quota_usage=result.quota_usage,
|
||||
)
|
||||
|
||||
@@ -60,6 +61,7 @@ def list_accounts(
|
||||
|
||||
@router.post("", response_model=AccountResponse, status_code=201)
|
||||
def create_account(payload: AccountCreate, db: Session = Depends(get_db)):
|
||||
import secrets
|
||||
tenant = db.get(Tenant, payload.tenant_id)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
@@ -68,8 +70,12 @@ def create_account(payload: AccountCreate, db: Session = Depends(get_db)):
|
||||
account_code = _build_account_code(tenant.prefix, seq_no)
|
||||
email = f"{payload.sso_account}@{tenant.domain}"
|
||||
|
||||
data = payload.model_dump()
|
||||
if not data.get("default_password"):
|
||||
data["default_password"] = account_code # 預設密碼 = 帳號編碼,使用者首次登入後必須變更
|
||||
|
||||
account = Account(
|
||||
**payload.model_dump(),
|
||||
**data,
|
||||
seq_no=seq_no,
|
||||
account_code=account_code,
|
||||
email=email,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import tenants, accounts, schedules, servers, status
|
||||
from app.api.v1 import tenants, accounts, schedules, servers, status, settings
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(tenants.router)
|
||||
@@ -7,3 +7,4 @@ api_router.include_router(accounts.router)
|
||||
api_router.include_router(schedules.router)
|
||||
api_router.include_router(servers.router)
|
||||
api_router.include_router(status.router)
|
||||
api_router.include_router(settings.router)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from app.core.utils import now_tw
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from croniter import croniter
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.schedule import Schedule
|
||||
from app.schemas.schedule import ScheduleResponse, ScheduleUpdate, ScheduleLogResponse
|
||||
from app.schemas.schedule import (
|
||||
ScheduleResponse, ScheduleUpdate, ScheduleLogResponse,
|
||||
LogResultsResponse, TenantResultItem, AccountResultItem,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/schedules", tags=["schedules"])
|
||||
|
||||
@@ -29,9 +33,9 @@ def update_schedule_cron(schedule_id: int, payload: ScheduleUpdate, db: Session
|
||||
s = db.get(Schedule, schedule_id)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||
# Validate cron expression
|
||||
# Validate cron expression (5-field: 分 時 日 月 週)
|
||||
try:
|
||||
cron = croniter(payload.cron_timer, datetime.utcnow())
|
||||
cron = croniter(payload.cron_timer, now_tw())
|
||||
next_run = cron.get_next(datetime)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=422, detail="Invalid cron expression")
|
||||
@@ -67,3 +71,59 @@ def get_schedule_logs(schedule_id: int, limit: int = 20, db: Session = Depends(g
|
||||
.all()
|
||||
)
|
||||
return logs
|
||||
|
||||
|
||||
@router.get("/{schedule_id}/logs/{log_id}/results", response_model=LogResultsResponse)
|
||||
def get_log_results(schedule_id: int, log_id: int, db: Session = Depends(get_db)):
|
||||
"""取得某次排程執行的詳細逐項結果"""
|
||||
from app.models.result import TenantScheduleResult, AccountScheduleResult
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.account import Account
|
||||
|
||||
tenant_results = []
|
||||
account_results = []
|
||||
|
||||
if schedule_id == 1:
|
||||
rows = (
|
||||
db.query(TenantScheduleResult)
|
||||
.filter(TenantScheduleResult.schedule_log_id == log_id)
|
||||
.all()
|
||||
)
|
||||
for r in rows:
|
||||
tenant = db.get(Tenant, r.tenant_id)
|
||||
tenant_results.append(TenantResultItem(
|
||||
tenant_id=r.tenant_id,
|
||||
tenant_name=tenant.name if tenant else None,
|
||||
traefik_status=r.traefik_status,
|
||||
sso_result=r.sso_result,
|
||||
mailbox_result=r.mailbox_result,
|
||||
nc_result=r.nc_result,
|
||||
office_result=r.office_result,
|
||||
quota_usage=r.quota_usage,
|
||||
fail_reason=r.fail_reason,
|
||||
))
|
||||
|
||||
elif schedule_id == 2:
|
||||
rows = (
|
||||
db.query(AccountScheduleResult)
|
||||
.filter(AccountScheduleResult.schedule_log_id == log_id)
|
||||
.all()
|
||||
)
|
||||
for r in rows:
|
||||
account_results.append(AccountResultItem(
|
||||
account_id=r.account_id,
|
||||
sso_account=r.sso_account,
|
||||
sso_result=r.sso_result,
|
||||
mailbox_result=r.mailbox_result,
|
||||
nc_result=r.nc_result,
|
||||
nc_mail_result=r.nc_mail_result,
|
||||
quota_usage=r.quota_usage,
|
||||
fail_reason=r.fail_reason,
|
||||
))
|
||||
|
||||
return LogResultsResponse(
|
||||
log_id=log_id,
|
||||
schedule_id=schedule_id,
|
||||
tenant_results=tenant_results,
|
||||
account_results=account_results,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.utils import now_tw
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, case
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -12,7 +13,7 @@ router = APIRouter(prefix="/servers", tags=["servers"])
|
||||
|
||||
|
||||
def _calc_availability(db: Session, server_id: int, days: int) -> Optional[float]:
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
since = now_tw() - timedelta(days=days)
|
||||
row = (
|
||||
db.query(
|
||||
func.count().label("total"),
|
||||
|
||||
135
backend/app/api/v1/settings.py
Normal file
135
backend/app/api/v1/settings.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.utils import configure_timezone
|
||||
from app.models.settings import SystemSettings
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.account import Account
|
||||
from app.schemas.settings import SettingsUpdate, SettingsResponse
|
||||
from app.services.keycloak_client import KeycloakClient
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def _get_or_create(db: Session) -> SystemSettings:
|
||||
s = db.query(SystemSettings).first()
|
||||
if not s:
|
||||
s = SystemSettings(id=1)
|
||||
db.add(s)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsResponse)
|
||||
def get_settings(db: Session = Depends(get_db)):
|
||||
return _get_or_create(db)
|
||||
|
||||
|
||||
@router.put("", response_model=SettingsResponse)
|
||||
def update_settings(payload: SettingsUpdate, db: Session = Depends(get_db)):
|
||||
s = _get_or_create(db)
|
||||
|
||||
# 啟用 SSO 前置條件檢查
|
||||
if payload.sso_enabled is True:
|
||||
manager = (
|
||||
db.query(Tenant)
|
||||
.filter(Tenant.is_manager == True, Tenant.is_active == True)
|
||||
.first()
|
||||
)
|
||||
if not manager:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="啟用 SSO 前必須先建立 is_manager=true 的管理租戶"
|
||||
)
|
||||
has_account = (
|
||||
db.query(Account)
|
||||
.filter(Account.tenant_id == manager.id, Account.is_active == True)
|
||||
.first()
|
||||
)
|
||||
if not has_account:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="管理租戶必須至少有一個有效帳號才能啟用 SSO"
|
||||
)
|
||||
|
||||
for field, value in payload.model_dump(exclude_none=True).items():
|
||||
setattr(s, field, value)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
configure_timezone(s.timezone)
|
||||
return s
|
||||
|
||||
|
||||
@router.post("/test-keycloak")
|
||||
def test_keycloak(db: Session = Depends(get_db)):
|
||||
"""測試 Keycloak master realm 管理帳密是否正確"""
|
||||
s = _get_or_create(db)
|
||||
if not s.keycloak_url or not s.keycloak_admin_user or not s.keycloak_admin_pass:
|
||||
raise HTTPException(status_code=400, detail="請先設定 Keycloak URL 及管理帳密")
|
||||
|
||||
kc = KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
|
||||
try:
|
||||
token = kc._get_admin_token()
|
||||
if token:
|
||||
return {"ok": True, "message": f"連線成功:{s.keycloak_url}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Keycloak 連線失敗:{str(e)}")
|
||||
|
||||
|
||||
@router.post("/init-sso-realm")
|
||||
def init_sso_realm(db: Session = Depends(get_db)):
|
||||
"""
|
||||
建立 Admin Portal SSO 環境:
|
||||
1. 以管理租戶的 keycloak_realm 為準(無管理租戶時 fallback 至 system settings)
|
||||
2. 確認該 realm 存在(不存在則建立)
|
||||
3. 在該 realm 建立 vmis-portal Public Client
|
||||
4. 同步回寫 system_settings.keycloak_realm(前端 JS Adapter 使用)
|
||||
"""
|
||||
s = _get_or_create(db)
|
||||
if not s.keycloak_url or not s.keycloak_admin_user or not s.keycloak_admin_pass:
|
||||
raise HTTPException(status_code=400, detail="請先設定並儲存 Keycloak 連線資訊")
|
||||
|
||||
# 以管理租戶的 keycloak_realm 為主要來源
|
||||
manager = (
|
||||
db.query(Tenant)
|
||||
.filter(Tenant.is_manager == True, Tenant.is_active == True)
|
||||
.first()
|
||||
)
|
||||
if manager and manager.keycloak_realm:
|
||||
realm = manager.keycloak_realm
|
||||
else:
|
||||
realm = s.keycloak_realm or "vmis-admin"
|
||||
|
||||
client_id = s.keycloak_client or "vmis-portal"
|
||||
|
||||
kc = KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
|
||||
results = []
|
||||
|
||||
# 確認/建立 realm
|
||||
if kc.realm_exists(realm):
|
||||
results.append(f"✓ Realm '{realm}' 已存在")
|
||||
else:
|
||||
ok = kc.create_realm(realm, manager.name if manager else "VMIS Admin Portal")
|
||||
if ok:
|
||||
results.append(f"✓ Realm '{realm}' 建立成功")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Realm '{realm}' 建立失敗")
|
||||
|
||||
# 建立 vmis-portal Public Client
|
||||
status = kc.create_public_client(realm, client_id)
|
||||
if status == "exists":
|
||||
results.append(f"✓ Client '{client_id}' 已存在")
|
||||
elif status == "created":
|
||||
results.append(f"✓ Client '{client_id}' 建立成功")
|
||||
else:
|
||||
results.append(f"✗ Client '{client_id}' 建立失敗")
|
||||
|
||||
# 同步回寫 system_settings.keycloak_realm(前端 Keycloak JS Adapter 使用)
|
||||
if s.keycloak_realm != realm:
|
||||
s.keycloak_realm = realm
|
||||
db.commit()
|
||||
results.append(f"✓ 系統設定 keycloak_realm 同步為 '{realm}'")
|
||||
|
||||
return {"ok": True, "realm": realm, "details": results}
|
||||
Reference in New Issue
Block a user