Build 2602261605: invite trace and cross-system user lifecycle

This commit is contained in:
2026-02-26 16:06:09 +13:00
parent bd3c0bdade
commit 1b1a3e233b
13 changed files with 976 additions and 16 deletions

View File

@@ -30,6 +30,8 @@ from ..db import (
set_user_jellyseerr_id,
set_setting,
set_user_blocked,
delete_user_by_username,
delete_user_activity_by_username,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
set_user_profile_id,
@@ -55,6 +57,8 @@ from ..db import (
create_signup_invite,
update_signup_invite,
delete_signup_invite,
get_signup_invite_by_code,
disable_signup_invites_by_creator,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -137,6 +141,136 @@ SETTING_KEYS: List[str] = [
]
def _http_error_detail(exc: Exception) -> str:
try:
import httpx # local import to avoid hard dependency in static analysis paths
if isinstance(exc, httpx.HTTPStatusError):
response = exc.response
body = ""
try:
body = response.text.strip()
except Exception:
body = ""
if body:
return f"HTTP {response.status_code}: {body}"
return f"HTTP {response.status_code}"
except Exception:
pass
return str(exc)
def _user_inviter_details(user: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if not user:
return None
invite_code = user.get("invited_by_code")
if not invite_code:
return None
invite = get_signup_invite_by_code(str(invite_code))
if not invite:
return {
"invite_code": invite_code,
"invited_by": None,
"invite": None,
}
return {
"invite_code": invite.get("code"),
"invited_by": invite.get("created_by"),
"invite": {
"id": invite.get("id"),
"code": invite.get("code"),
"label": invite.get("label"),
"created_by": invite.get("created_by"),
"created_at": invite.get("created_at"),
"enabled": invite.get("enabled"),
"is_usable": invite.get("is_usable"),
},
}
def _build_invite_trace_payload() -> Dict[str, Any]:
users = get_all_users()
invites = list_signup_invites()
usernames = {str(user.get("username") or "") for user in users}
nodes: list[Dict[str, Any]] = []
edges: list[Dict[str, Any]] = []
for user in users:
username = str(user.get("username") or "")
inviter = _user_inviter_details(user)
nodes.append(
{
"id": f"user:{username}",
"type": "user",
"username": username,
"label": username,
"role": user.get("role"),
"auth_provider": user.get("auth_provider"),
"created_at": user.get("created_at"),
"invited_by_code": user.get("invited_by_code"),
"invited_by": inviter.get("invited_by") if inviter else None,
}
)
invite_codes = set()
for invite in invites:
code = str(invite.get("code") or "")
if not code:
continue
invite_codes.add(code)
nodes.append(
{
"id": f"invite:{code}",
"type": "invite",
"code": code,
"label": invite.get("label") or code,
"created_by": invite.get("created_by"),
"enabled": invite.get("enabled"),
"use_count": invite.get("use_count"),
"remaining_uses": invite.get("remaining_uses"),
"created_at": invite.get("created_at"),
}
)
created_by = invite.get("created_by")
if isinstance(created_by, str) and created_by.strip():
edges.append(
{
"id": f"user:{created_by}->invite:{code}",
"from": f"user:{created_by}",
"to": f"invite:{code}",
"kind": "created",
"label": "created",
"from_missing": created_by not in usernames,
}
)
for user in users:
username = str(user.get("username") or "")
invited_by_code = user.get("invited_by_code")
if not isinstance(invited_by_code, str) or not invited_by_code.strip():
continue
code = invited_by_code.strip()
edges.append(
{
"id": f"invite:{code}->user:{username}",
"from": f"invite:{code}",
"to": f"user:{username}",
"kind": "invited",
"label": code,
"from_missing": code not in invite_codes,
}
)
return {
"users": users,
"invites": invites,
"nodes": nodes,
"edges": edges,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
def _admin_live_state_snapshot() -> Dict[str, Any]:
return {
"type": "admin_live_state",
@@ -835,7 +969,7 @@ async def get_user_summary(username: str) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats}
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
@router.get("/users/id/{user_id}")
@@ -845,7 +979,7 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats}
return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
@router.post("/users/{username}/block")
@@ -860,6 +994,98 @@ async def unblock_user(username: str) -> Dict[str, Any]:
return {"status": "ok", "username": username, "blocked": False}
@router.post("/users/{username}/system-action")
async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
action = str(payload.get("action") or "").strip().lower()
if action not in {"ban", "unban", "remove"}:
raise HTTPException(status_code=400, detail="action must be ban, unban, or remove")
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.get("role") == "admin":
raise HTTPException(status_code=400, detail="Cross-system actions are not allowed for admin users")
runtime = get_runtime_settings()
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
result: Dict[str, Any] = {
"status": "ok",
"action": action,
"username": user.get("username"),
"local": {"status": "pending"},
"jellyfin": {"status": "skipped", "detail": "Jellyfin not configured"},
"jellyseerr": {"status": "skipped", "detail": "Jellyseerr not configured or no linked user ID"},
"invites": {"status": "pending", "disabled": 0},
}
if action == "ban":
set_user_blocked(username, True)
result["local"] = {"status": "ok", "blocked": True}
elif action == "unban":
set_user_blocked(username, False)
result["local"] = {"status": "ok", "blocked": False}
else:
result["local"] = {"status": "pending-delete"}
if action in {"ban", "remove"}:
result["invites"] = {"status": "ok", "disabled": disable_signup_invites_by_creator(username)}
else:
result["invites"] = {"status": "ok", "disabled": 0}
if jellyfin.configured():
try:
jellyfin_user = await jellyfin.find_user_by_name(username)
if not jellyfin_user:
result["jellyfin"] = {"status": "not_found"}
else:
jellyfin_user_id = jellyfin._extract_user_id(jellyfin_user) # type: ignore[attr-defined]
if not jellyfin_user_id:
raise RuntimeError("Could not determine Jellyfin user ID")
if action == "ban":
await jellyfin.set_user_disabled(jellyfin_user_id, True)
result["jellyfin"] = {"status": "ok", "action": "disabled", "user_id": jellyfin_user_id}
elif action == "unban":
await jellyfin.set_user_disabled(jellyfin_user_id, False)
result["jellyfin"] = {"status": "ok", "action": "enabled", "user_id": jellyfin_user_id}
else:
await jellyfin.delete_user(jellyfin_user_id)
result["jellyfin"] = {"status": "ok", "action": "deleted", "user_id": jellyfin_user_id}
except Exception as exc:
result["jellyfin"] = {"status": "error", "detail": _http_error_detail(exc)}
jellyseerr_user_id = user.get("jellyseerr_user_id")
if jellyseerr.configured() and jellyseerr_user_id is not None:
try:
if action == "remove":
await jellyseerr.delete_user(int(jellyseerr_user_id))
result["jellyseerr"] = {"status": "ok", "action": "deleted", "user_id": int(jellyseerr_user_id)}
elif action == "ban":
result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-disable", "user_id": int(jellyseerr_user_id)}
else:
result["jellyseerr"] = {"status": "ok", "action": "delegated-to-jellyfin-enable", "user_id": int(jellyseerr_user_id)}
except Exception as exc:
result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
if action == "remove":
deleted = delete_user_by_username(username)
activity_deleted = delete_user_activity_by_username(username)
result["local"] = {
"status": "ok" if deleted else "not_found",
"deleted": bool(deleted),
"activity_deleted": activity_deleted,
}
if any(
isinstance(system, dict) and system.get("status") == "error"
for system in (result.get("jellyfin"), result.get("jellyseerr"))
):
result["status"] = "partial"
return result
@router.post("/users/{username}/role")
async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
role = payload.get("role")
@@ -1158,6 +1384,11 @@ async def get_invites() -> Dict[str, Any]:
return {"invites": results}
@router.get("/invites/trace")
async def get_invite_trace() -> Dict[str, Any]:
return {"status": "ok", "trace": _build_invite_trace_payload()}
@router.post("/invites")
async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if not isinstance(payload, dict):

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
@@ -84,6 +85,29 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
return None
def _extract_http_error_detail(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
response = exc.response
try:
text = response.text.strip()
except Exception:
text = ""
if text:
return text
return f"HTTP {response.status_code}"
return str(exc)
async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None:
try:
users = await client.get_users()
if isinstance(users, list):
save_jellyfin_users_cache(users)
except Exception:
# Cache refresh is best-effort and should not block auth/signup.
return
def _is_user_expired(user: dict | None) -> bool:
if not user:
return False
@@ -137,6 +161,11 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
user = verify_user_password(form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("auth_provider") != "local":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.",
)
_assert_user_can_login(user)
token = create_access_token(user["username"], user["role"])
set_last_login(user["username"])
@@ -299,12 +328,61 @@ async def signup(payload: dict) -> dict:
if isinstance(account_expires_days, int) and account_expires_days > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
runtime = get_runtime_settings()
password_value = password.strip()
auth_provider = "local"
local_password_value = password_value
matched_jellyseerr_user_id: int | None = None
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
auth_provider = "jellyfin"
local_password_value = "jellyfin-user"
try:
await jellyfin_client.create_user_with_password(username, password_value)
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code if exc.response is not None else None
duplicate_like = status_code in {400, 409}
if duplicate_like:
try:
response = await jellyfin_client.authenticate_by_name(username, password_value)
except Exception as auth_exc:
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
) from exc
if not isinstance(response, dict) or not response.get("User"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Jellyfin account already exists for that username.",
) from exc
else:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin account provisioning failed: {detail}",
) from exc
except Exception as exc:
detail = _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin account provisioning failed: {detail}",
) from exc
await _refresh_jellyfin_user_cache(jellyfin_client)
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
if candidate_map:
matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map)
try:
create_user(
username,
password.strip(),
local_password_value,
role=role,
auth_provider="local",
auth_provider=auth_provider,
jellyseerr_user_id=matched_jellyseerr_user_id,
auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
@@ -315,6 +393,15 @@ async def signup(payload: dict) -> dict:
increment_signup_invite_use(int(invite["id"]))
created_user = get_user_by_username(username)
if auth_provider == "jellyfin":
set_jellyfin_auth_cache(username, password_value)
if (
created_user
and created_user.get("jellyseerr_user_id") is None
and matched_jellyseerr_user_id is not None
):
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
created_user = get_user_by_username(username)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
set_last_login(username)
@@ -324,6 +411,7 @@ async def signup(payload: dict) -> dict:
"user": {
"username": username,
"role": role,
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},