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:
VMIS Developer
2026-03-15 15:31:37 +08:00
parent 42d1420f9c
commit 62baadb06f
53 changed files with 5638 additions and 195 deletions

View File

@@ -47,6 +47,20 @@ class NextcloudClient:
logger.error(f"NC create_user({username}) failed: {e}")
return False
def set_user_quota(self, username: str, quota_gb: int) -> bool:
try:
resp = httpx.put(
f"{self._base}/ocs/v1.php/cloud/users/{username}",
auth=self._auth,
headers=OCS_HEADERS,
data={"key": "quota", "value": f"{quota_gb}GB"},
timeout=TIMEOUT,
)
return resp.status_code == 200
except Exception as e:
logger.error(f"NC set_user_quota({username}) failed: {e}")
return False
def get_user_quota_used_gb(self, username: str) -> Optional[float]:
try:
resp = httpx.get(
@@ -57,11 +71,57 @@ class NextcloudClient:
)
if resp.status_code != 200:
return None
used_bytes = resp.json().get("ocs", {}).get("data", {}).get("quota", {}).get("used", 0)
quota = resp.json().get("ocs", {}).get("data", {}).get("quota", {})
if not isinstance(quota, dict):
return 0.0
used_bytes = quota.get("used", 0) or 0
return round(used_bytes / 1073741824, 4)
except Exception:
return None
def subscribe_calendar(
self,
username: str,
cal_slug: str,
display_name: str,
ics_url: str,
color: str = "#e9322d",
) -> bool:
"""
為使用者建立訂閱行事曆CalDAV MKCALENDAR with calendarserver source
已存在(405)視為成功201=新建成功。
"""
try:
body = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<x1:mkcalendar xmlns:x1="urn:ietf:params:xml:ns:caldav">\n'
' <x0:set xmlns:x0="DAV:">\n'
' <x0:prop>\n'
f' <x0:displayname>{display_name}</x0:displayname>\n'
f' <x2:calendar-color xmlns:x2="http://apple.com/ns/ical/">{color}</x2:calendar-color>\n'
' <x3:source xmlns:x3="http://calendarserver.org/ns/">\n'
f' <x0:href>{ics_url}</x0:href>\n'
' </x3:source>\n'
' </x0:prop>\n'
' </x0:set>\n'
'</x1:mkcalendar>'
)
url = f"{self._base}/remote.php/dav/calendars/{username}/{cal_slug}/"
resp = httpx.request(
"MKCALENDAR",
url,
auth=self._auth,
content=body.encode("utf-8"),
headers={"Content-Type": "application/xml; charset=utf-8"},
timeout=TIMEOUT,
verify=False,
)
# 201=建立成功, 405=已存在(Method Not Allowed on existing resource)
return resp.status_code in (201, 405)
except Exception as e:
logger.error(f"NC subscribe_calendar({username}, {cal_slug}) failed: {e}")
return False
def get_total_quota_used_gb(self) -> Optional[float]:
"""Sum all users' quota usage"""
try: