sync: auto-sync from HOWARD-HOME at 2026-07-05 20:23:39
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-07-05 20:23:39
This commit is contained in:
@@ -15,7 +15,7 @@ Commands:
|
||||
Write ops (admin) -- ALL preview by default; add --confirm to apply:
|
||||
user-create EMAIL --password P [--name N] [--quota-gb G] [--role R] [--inactive]
|
||||
user-set EMAIL [--active|--inactive] [--quota-gb G] [--name N] [--role R] [--password P]
|
||||
quota EMAIL --gb G set storage quota (GiB)
|
||||
quota EMAIL --gb G set storage quota (decimal GB, as shown by `users`)
|
||||
user-delete EMAIL remove account (irreversible)
|
||||
lib-create NAME [--owner EMAIL] create a library
|
||||
lib-transfer REPO_ID --to EMAIL transfer ownership
|
||||
@@ -197,17 +197,28 @@ def cmd_audit(c: SeafileClient, args) -> int:
|
||||
|
||||
|
||||
# --- write ops (gated) --------------------------------------------------------
|
||||
# Seafile stores user quota in MB (binary unit); its own admin UI treats the quota
|
||||
# field as GiB, i.e. 1 GiB = 1024 MB. We expose --gb and convert the same way.
|
||||
GIB_MB = 1024
|
||||
# Quota units: Seafile's admin API takes quota_total in MB, where 1 MB == 1 MiB
|
||||
# (get_file_size_unit('MB') == 2**20). This tool's read commands (users/usage/audit)
|
||||
# render quota as decimal GB (bytes / 1e9, see _gb/_fmt_quota). To make a quota you
|
||||
# SET round-trip with the quota you later SEE here, --gb is decimal GB and we convert
|
||||
# to the stored MB count accordingly. (Note: Seafile's own web admin UI shows quota
|
||||
# in binary GiB, so the same quota reads ~7% smaller there.)
|
||||
MB_BYTES = 1 << 20 # Seafile's MB unit (1 MiB)
|
||||
GB_BYTES = 1_000_000_000 # this tool's display GB (matches _gb)
|
||||
|
||||
|
||||
def _gb_to_mb(gb) -> int:
|
||||
return int(round(float(gb) * GIB_MB))
|
||||
return int(round(float(gb) * GB_BYTES / MB_BYTES))
|
||||
|
||||
|
||||
def _do_write(args, label: str, detail: dict, fn, reminder: str | None = None) -> int:
|
||||
"""Print the plan; execute only with --confirm. Preview is fully offline."""
|
||||
def _do_write(args, label: str, detail: dict, fn, reminder: str | None = None,
|
||||
on_result=None) -> int:
|
||||
"""Print the plan; execute only with --confirm. Preview is fully offline.
|
||||
|
||||
`on_result(result) -> int`: optional post-execute check that may print its own
|
||||
warnings and return a non-zero exit code (e.g. partial-success responses that
|
||||
still come back HTTP 200). A non-zero return suppresses the [OK] line.
|
||||
"""
|
||||
print(f"[PLAN] {label}")
|
||||
for k, v in detail.items():
|
||||
print(f" {k}: {v}")
|
||||
@@ -215,6 +226,10 @@ def _do_write(args, label: str, detail: dict, fn, reminder: str | None = None) -
|
||||
print("[PREVIEW] not executed. Re-run with --confirm to apply.")
|
||||
return 0
|
||||
result = fn()
|
||||
if on_result is not None:
|
||||
rc = on_result(result)
|
||||
if rc:
|
||||
return rc
|
||||
print(f"[OK] {label}")
|
||||
if reminder:
|
||||
print(f"[REMINDER] {reminder}")
|
||||
@@ -229,7 +244,7 @@ def cmd_user_create(c: SeafileClient, args) -> int:
|
||||
if args.name:
|
||||
detail["name"] = args.name
|
||||
if quota_mb is not None:
|
||||
detail["quota"] = f"{quota_mb} MB (= {args.quota_gb} GiB)"
|
||||
detail["quota"] = f"{args.quota_gb} GB (-> {quota_mb} MB stored)"
|
||||
if args.role:
|
||||
detail["role"] = args.role
|
||||
return _do_write(
|
||||
@@ -250,7 +265,7 @@ def cmd_user_set(c: SeafileClient, args) -> int:
|
||||
if is_active is not None:
|
||||
detail["active"] = is_active
|
||||
if quota_mb is not None:
|
||||
detail["quota"] = f"{quota_mb} MB (= {args.quota_gb} GiB)"
|
||||
detail["quota"] = f"{args.quota_gb} GB (-> {quota_mb} MB stored)"
|
||||
if args.name is not None:
|
||||
detail["name"] = args.name
|
||||
if args.role is not None:
|
||||
@@ -272,7 +287,7 @@ def cmd_user_set(c: SeafileClient, args) -> int:
|
||||
|
||||
def cmd_quota(c: SeafileClient, args) -> int:
|
||||
quota_mb = _gb_to_mb(args.gb)
|
||||
detail = {"email": args.email, "quota": f"{quota_mb} MB (= {args.gb} GiB)"}
|
||||
detail = {"email": args.email, "quota": f"{args.gb} GB (-> {quota_mb} MB stored)"}
|
||||
return _do_write(args, f"set quota for {args.email}", detail,
|
||||
lambda: c.update_user(args.email, quota_mb=quota_mb))
|
||||
|
||||
@@ -301,13 +316,31 @@ def cmd_lib_delete(c: SeafileClient, args) -> int:
|
||||
lambda: c.delete_library(args.repo_id))
|
||||
|
||||
|
||||
def _share_result_check(result) -> int:
|
||||
"""Seafile's admin bulk-share returns HTTP 200 with success[]/failed[] arrays;
|
||||
surface any per-recipient failure instead of reporting a blanket [OK]."""
|
||||
if isinstance(result, dict):
|
||||
failed = result.get("failed") or []
|
||||
if failed:
|
||||
print("[WARNING] some recipients were NOT shared:")
|
||||
for f in failed:
|
||||
who = f.get("email") or f.get("group_id") or f if isinstance(f, dict) else f
|
||||
emsg = f.get("error_msg") or f.get("error") or "failed" if isinstance(f, dict) else "failed"
|
||||
print(f" {who}: {emsg}")
|
||||
ok = result.get("success") or result.get("shared_to") or []
|
||||
print(f"[PARTIAL] shared to {len(ok)}, failed {len(failed)}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_share(c: SeafileClient, args) -> int:
|
||||
stype = "group" if args.group else "user"
|
||||
detail = {"repo_id": args.repo_id, "share_to": args.to,
|
||||
"permission": args.perm, "type": stype}
|
||||
return _do_write(args, f"share library {args.repo_id}", detail,
|
||||
lambda: c.share_library(args.repo_id, args.to,
|
||||
permission=args.perm, share_type=stype))
|
||||
permission=args.perm, share_type=stype),
|
||||
on_result=_share_result_check)
|
||||
|
||||
|
||||
def cmd_unshare(c: SeafileClient, args) -> int:
|
||||
@@ -343,7 +376,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
ust.add_argument("--role"); ust.add_argument("--password")
|
||||
ust.add_argument("--confirm", action="store_true")
|
||||
|
||||
q = sub.add_parser("quota", help="set a user's storage quota (GiB)")
|
||||
q = sub.add_parser("quota", help="set a user's storage quota (decimal GB)")
|
||||
q.add_argument("email"); q.add_argument("--gb", type=float, required=True)
|
||||
q.add_argument("--confirm", action="store_true")
|
||||
|
||||
|
||||
@@ -181,34 +181,22 @@ class SeafileClient:
|
||||
|
||||
The Seafile admin endpoints read from request.data; the share endpoints
|
||||
use QueryDict.getlist('share_to'), which only works for form-encoded
|
||||
bodies (not JSON) -- so all writes go out as
|
||||
application/x-www-form-urlencoded. `form` values that are lists are
|
||||
expanded (doseq) into repeated fields.
|
||||
bodies (not JSON) -- so all writes go out as form-urlencoded via the
|
||||
shared transport (bc.http_json form=). Any 2xx is success; on error we
|
||||
surface the server's message.
|
||||
"""
|
||||
url = f"{self.base}{path}"
|
||||
data = urllib.parse.urlencode(form or {}, doseq=True).encode()
|
||||
req = bc.urllib.request.Request(
|
||||
url, data=data, method=method,
|
||||
headers={"Authorization": f"Token {self._tok()}",
|
||||
"Content-Type": "application/x-www-form-urlencoded"})
|
||||
try:
|
||||
with bc.urllib.request.urlopen(req, timeout=bc.HTTP_TIMEOUT) as resp:
|
||||
status = resp.getcode()
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except bc.urllib.error.HTTPError as exc:
|
||||
status = exc.code
|
||||
raw = exc.read().decode("utf-8", errors="replace")
|
||||
except bc.urllib.error.URLError as exc:
|
||||
raise bc.BackupError(f"Seafile {method} {path} failed: {exc}")
|
||||
status, parsed = bc.http_json(
|
||||
method, f"{self.base}{path}", form=form or {},
|
||||
headers={"Authorization": f"Token {self._tok()}"})
|
||||
if status in (401, 403) and retry_auth:
|
||||
self._token = self._authorize()
|
||||
return self._write(method, path, form, retry_auth=False)
|
||||
try:
|
||||
parsed = json.loads(raw) if raw.strip() else {}
|
||||
except json.JSONDecodeError:
|
||||
parsed = {"_raw": raw[:600]}
|
||||
if status not in (200, 201):
|
||||
msg = parsed.get("error_msg") or parsed.get("detail") or raw[:400]
|
||||
if not (200 <= status < 300):
|
||||
msg = ""
|
||||
if isinstance(parsed, dict):
|
||||
msg = parsed.get("error_msg") or parsed.get("detail") or ""
|
||||
if not msg:
|
||||
msg = json.dumps(parsed)[:400] if parsed else ""
|
||||
raise bc.BackupError(f"Seafile {method} {path} failed (HTTP {status}): {msg}",
|
||||
status=status)
|
||||
return parsed
|
||||
|
||||
Reference in New Issue
Block a user