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>
146 lines
5.1 KiB
Python
146 lines
5.1 KiB
Python
"""
|
||
NextcloudClient — Nextcloud OCS API
|
||
管理 NC 使用者的查詢/建立與 quota 統計。
|
||
"""
|
||
import logging
|
||
from typing import Optional
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
OCS_HEADERS = {"OCS-APIRequest": "true"}
|
||
TIMEOUT = 15.0
|
||
|
||
|
||
class NextcloudClient:
|
||
def __init__(self, domain: str, admin_user: str = "admin", admin_password: str = ""):
|
||
self._base = f"https://{domain}"
|
||
self._auth = (admin_user, admin_password)
|
||
|
||
def user_exists(self, username: str) -> bool:
|
||
try:
|
||
resp = httpx.get(
|
||
f"{self._base}/ocs/v1.php/cloud/users/{username}",
|
||
auth=self._auth,
|
||
headers=OCS_HEADERS,
|
||
timeout=TIMEOUT,
|
||
)
|
||
return resp.status_code == 200
|
||
except Exception:
|
||
return False
|
||
|
||
def create_user(self, username: str, password: Optional[str], quota_gb: int = 20) -> bool:
|
||
try:
|
||
resp = httpx.post(
|
||
f"{self._base}/ocs/v1.php/cloud/users",
|
||
auth=self._auth,
|
||
headers=OCS_HEADERS,
|
||
data={
|
||
"userid": username,
|
||
"password": password or "",
|
||
"quota": f"{quota_gb}GB",
|
||
},
|
||
timeout=TIMEOUT,
|
||
)
|
||
return resp.status_code == 200
|
||
except Exception as e:
|
||
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(
|
||
f"{self._base}/ocs/v2.php/cloud/users/{username}",
|
||
auth=self._auth,
|
||
headers=OCS_HEADERS,
|
||
timeout=TIMEOUT,
|
||
)
|
||
if resp.status_code != 200:
|
||
return None
|
||
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:
|
||
resp = httpx.get(
|
||
f"{self._base}/ocs/v2.php/cloud/users",
|
||
auth=self._auth,
|
||
headers=OCS_HEADERS,
|
||
params={"limit": 500},
|
||
timeout=TIMEOUT,
|
||
)
|
||
if resp.status_code != 200:
|
||
return None
|
||
users = resp.json().get("ocs", {}).get("data", {}).get("users", [])
|
||
total = 0.0
|
||
for uid in users:
|
||
used = self.get_user_quota_used_gb(uid)
|
||
if used:
|
||
total += used
|
||
return round(total, 4)
|
||
except Exception:
|
||
return None
|